<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>mud_cookie.log</title>
        <link>https://velog.io/</link>
        <description>Spring 백엔드 개발자</description>
        <lastBuildDate>Tue, 26 Aug 2025 11:50:10 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>mud_cookie.log</title>
            <url>https://velog.velcdn.com/images/mud_cookie/profile/834510c3-b6f2-4afc-9aa9-3212383b8958/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. mud_cookie.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/mud_cookie" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[책 리뷰] 가상 면접 사례로 배우는 대규모 시스템 설계 기초 - 사용자 수에 따른 규모 확장성]]></title>
            <link>https://velog.io/@mud_cookie/%EC%B1%85-%EB%A6%AC%EB%B7%B0CH1-%EA%B0%80%EC%83%81-%EB%A9%B4%EC%A0%91-%EC%82%AC%EB%A1%80%EB%A1%9C-%EB%B0%B0%EC%9A%B0%EB%8A%94-%EB%8C%80%EA%B7%9C%EB%AA%A8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-%EA%B8%B0%EC%B4%88-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%88%98%EC%97%90-%EB%94%B0%EB%A5%B8-%EA%B7%9C%EB%AA%A8-%ED%99%95%EC%9E%A5%EC%84%B1</link>
            <guid>https://velog.io/@mud_cookie/%EC%B1%85-%EB%A6%AC%EB%B7%B0CH1-%EA%B0%80%EC%83%81-%EB%A9%B4%EC%A0%91-%EC%82%AC%EB%A1%80%EB%A1%9C-%EB%B0%B0%EC%9A%B0%EB%8A%94-%EB%8C%80%EA%B7%9C%EB%AA%A8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-%EA%B8%B0%EC%B4%88-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%88%98%EC%97%90-%EB%94%B0%EB%A5%B8-%EA%B7%9C%EB%AA%A8-%ED%99%95%EC%9E%A5%EC%84%B1</guid>
            <pubDate>Tue, 26 Aug 2025 11:50:10 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/mud_cookie/post/29974154-60ee-4ff1-be02-b16e09ddff0e/image.png" alt=""></p>
<br>

<h2 id="이-책을-접한-이유">이 책을 접한 이유</h2>
<p>시스템 설계는 많은 개발자에게 여전히 어렵고 복잡하게 느껴지는 주제이다.
특히, 대규모 시스템 설계는 추상적으로만 접근하기는 어려운 주제이며, 깊이 있는 지식과 실질적인 문제 해결 능력을 요구한다.<br>
나 역시 시스템 설계에 대한 갈증을 느끼던 중, 주변인의 추천을 받아 이 책 <strong>&quot;가상 면접 사례로 배우는 대규모 시스템 설계 기초&quot;</strong> 를 접하게 되었다.
이 책은 단순히 이론적인 지식을 나열하는 것을 넘어, 실제 면접 사례를 통해 시스템 설계의 기본 원칙과 핵심 개념을 학습할 수 있도록 돕는다.
저자들이 유튜브나 온라인에서 시스템 설계 면접을 준비하며 겪었던 경험과 노하우를 바탕으로, 문제 분석부터 아이디어 도출, 그리고 해결책 제시까지의 과정을 체계적으로 설명하고 있다.<br>
복잡하고 추상적으로 느껴질 수 있는 대규모 시스템 설계를 안정적이고 확장 가능한 방식으로 구축하는 데 필요한 실용적인 지침을 제공한다는 점에서 큰 매력을 느껴,
책 내용과 더불어 주관적인 리뷰를 작성하며 챕터 별로 개념을 되새기고자 한다.</p>
<br>

<h2 id="주요-내용-요약">주요 내용 요약</h2>
<br>

<h3 id="단일-서버-single-server">단일 서버 (Single Server)</h3>
<ul>
<li>가장 기본적인 시스템 구성으로, 웹 서버, 데이터베이스, 캐시 등 모든 구성 요소가 하나의 서버에서 실행되는 형태를 설명한다.</li>
<li>클라이언트 요청이 DNS를 통해 IP 주소를 얻고, 웹 서버로 HTTP 요청을 보내 HTML 페이지나 JSON 형식의 응답을 받는 과정을 예시로 보여준다.</li>
<li>외부 서비스(Third-party service)의 활용도 언급한다.</li>
</ul>
<br>

<h3 id="데이터베이스-database">데이터베이스 (Database)</h3>
<ul>
<li>단일 서버의 한계를 넘어설 때, 웹 서버와 데이터베이스를 분리하는 것으로 시작한다.</li>
<li><strong>관계형 데이터베이스(RDBMS)</strong>와 <strong>비관계형 데이터베이스(NoSQL)</strong>의 차이점을 설명하고, 각 데이터베이스의 예시를 제시한다.</li>
<li>데이터베이스의 <strong>다중화(replication)</strong>, 특히 <strong>주-부(master-slave)</strong> 구성을 통해 데이터의 <strong>가용성(availability)</strong> 과 안정성, 읽기 성능 향상, 빠른 데이터 복구(<strong>failover</strong>)를 어떻게 달성하는지 상세히 설명한다.</li>
</ul>
<br>

<h3 id="수직-규모-확장-vs-수평-규모-확장-vertical-scaling-vs-horizontal-scaling">수직 규모 확장 vs 수평 규모 확장 (Vertical Scaling vs. Horizontal Scaling)</h3>
<ul>
<li><strong>수직 규모 확장(scale up)</strong>: 서버의 CPU나 RAM 같은 자원을 늘려 성능을 향상시키는 방법. 구현은 간단하지만, 단일 서버의 한계와 단일 장애점(<strong>SPOF</strong>) 문제가 있다.</li>
<li><strong>수평 규모 확장(scale out)</strong>: 서버를 추가하여 분산 시스템을 만드는 방법으로, 대규모 시스템에서 선호되는 방식이다. 장애 허용(<strong>fault tolerance</strong>)과 확장성 측면에서 유리하다.</li>
</ul>
<br>

<h3 id="캐시-cache">캐시 (Cache)</h3>
<ul>
<li>자주 접근하는 데이터를 임시로 저장하여 읽기 성능을 높이고 데이터베이스 부하를 줄이는 기술이다.    </li>
<li>캐시를 사용하는 일반적인 흐름(캐시 히트/미스)과 읽기 전용(<strong>read-through</strong>) 캐싱 전략을 소개한다.</li>
<li>캐시 사용 시 고려 사항: 캐시 적중률, 데이터 일관성, 캐시 만료, 캐시 오버프로비저닝, 단일 장애점을 회피한다.</li>
</ul>
<br>

<h3 id="콘텐츠-전송-네트워크cdn-content-delivery-network">콘텐츠 전송 네트워크(CDN) (Content Delivery Network)</h3>
<ul>
<li>지리적으로 분산된 서버 네트워크를 통해 이미지, 비디오, HTML 등 정적 콘텐츠를 사용자에게 더 빠르게 제공하는 서비스이다.</li>
<li>사용자의 요청이 가장 가까운 CDN 서버로 라우팅되고, 캐시된 콘텐츠를 제공하거나 원본 서버에서 가져와 캐싱하는 과정을 설명한다.</li>
<li>CDN 사용 시 이점: 응답 지연 감소, 원본 서버 부하 분산, 가용성 향상이다.</li>
<li>고려사항: 비용, 콘텐츠 만료 정책, 큰 파일 처리이다.</li>
</ul>
<br>

<h3 id="무상태stateless-웹-계층-stateless-web-tier">무상태(Stateless) 웹 계층 (Stateless Web Tier)</h3>
<ul>
<li>웹 서버가 클라이언트의 상태(세션 정보 등)를 저장하지 않는 무상태 아키텍처를 설명한다.</li>
<li>상태 정보를 공유 저장소(데이터베이스, 캐시)에 저장해 웹 서버의 수평 확장을 용이하게 하고 시스템 안정성을 높인다.</li>
</ul>
<br>

<h3 id="데이터-센터-data-center">데이터 센터 (Data Center)</h3>
<ul>
<li>시스템의 높은 <strong>가용성(availability)</strong>과 <strong>재해 복구(disaster recovery)</strong>를 위해 다중 데이터 센터(<strong>multi-DC</strong>) 전략을 소개한다.</li>
<li><strong>GeoDNS</strong>를 사용하여 사용자를 가장 가까운 데이터 센터로 라우팅해 응답 지연을 줄이는 방법을 설명한다.</li>
<li>데이터 센터 간 데이터 <strong>동기화(synchronization)</strong>의 중요성을 언급한다.</li>
</ul>
<br>

<h3 id="메시지-큐-message-queue">메시지 큐 (Message Queue)</h3>
<ul>
<li>분산 시스템에서 구성 요소 간의 비동기 통신을 가능하게 하는 기술이다.</li>
<li>생산자(<strong>publisher</strong>)가 메시지를 메시지 큐에 보내고, 소비자(<strong>consumer</strong>)가 메시지를 처리하여 서비스 간 결합도를 낮추고(<strong>decoupling</strong>) 확장성을 높인다.</li>
<li>메시지 <strong>지속성(durability)</strong>과 작업 <strong>지연(latency)</strong> 문제 해결에도 기여한다.</li>
</ul>
<br>

<h3 id="로그-메트릭-그리고-자동화-logs-metrics-and-automation">로그, 메트릭 그리고 자동화 (Logs, Metrics, and Automation)</h3>
<ul>
<li>시스템의 건강 상태 모니터링 및 문제 발생 시 디버깅을 위해 로그와 메트릭은 필수이다.</li>
<li>CPU, 메모리, 디스크 I/O, 네트워크 I/O, QPS(초당 쿼리 수), 응답 지연(<strong>latency</strong>) 등의 메트릭 수집 및 분석이 중요하다.</li>
<li>자동화(<strong>automation</strong>), 특히 <strong>CI/CD</strong> 파이프라인의 중요성도 다룬다.</li>
</ul>
<br>

<h3 id="데이터베이스의-규모-확장-database-scaling">데이터베이스의 규모 확장 (Database Scaling)</h3>
<ul>
<li>데이터베이스의 수평 확장을 위한 <strong>샤딩(sharding)</strong> 또는 <strong>파티셔닝(partitioning)</strong> 기술을 설명한다.</li>
<li>데이터를 여러 데이터베이스 서버에 분산하여 저장하는 샤딩의 원리 예시:</li>
</ul>
<pre><code class="language-text">serverIndex = hash(key) % N  // N: 서버 수</code></pre>
<ul>
<li>샤딩 시 문제점: 데이터 재분배, 핫스팟, 조인/집계 연산의 어려움.</li>
<li>해결을 위한 기술들을 언급하며, <strong>NoSQL</strong> 데이터베이스가 대규모 시스템에서 더 나은 확장성을 제공할 수 있음을 제시한다.</li>
</ul>
<h3 id="백만-사용자-그리고-그-이상-one-million-users-and-beyond">백만 사용자, 그리고 그 이상 (One Million Users, and Beyond)</h3>
<ul>
<li>시스템 확장은 지속적인 반복 작업임을 강조한다.</li>
<li>지금까지의 핵심 개념(무상태 웹 계층, 데이터베이스 다중화, CDN, 캐시, 데이터 센터, 메시지 큐, 모니터링, 자동화 등)을 활용하여 수백만 명 이상의 사용자를 지원하는 시스템 설계 방향을 제시한다.</li>
</ul>
<hr>
<br>
<br>

<h1 id="주제-별-주관적-리뷰">주제 별 주관적 리뷰</h1>
<br>

<h3 id="수직-규모-확장-vs-수평-규모-확장-vertical-scaling-scale-up-vs-horizontal-scaling-scale-out">수직 규모 확장 vs 수평 규모 확장 (Vertical Scaling; Scale Up vs. Horizontal Scaling; Scale Out)</h3>
<p><strong>언제 수직 규모 확장(Scale Up)과 수평 규모 확장(Scale Out)이 필요할까?</strong>
주관적인 Scale Up 이 필요한 경우는 아래와 같다.</p>
<ul>
<li>적은 트래픽이라도 Application 이 안정적으로 작동하지 못 하는 경우</li>
<li>I/O 작업보다 CPU 작업이 압도적으로 많은 경우 (I/O Bound, CPU Bound)<ul>
<li>대부분의 시스템에서는 각 역할이 명시적으로 나뉘어져 있지만, 하나의 Application 에서 수많은 로직과 데이터를 담당하는 경우 역할에 따른 I/O 보다 
CPU 작업이 더 많아, 이럴 경우에는 해당 Application 에 더 많은 리소스를 할당해 주어야 한다.</li>
</ul>
</li>
<li>GC 등의 메모리 정리 작업이 불안정한 경우</li>
<li>Scale Out 에 소요되는 시간보다 트래픽이 몰리는 속도가 더 빠른 경우</li>
<li>시스템의 확장을 원하지만, 물리적으로 Scale Out 하는 것에 있어 부담이 되는 경우<ul>
<li>컨테이너 및 VM과 같은 독립적인 환경을 사용하지 않는 경우 물리 서버를 추가 구매해야 함<ul>
<li>컨테이너를 사용하지 않는 이유 중 하나로는 Docker Daemon 등의 과도한 권한으로 인한 개발자의 보안 제약 사항에 위배될 수 있다.</li>
<li>VM 을 사용하지 않는 이유 중 하나로는 최소 비용으로 물리 서버의 성능을 극대화하고 싶은데, VM 을 관리함으로서 추가적인 리소스가 사용될 수 있다.</li>
<li>물리 서버를 추가 구매한다고 하면, 하드웨어 유지보수 비용까지 고려해야 한다.</li>
</ul>
</li>
<li>물론 이와 같은 불편함을 해소하기 위해 클라우드 + 컨테이너 오케스트레이션(ex: k8s) 환경이 대중적으로 사용되기는 하나, 
아직까지도 비용과 보안 문제로 온프레미스 + 컨테이너를 사용하지 않는 환경도 다수 존재한다.</li>
</ul>
</li>
</ul>
<p>주관적인 Scale Out 이 필요한 경우        </p>
<ul>
<li>단일 장애 지점(SPOF)을 회피하고 싶은 경우</li>
<li>Scale Up 이 더 이상 물리적으로 불가능한 경우</li>
<li>하나의 서버가 모종의 이유로 죽어도 전체적인 영향도를 줄이고 싶은 경우</li>
<li>유동적으로 Scale Out 이 가능하다면, 트래픽 폭증 이벤트에 유연하게 대처가 가능하다. (Scale Up 보다 훨씬 간단하다)</li>
</ul>
<p>위와 같은 이유로 인해 Scale Out 에 대한 여러 도구들이 많이 활용되고 있고, 그 중에 대표적인 것이 K8s 라고 생각한다.
Node 라는 물리적인 공간 안에 Pod 라는 최소 단위로 서비스들을 분산해 배포하며, 각 서비스 별 리소스의 양과 수를 유동적으로 조절할 수 있어 운영 관리에 매우 효율적이다.</p>
<p>그러면 운영 환경에서 Scale Up 과 Scale Out 를 어떻게 조절하는 것이 좋을까?
이번에 대기열 프로젝트를 AWS 에 구축한 내 경험으로 소개해본다. (Spring Boot Application 이 띄워져 있는 서버만 기준으로 설명한다.)</p>
<ul>
<li>기본 EC2 스펙 : c7i.2xlarge (8vCPU, 16GB)</li>
<li>평시 EC2 수 : 2개 + CPU 30% 도달 시 Auto Scaling </li>
<li>이벤트 폭증 시 : 수동으로 16개 지정 (Scale Out 시간보다 트래픽 폭증이 더 빠른 경우를 대비)</li>
</ul>
<p>평시 운영 대기열 서비스는 c7i.2xlarge 스펙으로는 차고 넘친다. 그런데 왜 굳이 더 높은 사양으로 더 많은 서버 비용을 지불할까?</p>
<ul>
<li>Java 특성 상 <strong>Warm-Up</strong> 이라는 개념이 있어, 자주 사용되는 메서드는 JIT 컴파일 캐싱이 되어 더욱 빠르게 처리가 가능하다.<ul>
<li>실제로 약 10,000번 이상 호출된 메서드는 (Warm-Up) 응답시간이 6ms -&gt; 2ms 로 극단적인 성능 향상을 보였다.</li>
<li>JIT 컴파일 캐싱은 횟수에 따라 캐싱 레벨이 달라지게 되는데, Applciaton 수가 많아져 분산이 많이 될수록 JIT 컴파일 캐싱 레벨에 도달하기까지의 트래픽이 더 많이 필요하다.</li>
</ul>
</li>
<li>로직을 수행하는 것 외에, Applicatoin 을 기동하고 VM(OS) 및 AWS <strong>서비스를 유지</strong>하는 데도 일부 <strong>리소스가 사용</strong>된다.<ul>
<li>엄밀히 따지자면 vCPU 8 서버 1개와 vs vCPU 4 서버 2개를 비교했을 때 vCPU 8 서버 1개가 리소스 여유가 많다.</li>
</ul>
</li>
<li>Scale Out 소요 시간보다 <strong>트래픽 폭증이 더 빠른 경우</strong><ul>
<li>EC2 를 새로 생성하려면 VM 에 이미지를 넣어 새로 띄우는 데 까지 약 30~60초 가량이 소요된다.</li>
<li>만약 리소스를 넉넉하게 잡지 않으면, 특정 이벤트로 인해 사용자가 폭증하는 경우 Scale Out 도중에 서버가 죽을 수 있다.</li>
</ul>
</li>
</ul>
<br>

<h3 id="캐시-cache-1">캐시 (Cache)</h3>
<p><strong>대부분의 서비스들은 데이터를 쓰기(삽입, 수정)하는 것보다 읽기 작업이 훨씬 많다.</strong>
그러면 매번 같은 요청에 대해서 같은 응답을 할 텐데, 그 때마다 매번 DB 에 접근하거나 비즈니스 로직을 수행하기에는 서버 리소스가 아깝다.
캐시를 도입하면 성능을 상당 수 개선할 수 있는데, 일반적으로는 Redis 와 같은 분산 Application 에서 동일한 캐시 서버를 바라보는 in-memory 분산 캐시 형태를 사용한다.
다만 이럴 경우 분산 캐시 서버인 Redis 에 대한 SPOF(단일 장애 지점)에 대한 부하가 더욱 커지므로, 
Redis 내 캐시 데이터를 샤딩하고 물리적인 서버 자체를 분산하는 Redis Cluster 와 같은 구조를 고려할 수 있으며,
더 나아가서는 Spring Boot 와 같은 Applicaiton 단에서 로컬 캐시를 활용하는 방법도 고려할 수 있다.</p>
<ul>
<li>Spring Boot 에서 Local Cache 라 함은 대표적으로 Caffeine, Ehcache 또는 간단하게 ConcurrentHashMap 을 활용하는 방법이 있다.</li>
<li>다만 Local Cache 를 사용한다고 하면, TTL 과 분산 Applicatoin 간의 데이터 정합성은 어떻게 보장할 것인가에 대해서도 깊이 고민해야 한다.</li>
</ul>
<p>이미지 파일과 같은 정적 파일들도 CDN 과 같은 콘텐츠 전용 네트워크 서비스에 물리적으로 가까운 위치에 캐싱해두어 응답하게 하는 구조도 존재한다.</p>
<p>사실 캐싱이라는 개념은 수많은 곳에 존재한다.
하드웨어의 CPU 에서도 L1, L2, L3 와 같은 캐시가 존재하며, OS 단에서도, Framework 단에서도 눈에 직접적으로 보이지는 않지만 성능을 향상시키기 위해 다양한 캐시들을 활용하고 있다.
이를 참고하여 캐시를 도입할 때 <strong>캐시에 대한 정책을 어떻게 가져갈 것인지를 깊게 고민해볼 필요가 있다.</strong></p>
<ul>
<li>캐시 만료 시간</li>
<li>LRU(Last Recently Used), LFU(Least Frequently Used) 여부</li>
<li>Cache Miss 시 로직</li>
<li>캐시 데이터 정합성</li>
<li>원본 데이터 수정 시 캐시 데이터 수정 전파 방안</li>
</ul>
<br>

<h3 id="무상태stateless-웹-계층-stateless-web-tier-1">무상태(Stateless) 웹 계층 (Stateless Web Tier)</h3>
<p><strong>Stateless</strong> 와 <strong>Stateful</strong> 한 것은 무슨 차이일까?
한 가지 예시를 들어보자.
웹 서버1은 특정 사용자1이 접속할 때 해당 사용자만의 고유한 세션 정보를 서버에 저장해두고, 그 사용자 요청에 대한 로직을 처리할 때마다 해당 세션을 가지고 검증한다.
이 방식을 사용하기 위해서는, 사용자1에 대한 요청은 서버2가 아닌 서버1로만 요청되어야 한다는 제약이 있다. (Sticky Session)
서버에 &#39;상태&#39;를 가지고 있다는 것이 stateful 하다는 의미이다.</p>
<p>언뜻 보았을 때, 로드밸런싱만 잘 되면 구현이 매우 간단해진다. 다만 아래와 같은 크리티컬한 문제점이 있다.</p>
<ul>
<li>웹 서버가 병목지점이 되어 웹 서버를 Scale Out 했을 때, 기존 세션이 물린 사용자들은 기존 서버로만 접속해야 하므로 Scale Out 에 대한 의미가 퇴색된다.</li>
</ul>
<p>현대의 시스템에서는 이를 해소하기 위해 대부분 stateless 한 아키텍처를 가진다. 대표적으로 아래와 같은 구조가 존재한다.</p>
<ul>
<li>JWT token (어느 서버에서든 token 을 검증할 수 있음)</li>
<li>분산 세션 저장소 활용 (Redis 등)</li>
</ul>
<p>하지만 stateful 한 아키텍처가 필요할 때도 있다.
TCP 연결의 오버헤드를 줄이기 위해 SSE (Server-Sent Events) 또는 WebSocket 과 같은 단/양방향 연결을 Stateful 하게 구성할 수 있다.</p>
<ul>
<li>SSE : 서버가 클라이언트에게 한 번의 연결로 정보를 여러 번 전달해야 하는 경우 (ex: 증권)</li>
<li>WebSocket : 채팅과 같이 한 번의 연결로 양방향 통신이 필요한 경우</li>
</ul>
<br>

<h3 id="로그-메트릭-그리고-자동화-logs-metrics-and-automation-1">로그, 메트릭 그리고 자동화 (Logs, Metrics, and Automation)</h3>
<p>서비스에 문제 발생 시 원인 분석을 위해 로그는 필수적이다.
이에 더해 서버 또는 인프라 측면에서의 리소스 사용 등의 지표를 파악하기 위해 metric 들을 수집할 수 있다.</p>
<p>대표적인 Metric 을 수집해 서비스를 실시간으로 모니터링 할 수 있는 도구로는 Jeniffer APM(상용), Pinpoint(오픈소스) 등이 있다.
이외 내가 많이 활용하는 것은 Prometheus + Grafana 조합으로, </p>
<ul>
<li>Prometheus로 여러 서버 및 인프라의 metric 들을 수집(Pull 방식)해 시계열 데이터(Time Series DB) 로 저장하고,</li>
<li>Grafana 에서 Prometheus 와 연동해 내가 보고 싶은 metric 들을 커스텀한 대시보드에 시간대별로 시각화한다.</li>
</ul>
<p>Prometheus 같은 경우는 가장 대중적인 Time Series DB 로 수많은 application 뿐 아니라 OS, 오케스트레이션 도구까지도 데이터를 쉽게 수집할 수 있다.
Grafana 역시 가장 대중적인 시계열 데이터 시각화 도구로, Prometheus 뿐 아니라 다양한 데이터(RDB, NoSQL, AWS Cloudwatch)들도 쉽게 시각화하고 임계치를 지정해 알림까지 자동화할 수 있다.</p>
<p>최근 운영에서 발생한 TCP 연결 중 Socket overflow, SYN Drop 과 같은 현상의 원인을 분석하다보니 OS 레벨의 Metric 수집도 필수임을 다시 한 번 체감한다.</p>
<br>

<h3 id="데이터베이스의-규모-확장-database-scaling-1">데이터베이스의 규모 확장 (Database Scaling)</h3>
<p>2013년 Stack Overflow 에서는 단일 DB 서버 1대만으로 천 만명의 사용자들을 견뎌냈다고 한다.
다만 이것은 굉장히 극적인 케이스로, 아래와 같은 문제가 존재한다.</p>
<ul>
<li>서버 Scale Up 의 한계</li>
<li>SPOF (단일장애지점) 리스크</li>
<li>최적화되지 않은 비용</li>
</ul>
<p>이를 해결하기 위해 <strong>샤딩(Sharding)</strong> 을 활용할 수 있다. 부가적으로 동일 서버이지만 테이블을 분산하는 <strong>파티셔닝(Partitioning)</strong> 을 활용할 수도 있다.
RDBMS 기준으로 간단하게만 말하자면,</p>
<ul>
<li>파티셔닝은 동일 RDBMS 서버 내에서 하나의 테이블을 여러 테이블로 쪼개는 방식</li>
<li>샤딩은 하나의 테이블을 여러 RDBMS 서버에 분산하는 방식
으로 생각하면 된다.</li>
</ul>
<p>각각의 장단점이 명확하므로, 상황에 맞게 적절하게 구현해야 한다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>파티셔닝</th>
<th>샤딩</th>
</tr>
</thead>
<tbody><tr>
<td>물리적 위치</td>
<td>한 DB 인스턴스 내</td>
<td>여러 DB 인스턴스로 분산</td>
</tr>
<tr>
<td>관리 주체</td>
<td>DB 엔진 자동 관리</td>
<td>애플리케이션/미들웨어 관리</td>
</tr>
<tr>
<td>목적</td>
<td>성능 최적화(Scale-up)</td>
<td>수평 확장(Scale-out)</td>
</tr>
<tr>
<td>조인/트랜잭션</td>
<td>완전 지원</td>
<td>추가 계층 필요</td>
</tr>
<tr>
<td>장애 영향</td>
<td>전체 DB에 영향</td>
<td>샤드 단위 격리</td>
</tr>
<tr>
<td>장점</td>
<td>쿼리 성능 향상, 관리 용이, 백업/복구 편리</td>
<td>용량 확장 가능, 장애 격리 가능, 확장성 뛰어남</td>
</tr>
<tr>
<td>단점</td>
<td>조인 비용 증가, 무결성 위험</td>
<td>복잡도 증가, 개발 비용 상승, ACID 지원 한계</td>
</tr>
</tbody></table>
<p>결론적으로 데이터를 골고루 분할해야 좋다는 것인데, <strong>핫스팟 키(hospot key)</strong> 문제로도 불리는 고려사항이 있다.</p>
<ul>
<li>저스틴 비버, 리오넬 메시와 같은 유명인사가 같은 샤드에 저장된다고 할 때, read 연산이 해당 샤드(파티셔닝)에 몰리는 현상이 발생할 수 있다.</li>
<li>이를 위해 데이터 양의 분포가 고루 이루어 졌다고 하더라도, 쿼리 양에 따라서도 샤딩 및 파티셔닝 키 전략에 대해 깊이 고민할 필요가 있다.</li>
</ul>
<p>또한 데이터 재 샤딩(resharding) 현상도 존재할 수 있다.</p>
<ul>
<li>초기에 데이터를 고루 분포했다고 하더라도, 서비스 운영을 지속하다보면 특정 샤드(파티셔닝)에 데이터가 쏠릴 수 있다.</li>
<li>해당 샤드에 할당된 공간 소모가 다른 샤드에 비해 빠르게 소모되는 것(<strong>샤드 소진; shard exhaustion</strong>)을 방지하기 위해, 
  샤드 키 계산 함수를 변경하고 데이터를 재배치해야 한다. <strong>안정 해시(consistent hashing)</strong> 기법을 활용해 해결 가능한데, 자세한 것은 나중에 설명한다.</li>
</ul>
<hr>
<p>여기까지 &lt;가상 면접 사례로 배우는 대규모 시스템 설계 기초&gt; 책에 대해 다루어보았다.
시스템 설계의 가장 기초적인 부분을 다뤘지만, 
이를 넘어 실제 내가 경험했던 부분을 되짚어보고 여러 복합적인 상황을 가정했을 때 어떻게 해결할 것인가에 대해 깊이 고민하는 것을 이 책을 통해 많은 도움을 받았다.</p>
<br>

<blockquote>
<p>&lt;가상 면접 사례로 배우는 대규모 시스템 설계 기초&gt; - 알렉스 쉬</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[대기열 프로젝트 구축]]></title>
            <link>https://velog.io/@mud_cookie/%EB%8C%80%EA%B8%B0%EC%97%B4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@mud_cookie/%EB%8C%80%EA%B8%B0%EC%97%B4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Sun, 10 Aug 2025 14:54:26 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p><br><br></p>
<p>서비스 특성 상 매월 1일 특정시간에 선착순으로 충전 시 인센티브를 제공해, 트래픽이 짧은 시간 내 폭증하는 이벤트가 존재한다. <br>
또한 <strong>민생회복 소비쿠폰</strong> 등의 대규모 이벤트에도 대비해야 되었다.</p>
<p>다만 코어 인프라에 인입되는 트래픽이 일정 수준 이상일 경우 서비스 장애가 발생하였고, 
과거에 트래픽을 분 단위로 특정 수 만큼 인입되게 하는 대기열 솔루션 (트레이서라고 칭함)이 존재했다. <br></p>
<br>

<p>다만 그 트레이서(대기열 솔루션)도 일정 수 이상의 사용자가 인입되면 대기열 자체에도 장애가 발생해, 
이를 해결하기 위해 직접 대기열 프로젝트를 구축하는 TF 팀에 참여하게 되었다.</p>
<p><br><br></p>
<hr>
<p><br><br></p>
<h1 id="구-대기열-솔루션-트레이서-장애-원인-분석">구 대기열 솔루션 (트레이서) 장애 원인 분석</h1>
<p><br><br></p>
<p>구 대기열 솔루션 (트레이서)는 외부 솔루션이라, 설정 및 소스코드 분석이 제한적일 뿐 아니라 해당 개발사와도 연락이 되지 않는 상황이었다.
그래서 분석할 수 있는 최소한의 메트릭들을 분석하던 중, <br></p>
<p>아래와 같은 Linux 메트릭 정보를 확인해, TCP 통신 중 Socket Overflow에 대한 오류 원인 분석을 <br>
<a href="https://velog.io/@mud_cookie/Socket-Overflow-%EB%B6%84%EC%84%9D-listen-queue-of-a-socket-overflowed">Socket Overflow 분석</a>
에 정리해 두었다.</p>
<pre><code>2063660 times the listen queue of a socket overflowed
2245967 SYNs to LISTEN sockets dropped</code></pre><p><br><br></p>
<hr>
<p><br><br></p>
<h1 id="요구사항">요구사항</h1>
<br>

<ul>
<li>최대한 많은 양의 사용자를 대기열 내 수용할 수 있어야 함.</li>
<li>대기열 서버와 코어 인프라는 물리적으로 분리되어야 한다. (장애 전파 최소화 및 보안)</li>
<li>사용자가 대기열 서버로부터 &quot;해당 화면 진입 가능&quot; 이라고 명시적으로 응답 받아야지만 해당 화면에 진입함.</li>
<li>사용자가 대기열 서버로부터 &quot;해당 화면 진입 불가능&quot; 이라고 응답받은 경우는 3초마다 다시 Polling 하며, 이 때 본인의 순번과 예상 대기시간을 응답받는다.</li>
<li>특정 화면(Zone)에는 1분마다 N 명의 사용자만 인입되도록 설정할 수 있어야 함</li>
<li>1분 내에서도 임계치만큼의 사용자들이 순번대로 천천히 유입되어야 함.</li>
<li>Zone 은 App(지자체) 별, App 내 화면 별로 구성되어 있어 그 수가 수백 단위.</li>
<li>예상 대기시간을 초 단위로 보여주어야 함.</li>
<li>1분동안 Polling 하지 않은 유저는 대기열에서 삭제해야 함. (App 에서는 사용자가 대기열에서 이탈했는지 명시적으로 알 수 없음)</li>
<li>App -&gt; 트레이서와의 호출 방식 및 구현 방식을 신규 대기열 프로젝트에 그대로 유지해야 함.<ul>
<li>사용자는 3초마다 Polling 을 통해 자신의 순번과 예상 대기시간을 실시간으로 확인</li>
</ul>
</li>
<li>Zone 별 대기열의 상태와 인프라 리소스 사용률을 실시간 모니터링 할 수 있어야 함.</li>
<li>서버 비용 최소화</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/38d57dbf-0a4c-43a3-b865-e9c782becfbd/image.png" alt="1-요구사항"></p>
<p><br><br></p>
<hr>
<p><br><br></p>
<h1 id="설계-과정">설계 과정</h1>
<p><br><br></p>
<h2 id="기술-선택">기술 선택</h2>
<br>

<h3 id="redis-cluster">Redis Cluster</h3>
<p>기술적으로 아래 기능에 대해 가장 성능이 중요한 것을 선택해야 했다.</p>
<ul>
<li>하나의 Zone 에 대해 수 많은 사용자가 동시에 대기열에 등록되며 순번을 빠르게 조회할 수 있어야 함.</li>
<li>하나의 데이터에 대해 쓰기 작업에 대한 동시성이 매우 높음.</li>
</ul>
<br>

<p>이로 인해, 하나의 데이터에 경합을 최소화 하기 위해 Redis 를 선택.
    -&gt; Main Command 작업은 Single Thread 로 동작하므로 Lock 으로 인한 성능 저하가 일어나지 않는다.
    -&gt; 저수준 (C언어) 으로 구현되어 어셈블리 만큼의 성능을 발휘할 수 있다.</p>
<br>    

<p>또한, Redis 는 다양한 자료구조를 제공한다.</p>
<ul>
<li>ZSET (Sorted Set) 을 통해 사용자의 순번을 매우 빠르게 조회 가능하다.</li>
<li>ZSET 은 특정 key 값에 대해 TimeStamp value 값으로 정렬되어 있고, 내부적으로 Skip List (다중 연결 리스트) + Hash Table 로 구성되어 있다.</li>
<li>단일/범위 조회 시 Full Scan 하지 않고 Skip List 의 내부 Size 만큼 데이터를 건너뛰며 조회 가능하다.</li>
<li>결론적으로, O(Log N) 의 시간복잡도로 단일 조회가 가능하다.</li>
<li>이로 인해 3초마다 Polling 을 통해 순번을 조회해야 하는 부하를 높은 성능으로 처리할 수 있다.</li>
</ul>
<br>

<p>Zone 별로 독립적인 물리 서버에서 연산하는 구조를 위해 Cluster 구조를 선택.</p>
<ul>
<li>기존 사내에서는 Redis Sentinel 만 사용하고 있었음. </li>
<li>Redis Cluster 구조는 데이터를 샤딩해 여러 서버에 분산 저장하며, 이로 인해 Zone 별로 독립적인 리소스를 사용할 수 있다.</li>
<li>샤딩은 key 기준 CRC16 알고리즘으로 각 Redis Node 에 분산 저장한다.</li>
</ul>
<br>

<p>Redis 의 오픈소스 버전인 Valkey 를 사용하지 않은 이유</p>
<ul>
<li>구현 당시인 2025.05 당시에는 Valkey 8.1 가 Beta 버전에서 공식 버전으로 올라온지 얼마 되지 않아, 신뢰도가 부족했음.</li>
<li>Valkey 공식 문서에서는 Redis 보다 일부 성능이 더 뛰어나다고 명시되었지만, 릴리즈 노트에는 수 많은 버그 픽스들이 업데이트 되고 있었음.</li>
</ul>
<p>Redis 8.0 버전을 사용한 이유</p>
<ul>
<li>당시 Redis 8.0 버전이 공식 버전으로 출시</li>
<li>7.2.5 대비 명령어 처리 속도 최대 87% 개선 (ZSET 은 최대 61% 개선)</li>
<li>I/O 스레드 엔진 재설계로, 멀티코어 환경에서 처리량이 최대 112% 개선됨. (io-threads)</li>
<li>Replication 성능 및 메모리 효율 강화 : Replication(복제) 지연 18% 단축</li>
<li><a href="https://redis.io/blog/redis-8-ga/">https://redis.io/blog/redis-8-ga/</a></li>
</ul>
<p>AWS ElastiCache 를 사용하지 않은 이유</p>
<ul>
<li>부하테스트 결과 직접 구축한 Redis 보다 성능이 훨씬 떨어졌음.</li>
<li>클러스터 확장 자동화, 백업 기능 추상화 등 운영자 편의를 위해 고도화된 기능들이 오히려 성능에 좋지 않은 영향을 끼친 것으로 예상.</li>
<li>디테일한 튜닝이 불가.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/33962f69-c1b2-491c-ae5f-9dfd8162917c/image.png" alt="2-Redis8"></p>
<br>

<h3 id="virtual-thread">Virtual Thread</h3>
<br>

<p>사내 기본 개발 환경인 Spring Boot 2.x 를 사용하면서, 항상 무거운 Thread Pool 으로 인한 성능 저하에 대해 항상 고민을 했었다.
이를 해결할 Stream 기반 Webflux 도 찾아보았으나, 기술 패러다임이 기존 MVC 구조와는 크게 달라 개발 생산성 및 유지보수에 문제가 있었다.
기술 검토 중, Java 21 부터 공식적으로 지원하는 Virtual Thread 를 찾아보며 기존 개발 구조를 유지하면서 개선이 가능할 것이라는 판단을 했다.</p>
<br>

<p><strong>기존 Spring Boot 기본 MVC 모델의 한계</strong></p>
<ul>
<li>Thread Per Request 구조</li>
<li>하지만 여기서 사용되는 Thread 는 OS 에서 직접 관리 (스케줄링) 하는 Platform Thread</li>
<li>Platform Thread 는 생성 비용 (그래서 Thread Pool 을 사용하기는 함), 컨텍스트 스위칭 (매번 시스템 콜이 발생) 비용이 매우 높다.</li>
<li>OS 단에서 관리되는 Thread 이기 때문에, Java 내부적으로 I/O 가 발생해도 자동으로 Context Switching 이 되지 않는다. (해당 Thread 는 I/O 가 발생하면 대기 상태)</li>
</ul>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2a602146-bc96-4db1-acae-c7646475025f/image.png" alt="3-spring-mvc"></p>
<p><strong>Virtual Thread 의 구조 및 장점</strong></p>
<ul>
<li>Platform Thread (OS Thread 가 관리하며 1:1 매핑되는 쓰레드) 에 여러 개의 Virtual Thread 가 마운트 되어 사용되는 구조.</li>
<li>OS 는 Platform Thread 에 대해서만 스케줄링을 하고, 이에 마운트 된 Virtual Thread 는 JVM 단에서 매우 경량화된 스케줄링을 한다.</li>
<li>WebFlux 와 같이 기존의 개발 패러다임을 바꾸지 않고, 기존의 Thread 를 그대로 상속해 사용하는 구조이므로 구조 변경 없이 성능 개선이 가능하다.</li>
<li>JVM 에서 스케줄링 되므로, I/O 발생을 인식해 자동으로 Context Switching 이 가능하다.</li>
<li>생성시간과 컨텍스트 스위칭, 메모리 비용이 매우 적다.</li>
</ul>
<br>

<p><strong>Virtual Thread 쓰레드 생성/스케줄 속도</strong></p>
<table>
<thead>
<tr>
<th>대상</th>
<th>기본 Thread</th>
<th>Virtual Thread</th>
</tr>
</thead>
<tbody><tr>
<td>메모리 사이즈</td>
<td>~2MB</td>
<td>~50 KB</td>
</tr>
<tr>
<td>생성 시간</td>
<td>~1ms</td>
<td>~10µs</td>
</tr>
<tr>
<td>컨텍스트 스위칭 시간</td>
<td>~100µs</td>
<td>~10µs</td>
</tr>
</tbody></table>
<br>

<p><strong>Virtual Thread 사용 시 유의사항</strong></p>
<ul>
<li>Virtual Thread 설계 사상을 따르면 Thread Pool 을 사용하지 않는 것이 바람직하다.</li>
<li>매번 가상 쓰레드를 생성, 파괴하며 일회성으로 사용하는 것이 기본 사상<ul>
<li>그래서 ThreadLocal 과 같이 Thread 전역적으로 캐싱하는 것은 오히려 메모리만 낭비한다.</li>
<li>대신 ScopedValue 과 같은 대안책이 있다.</li>
</ul>
</li>
<li>대신 백만개 이상의 가상 쓰레드도 무리 없이 생성 가능</li>
<li>SpringBoot 의 worker thread Pool 관련 설정들이 무시됨.</li>
<li>Synchronized 키워드 시 Platform Thread 에 Blocking 전파 현상<ul>
<li>Virtual Thread 는 적은 수의 Platform Thread 에 Mount 하는 형식인데, Synchronized 와 같은 키워드는 Platform Thread 에 영향을 끼친다.</li>
<li>그래서 일반적인 JDBC 와 같이 Synchronized 키워드를 사용하는 라이브러리는 사용하지 않는 것이 좋다.</li>
<li>이 프로젝트는 JDK21 을 사용하며 JDBC는 사용하지 않는다.</li>
<li>JDK 24 에서 Synchronized 키워드에 대한 Virtual Thread 성능 개선이 이루어졌다.</li>
</ul>
</li>
</ul>
<br>

<p><strong>참고 : 사용 버전</strong>
    - JDK : 21
    - Spring Boot : 3.4.0
    - Kotlin : 2.1.0
    - Redis : 8.0</p>
<p><br><br></p>
<h2 id="로직-구현-sliding-window-log">로직 구현 (Sliding Window Log)</h2>
<p>Sliding Window Log는 특정 window 내에서 발생한 이벤트를 기록하고, 그 창이 시간에 따라 이동하면서 오래된 이벤트는 제외하는 방식이다.
Window 내 트래픽을 정밀하게 제어해 임계치 이상의 트래픽은 진입되지 못한다.
해당 알고리즘 자체의 단점은 진입되지 못하는 트래픽도 메모리에 저장되기 때문에 메모리 사용량이 높아질 수 있다는 것인데,
하지만 이 부분은 오히려 진입하지 못한 사용자들의 대기 순번을 지정해 예상 진입시간을 노출하는 요구사항에 오히려 부합한다.</p>
<br>

<p>즉 이 프로젝트에서는 단점 없이 효과적으로 구현할 수 있었을 뿐 아니라, 
<strong>Window 의 사이즈를 운영자 설정사항인 1분 단위가 아닌 더 작은 단위(6초)로도 구현할 수 있어</strong>
<strong>1분 내에서도 특정 구간에서 사용자의 트래픽이 일순간 폭증하는(Burst) 상황에서도 Window 내 임계치에 막혀 트래픽이 비정상적으로 흘러가지 않는다.</strong></p>
<br>

<p>또한 Redis Sorted Set 에서 특정 <strong>유저 Token 에 대한 값은 최초 진입 요청 Timestamp</strong> 값으로 정렬되어 저장할 수 있으므로,
Window 를 특정 분 혹은 구간으로 설정하면 유저 Token 값으로 해당 Window 안에 속하는지 빠르게 판별이 가능하다.
앞서 말했듯이 사용자의 순번을 조회하는 것은 Reids ZSET 내부의 Skip List 자료구조 덕분에 O(Log N) 시간복잡도로 조회가 가능하며,
예상 대기 시간은 Token Timestamp, 순번과 window size + 임계치를 조합해 사용자에게 응답한다.</p>
<br>

<p>여기서 끝이 아니라, <strong>사용자 경험을 높이기 위한 여러 예외사항</strong>들을 처리해야 한다.</p>
<ul>
<li><p><strong>대기열 이탈자로 인한 후순위 사용자들의 무의미한 대기</strong> </p>
<ul>
<li>만약 대기열 후순위에 추가된 사용자가 10,000 번에 위치했는데, 이 사용자가 진입 가능하기 전에 <strong>대기열에서 나가버려도 서버는 명시적으로 알 수 없다.</strong> (앱 강제 종료, 백그라운드 실행)</li>
<li>극단적으로 이러한 사용자들이 5,000 ~ 10,000 모두 <strong>대기열에서 이탈</strong>해버린다면 10,001 순번 사용자들은 이전 사용자들이 무의미한 순번을 가지고 있음에도 최초 예상 대기 시간보다 더 빨리 진입할 수 없게 되어버린다.</li>
<li>이러한 경우를 처리하기 위해, <strong>사용자가 마지막으로 Polling 한 시간</strong> 을 별도 Hash 로 저장해, 특정 주기(ex: 30초)마다 <strong>최근 1분동안 Polling 하지 않은 사용자 Token 들을 대기열에서 삭제</strong>시켜 버린다.</li>
<li>이로 인해 사용자 입장에서는 최초 예상 대기 시간보다 더 빠르게 진입이 가능할 수 있다. 실제로도 운영 상 약 30~40% 의 사용자들이 대기열에서 이탈하는 것으로 확인되었다.<br>
</li>
</ul>
</li>
<li><p><strong>일시적인 사용자 개인 네트워크 지연(Wi-fi 등)으로 인한 Polling 중지 시간동안 현재 Window 가 이미 지나버린 경우</strong></p>
<ul>
<li>사용자의 App 에서 일시적으로 Polling 이 되지 않는 예외사항은 꽤 존재한다. (Wi-fi 순단, App Crash, 핸드폰 성능 문제, 전화로 인한 갑작스런 백그라운드 이동 등)</li>
<li><strong>이 순간동안 사용자의 Token 이 현재 진입 가능한 Window 보다 지나버린 경우에도 진입할 수 있게 해주어야 사용자는 억울하지 않다.</strong></li>
<li>그래서 꼭 Window 내의 Token Timestamp 값만 진입 가능한 것이 아닌, 지나버린 Token 도 진입할 수 있게 해준다.</li>
<li>물론 그 시간은 무제한이 아닌 위 1. 에서 언급한 마지막 Polling 시간 기준 1분이 지난 것들은 삭제시켜 불필요한 메모리 낭비는 방지한다.<br>
</li>
</ul>
</li>
<li><p><strong>대기열이 없어야 되는 평상시임에도, 일시적인 Burst 로 인해 Window 내 임계치에 도달해 대기열이 발생하는 경우</strong></p>
<ul>
<li>대량 트래픽 이벤트가 없을 때에는(평상시) 사용자 경험을 높이기 위해 대기열이 발생하면 안된다.</li>
<li>다만 평상시에도 충분히 광고/홍보/공지성 푸시 및 알림을 발송해 사용자 트래픽이 인입될 수 있다.</li>
<li>이 정도의 트래픽은 대기열 없이 전부 흘려보내도 핵심 인프라에는 영향을 끼치지 못하기 때문에, 이 때에는 사용자들이 대기열에 진입해서는 안 된다.</li>
<li>따라서 큰 1분 단위 Window 내의 작은 단위 6초 Window 내에서는, 6초 단위의 Window 를 엄격하게 처리하지 않는다.<ul>
<li>예시) 1분 동안 10,000 명 진입 가능하게 설정. -&gt; 내부 로직에서는 6초 동안 1,000 명씩 진입 가능</li>
<li>다만 광고성 푸시로 인해 2초동안 2,000 명이 인입된다면 대기열이 걸려야 할까? 아니다. 이 정도는 핵심 인프라가 충분히 버틸 수 있어 오히려 대기열에 걸리면 사용자 경험에 악영향만 끼칠 뿐이다.</li>
<li>이를 대비해, 작은 단위의 Window 가 사용자의 첫 트래픽 진입 요청을 차단해 대기열에 보내는 조건은 <strong>이미 대기열이 걸린 경우</strong> 로 제한한다.</li>
<li>이렇게 구현한다면 평상시에도 1분 내 10,000명이 넘게 진입한 경우에만 대기열이 발생하게 되어 문제가 발생하지 않는다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<p><br><br></p>
<h2 id="인프라-선택-뉴타닉스-vs-aws">인프라 선택 (뉴타닉스 vs AWS)</h2>
<p>사내에서는 전자금융업 보안 상 Public Cloud 사용이 제한적이었다.
그래서 설계 초기 당시 온프레미스 서버에 가상화 솔루션을 설치해 사용하는 뉴타닉스 사용을 지시받았다. (사내에서도 사용 중인 솔루션)</p>
<br>

<p>다만 뉴타닉스의 한계는 아래와 같았다.</p>
<ul>
<li>사내 온프레미스 서버를 사용하므로, 물리적인 서버 축소/확장에 제한이 있었음.<ul>
<li>이로 인해 짧은 이벤트에 대비해야 하는 서버 확장이 어려웠고,</li>
<li>서버를 축소 해야되는 상황에도 놀고 있는 자원들이 많은 것이 문제였음.</li>
</ul>
</li>
<li>가상화 솔루션 특성 상, <strong>CPU Overcommitting</strong> 으로 인한 문제가 발생할 가능성이 높았음.<ul>
<li>CPU Overcommitting : 물리적인 CPU Core 수보다 논리적으로 가상화된 CPU Core 수를 제공.</li>
<li>이로 인해 CPU Bound 가 많은 작업이 수행되면 다른 논리 가상화 CPU 에 영향을 끼칠 수 있음.</li>
<li>신규 대기열 프로젝트 특성 상 성능 극대화를 위해 I/O 보다 CPU Bound 가 많은 작업이 수행되었음.</li>
</ul>
</li>
</ul>
<br>

<p>이로 인해 AWS 의 장점을 비교해 AWS 를 사용해야 하는 이유를 보고했고, </p>
<ul>
<li>물리적인 인스턴스 확장에 거의 제한이 없음.</li>
<li>사용한 만큼만 비용을 지불.</li>
<li>ALB, EC2 오토스케일링 등의 자체 추상화된 서비스를 편리하게 이용할 수 있음.</li>
<li>코어 인프라와의 통신은 일절 없고, 개인화된 정보는 관리하지 않는 완전 독립된 구조로 설계.</li>
</ul>
<p>사용 허가를 받음에 따라 아직은 사내 운영이 미숙한 AWS 에 대해 직접 학습하고 설계 및 검증을 했다.</p>
<p><br><br></p>
<h2 id="인프라-기본-구조">인프라 기본 구조</h2>
<br>

<p><strong>핵심 인스턴스는 모두 EC2 로 띄움</strong></p>
<ul>
<li>대기열 코어 서버 (c7i.2xlarge)<ul>
<li>대기열 인입 요청 전/후처리, Redis 와 Lua Script 통신</li>
<li>오토스케일링 그룹 적용</li>
</ul>
</li>
<li>관리자용 API 서버 (c7i.xlarge)</li>
<li>관리자용 웹 서버 (c7i.xlarge)</li>
<li>Redis Cluster (c7i.2xlarge)<ul>
<li>master 3, replica 3</li>
</ul>
</li>
<li>Resource Monitoring 서버 (c7i.xlarge)<ul>
<li>Prometheus, Grafana</li>
</ul>
</li>
</ul>
<br>

<p><strong>인스턴스 타입 선택 이유 (c7i.xlarge, c7i.2xlarge)</strong></p>
<ul>
<li>c7i.xlarge : 4 vCPU, 8GB</li>
<li>c7i.2xlarge : 8 vCPU, 16GB</li>
<li>vCPU : 물리 코어 수가 아닌 논리 Thread 수</li>
<li>c : 컴퓨팅 최적화 (CPU)</li>
<li>7 : 세대 수 (당시 7이 최신 세대라 가장 성능 및 비용 효율적)</li>
<li>i : Intel 기반 프로세서</li>
</ul>
<br>

<p>c7i 의 특징</p>
<ul>
<li>c7i 는 Intel Zeon 기반, CPU Bound 작업에 최적화</li>
<li>메모리는 DDR4 가 아닌 DDR5 기반<ul>
<li>램 클럭이 높아 Redis 성능 향상</li>
</ul>
</li>
</ul>
<br>

<p>대기열 코어 서버, Redis 만 2xlarge 선택 이유</p>
<ul>
<li>대기열 코어 서버 : 부하테스트 결과, 최소 이중화 인스턴스를 고려해 2xlarge 인스턴스 2개가 평시 트래픽을 여유있게 처리할 수 있는 스펙.</li>
<li>Redis : Redis Command 는 Single Thread 기반으로 동작하지만 I/O, 백업, Replication 등의 작업을 고려해 2xlarge 인스턴스가 적절하다고 판단.</li>
</ul>
<br>

<p><strong>보안 고려</strong></p>
<ul>
<li>모든 SSH 접속은 Bastion 서버를 통해서만 허용<ul>
<li>즉, Bastion 서버로 proxy 해 SSH 터널링으로만 접속할 수 있다. </li>
<li>Bastion 서버의 IP, SSH Port, SSH key, user/pw 정보 + 대상 EC2 서버의 IP, SSH Port, SSH key, user/pw 정보를 알아야만 SSH 접속 가능.</li>
</ul>
</li>
<li>관리자 및 모니터링 서버는 특정 Source IP 접속만 허용</li>
<li>배포 파일 업로드는 사내 Jenkins 서버를 통해서만 허용</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/6b601c41-657b-4bcd-9073-1bf1ae68215a/image.png" alt="4-infra"></p>
<p><br><br></p>
<h2 id="인프라-배포-구조">인프라 배포 구조</h2>
<ul>
<li>빠른 배포를 위해 기본 Properties 및 로그, 모니터링이 적용된 AMI (이미지) 를 커스텀해서 사용</li>
<li>개발자 master 브랜치 push -&gt; Jenkins Pipeline Build (+ 소스 검증, 테스트) -&gt; S3 의 특정 디렉토리에 업로드 (AWS CLI)</li>
<li>운영자 S3 업로드 확인 -&gt; 시작 템플릿의 script 에 S3 의 디렉토리 명시해 버전 업데이트 -&gt; EC2 오토스케일링 그룹 인스턴스 새로고침 (롤링 업데이트 방식)</li>
</ul>
<p><br><br></p>
<h2 id="인프라-모니터링-구조">인프라 모니터링 구조</h2>
<ul>
<li>ALB : 기본적으로 AWS Cloudwatch 에 Metric 들이 저장되므로, Cloudwatch - Grafana 연동을 통해 모니터링</li>
<li>대기열 코어 서버 OS : EC2 내부에 Node-Exporter 를 통해 Prometheus 가 수집해 Grafana 에서 모니터링</li>
<li>대기열 코어 서버 Spring Boot Metric : prometheus actuator 를 통해, Prometheus 가 수집해 Grafana 에서 모니터링</li>
<li>Redis : Grafana 플러그인을 통한 모니터링 (CPU, Memory, I/O, Replication, Slowlog...)</li>
<li>MySQL : Grafana 에서 SQL 조회를 통해 Zone(대기열)의 통계 조회</li>
</ul>
<p><strong>Prometheus 는 어떻게 Auto Scaling 되는 대기열 코어 서버의 각각의 인스턴스들의 Metric 들을 수집하는지</strong>
    - 일정 주기(30s)마다 AWS 에 특정 이름 또는 Auto Scaling Group 에 속한 인스턴스들의 Private IP 들을 조회
    - 조회되는 Private IP 들에게 Metric 수집 요청</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/60baa132-b78a-46be-87ac-c123ea37505c/image.png" alt="5-monitoring"></p>
<p><br><br></p>
<hr>
<p><br><br></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b6e8b8b7-6797-45b0-b367-a6c2ec54718c/image.png" alt="6-bottle-neck"></p>
<h1 id="이벤트-발생-예정-시-사전-작업-사항-병목-지점-파악">이벤트 발생 예정 시 사전 작업 사항 (병목 지점 파악)</h1>
<br>

<h2 id="코어-서버-ec2-scale-out">코어 서버 EC2 Scale Out</h2>
<ul>
<li>트래픽이 몰리는 Spring Boot 인스턴스</li>
<li>AWS EC2 Auto Scalint Group 에 등록</li>
<li>CPU 30% 가 넘어갈 시 Auto Scale Out 되도록 조정<ul>
<li>일반적으로는 CPU 50% 가 권장 조정 값이지만,</li>
<li>서비스 특성 상 트래픽이 빠른 속도로 폭증하므로 보수적으로 적용.</li>
</ul>
</li>
<li>CPU Metric 은 일반적으로 5분 단위로 측정되나, 세부 조정으로 10초 단위로 측정되도록 설정.</li>
<li><strong>다만 이벤트 폭증이 명시적으로 예상될 때에는 EC2 인스턴스가 띄워지는 시간(약 60초) 및 로드밸런서 health check/등록시간도 고려해, 미리 Scale Out 한다.</strong></li>
</ul>
<br>

<h2 id="scale-out-후-warm-up-트래픽-전송">Scale Out 후 Warm Up 트래픽 전송</h2>
<ul>
<li>Java 특성 상 자주 사용되는 코드는 컴파일 캐싱한다.<ul>
<li>JVM 실행 전에는 .java 파일을 바이트코드인 .class 파일로 변환</li>
<li>JVM 실행 후에는 클래스 사용 시점에 클래스 로더가 lazy loading 수행, 로딩된 클래스를 기계어로 변환</li>
</ul>
</li>
<li>JIT 컴파일러는 자주 사용되는 코드를 Hot Spot 으로 지정해 기계어를 캐싱해둔다.<ul>
<li>Tiered Compilation : 호출 순에 따른 최적화 단계<ul>
<li>C1 컴파일러 : 빠르지만 제한된 수준으로 최적화 수행 (기본값 200회)</li>
<li>C2 컴파일러 : 최적화 수준이 높은 컴파일 수행 (기본값 5,000회)</li>
</ul>
</li>
</ul>
</li>
<li>실제로 Warm-Up 전/후 응답 지연시간 확인 시 약 2.5배의 차이가 발생했음. (5<del>6ms -&gt; 2</del>3ms)</li>
</ul>
<br>

<h2 id="alb-load-balancer-lcu-용량-예약-lcu-r-lcu-reservation">ALB (Load Balancer) LCU 용량 예약 (LCU-R; LCU Reservation)</h2>
<ul>
<li>LCU 이전에는 Pre-Warming 이라는 기능으로 제공했었음.</li>
<li>LCU : ALB 가 처리하는 트래픽의 여러 지표를 통합하는 단위</li>
<li>LCU 예약 (LCU Reservation) : <ul>
<li>AWS는 예측 가능한 트래픽 급증에 대비하여 ALB의 최소 용량을 사전에 예약할 수 있는 LCU 예약(LCU-R) 기능을 제공한다.</li>
<li>갑작스러운 트래픽 증가로 인한 ALB 의 5xx 응답 에러를 방지한다.</li>
</ul>
</li>
<li>평시 LCU 사용량 100 이하, 트래픽 급증 시 1,000 이상 사용하므로 사전 LCU 용량 예약을 한다.</li>
<li>LCU 사전 예약은 반영되는 데 까지 1~2시간 소요되며 웹 콘솔에서는 특정 시간에 예약이 불가해 사전에 미리 조정해야 한다.</li>
</ul>
<p><br><br></p>
<hr>
<p><br><br></p>
<h1 id="튜닝">튜닝</h1>
<br>

<h2 id="redis-lua-script">Redis Lua Script</h2>
<ul>
<li>대기열 코어 서버의 핵심 로직은 Lua Script 로 구현</li>
<li>최대한 Redis 와의 I/O 를 줄이고, 명령어를 한 번에 모아서 요청해 처리하도록 한다.<ul>
<li>Zone 에 대한 유저 순번 조회, Zone 분/N초당 임계치 확인, Zone 임계치 내 진입 유저 수 조회, 마지막 Polling 시간 업데이트, 진입 및 대기열 내 유저 삭제 처리 등..</li>
</ul>
</li>
<li>Redis 에서는 명령어 모음인 Lua Script 에 대한 컴파일 캐싱(EVALSHA)을 통해 성능을 향상시킨다.</li>
</ul>
<br>

<h2 id="redis-io-threads">Redis io-threads</h2>
<ul>
<li>Redis 6.0 까지는 완전한 단일 스레드 모델로 동작. 아래 중 1번과 4번 단계가 CPU 시간의 상당 부분을 차지해 I/O 가 주요 병목지점으로 작용함.<ul>
<li>소켓에서 요청 읽기 (socket read)</li>
<li>명령 파싱 (command parsing)</li>
<li>명령 실행 (command execution)</li>
<li>소켓에 응답 쓰기 (socket write)</li>
</ul>
</li>
<li>이후 버전에서는 io 멀티플렉싱으로 인해 io thread 를 여러 개 설정하면 통합 I/O 성능 향상</li>
<li>c7i.2xlarge 에서는 사용 가능한 thread 수가 8이므로 main, backup 용 thread 를 제외해 6개로 설정.</li>
</ul>
<br>

<h2 id="redis-백업-최적화">Redis 백업 최적화</h2>
<ul>
<li>Redis 의 백업 방식은 RDB, AOF 로깅으로 나뉜다. (기본값 : 백그라운드 저장)<ul>
<li>RDB : 스냅샷 방식으로 Redis 인스턴스의 데이터를 파일로 저장<ul>
<li>일정 주기마다 수행되며, 마지막 주기를 기준으로 데이터를 복구할 수 있어 일부 마지막 데이터가 유실될 수 있다.</li>
</ul>
</li>
<li>AOF : Append Only File 방식으로 Redis 인스턴스의 데이터 변경을 파일로 저장<ul>
<li>데이터 삽입, 삭제 등의 모든 과정을 저장</li>
<li>데이터 복구 시 AOF 파일에 기록된 명령어를 순서대로 다시 실행해 데이터를 복구한다.</li>
<li>데이터 복구 시간이 다소 오래 걸린다.</li>
</ul>
</li>
</ul>
</li>
<li>백업 복구 시간을 고려해 RDB 방식을 채택.</li>
<li>BGSAVE 의 fork()로 자식 프로세스를 생성해 저장해 복사 비용이 큰 것을 고려, </li>
<li>Master Node 에서 백업을 수행하면 실제 Command 실행에 따른 CPU 사용이 발생하므로 Replica Node 에서만 수행한다.</li>
</ul>
<br>

<h2 id="jit-컴파일-캐싱-tier-임계치-조정">JIT 컴파일 캐싱 Tier 임계치 조정</h2>
<p>Java 의 JIT(Just-In-Time) 컴파일 캐싱은 JVM에서 실행 성능을 최적화하기 위해 사용하는 기술이다. 
이 기술은 자주 사용되는 바이트코드를 네이티브 머신 코드로 변환하여 heap-off 메모리에 코드 캐시로 저장한다.</p>
<br>

<p>이는 코드의 수행 횟수가 많을 수록 Tier 가 높아져 더 최적화된 수행을 가능하게 만든다.
이 프로젝트에서는 갑작스런 트래픽 급증(Burst)에도 컴파일 캐싱이 늦게 되는 것을 조금이나마 방지하기 위해,
컴파일 최적화 Tier 임계치 옵션을 튜닝한다.</p>
<br>

<pre><code class="language-bash">java -XX:+PrintFlagsFinal -version | grep Threshold | grep Tier
java version &quot;21.0.2&quot; 2024-01-16 LTS
Java(TM) SE Runtime Environment (build 21.0.2+13-LTS-58)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.2+13-LTS-58, mixed mode, sharing)
    uintx IncreaseFirstTierCompileThresholdAt      = 50                                        {product} {default}
     intx Tier2BackEdgeThreshold                   = 0                                         {product} {default}
     intx Tier2CompileThreshold                    = 0                                         {product} {default}
     intx Tier3BackEdgeThreshold                   = 60000                                     {product} {default}
     intx Tier3CompileThreshold                    = 2000                                      {product} {default}
     intx Tier3InvocationThreshold                 = 200                                       {product} {default}
     intx Tier3MinInvocationThreshold              = 100                                       {product} {default}
     intx Tier4BackEdgeThreshold                   = 40000                                     {product} {default}
     intx Tier4CompileThreshold                    = 15000                                     {product} {default}
     intx Tier4InvocationThreshold                 = 5000                                      {product} {default}
     intx Tier4MinInvocationThreshold              = 600                                       {product} {default}</code></pre>
<p><strong>임계치 설명</strong></p>
<ul>
<li>InvocationThreshold : 메서드가 순수하게 호출된 횟수</li>
<li>BackEdgeThreshold : 루프(for, while 등)가 실행된 횟수</li>
<li>CompileThreshold : Invocation과 BackEdge를 모두 고려한 종합 점수</li>
<li>MinInvocationThreshold : 컴파일을 고려하기 위한 최소한의 메서드 호출 횟수</li>
</ul>
<br>

<p><strong>JIT 컴파일 캐싱 Tier</strong></p>
<p>Tier0 : 인터프리터 (Interpreter) - 바이트코드를 한 줄씩 해석해서 실행.
Tier1 : C1 컴파일러 (Simple C1 compiled code) - 프로파일링 정보 없이, 매우 기본적인 최적화만 수행하여 빠르게 컴파일.
Tier2 : C1 컴파일러 (Limited C1 compiled code) - 일부 프로파일링 정보 수집.
Tier3 : C1 컴파일러 (Full C1 compiled code) - 모든 프로파일링 정보를 수집하여 C2 컴파일러가 사용할 수 있도록 준비.
Tier4 : C2 컴파일러 (C2 compiled code) - C1이 수집한 프로파일링 정보를 바탕으로 가장 높은 수준의 최적화를 수행. 컴파일 속도는 느리지만 실행 속도는 가장 빠름.</p>
<p>일반적으로 Tier0 -&gt; Tier3 -&gt; Tier4 단계로 상승함.
Tier1, Tier2 는 특수한 경우에 사용되는데,</p>
<ul>
<li>Tier1 : 메서드의 복잡성이 낮아 추가 최적화가 불필요하다고 판단할 때 사용.</li>
<li>Tier2 : C2 컴파일러 큐가 가득 찬 상황에서 사용되는 특수한 레벨. 임시적 성격이 강하며, 큐에 여유가 생기면 Tier3 또는 Tier4로 재컴파일된다.</li>
</ul>
<br>

<p><strong>Tier1 ~ Tier4 임계치 일괄 조정</strong></p>
<pre><code class="language-bash"># Threshold 를 일괄 0.5배로 조정. 제일 간단한 설정 방법
-XX:CompileThresholdScaling=0.5</code></pre>
<br>

<p><strong>Tier 임계치 조정 시 유의사항</strong></p>
<ul>
<li>과도한 코드 캐시 메모리 사용 주의 :<ul>
<li>c7i.2xlarge 에서는 16GB 메모리. </li>
<li>heap 에는 4GB 할당해 충분할 뿐더러 로직 관련한 코드 라인 수가 많지 않음.</li>
<li>Virtual Thread 또한 메모리 사용량 최적화에 기여</li>
</ul>
</li>
<li>Cold Start 직후 트래픽 급증 시 컴파일 주의 :<ul>
<li>트래픽 급증 예상되는 이벤트에서는 사전 Warm Up 트래픽으로 방어</li>
<li>예상치 못한 트래픽 급증에는 일순간 C2 Compile 과정에서 순간 CPU 사용량이 증가할 수 있으나, 
그 순간은 사용자의 입장에서는 매우 짧을 뿐더러 빠른 Warm Up 을 위한 일종의 트레이드오프</li>
</ul>
</li>
</ul>
<br>

<p><strong>차후 개선 고려사항</strong>
AOT (Ahead-of-Time) 컴파일 : </p>
<ul>
<li>GraalVM Native Image 를 통해 JIT 없이 코드를 네이티브로 컴파일해 실행 중 컴파일로 인한 성능 이슈를 아예 고려하지 않을 수 있다.</li>
</ul>
<p><br><br></p>
<hr>
<p><br><br></p>
<h1 id="트러블슈팅">트러블슈팅</h1>
<h2 id="redis-zrange-8000-개-이상-범위-조회-시-에러-발생">Redis ZRANGE 8,000 개 이상 범위 조회 시 에러 발생</h2>
<p>부하테스트 도중,
Redis Lua Script 제한으로 인해 ZRANGE 등의 unpack() 함수는 8,000 개 이상의 범위를 한꺼번에 처리할 시 아래와 같은 에러 발생</p>
<pre><code>(error) ERR Error running script (call to f_xxx): user_script:line_number: too many results to unpack</code></pre><p>이로 인해 5,000 개의 chunk size 조절로 반복문 처리.</p>
<p><br><br></p>
<hr>
<p><br><br></p>
<h1 id="부하테스트">부하테스트</h1>
<p><br><br></p>
<h2 id="부하테스트-환경">부하테스트 환경</h2>
<ul>
<li>부하테스트 도구 : K6<ul>
<li>고루틴 기반 경량화된 부하테스트 도구, 동일 리소스에서 JMeter 대비 10배 이상 많은 부하를 줄 수 있음.</li>
<li>실제로 c7i.large 서버 1대로도 Ephemeral Ports 기본 값 약 28,000 개를 모두 가상 사용자로 활용할 수 있음.</li>
<li>AWS EC2 에 올려서 사용</li>
<li>c7i.large * 20</li>
</ul>
</li>
<li>모니터링<ul>
<li>Prometheus(메트릭 수집), Grafana(모니터링 대시보드), InfluxDB(부하테스트 결과 저장)<ul>
<li>c7i.xlarge</li>
</ul>
</li>
</ul>
</li>
<li>서비스 기본 인프라<ul>
<li>대기열 코어 서버 c7i.2xlarge * 16</li>
<li>Redis c7i.2xlarge * 6 (Master 3, Replica 3)</li>
</ul>
</li>
</ul>
<br>    

<h2 id="부하테스트-환경-자동화">부하테스트 환경 자동화</h2>
<ul>
<li>부하테스트 인프라를 AWS 에 지속적으로 유지하기에는 비용 부담이 있어, 인프라를 일괄 생성 및 삭제하는 환경 구축</li>
<li>Terrform 을 활용</li>
<li><a href="https://velog.io/@mud_cookie/Terraform-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95">Terraform 부하테스트/모니터링 환경 구축</a> 에 과정 작성.</li>
</ul>
<br>

<h2 id="부하테스트-조건">부하테스트 조건</h2>
<ul>
<li>충분한 Warm-Up 후 수행</li>
<li>VUs Ramp-UP<ul>
<li>0 ~ 10s : max VUs 의 50% 까지 점진 증가</li>
<li>10 ~ 120s : max VUs 100% 까지 점진 증가</li>
<li>120 ~ 150s : max VUs 100% 유지</li>
<li>150 ~ 160s : 0으로 점진 감소</li>
</ul>
</li>
</ul>
<br>    

<h2 id="k6-부하테스트-스크립트-예시">K6 부하테스트 스크립트 예시</h2>
<pre><code>import http from &#39;k6/http&#39;; import { sleep, check, group } from &#39;k6&#39;; import { Counter } from &#39;k6/metrics&#39;;

// 외부 환경변수로부터 stages 값 주입 
const stage1_duration = __ENV.STAGE1_DURATION || &#39;10s&#39;; 
const stage1_target = Number(__ENV.STAGE1_TARGET || 10000); 
const stage2_duration = __ENV.STAGE2_DURATION || &#39;110s&#39;; 
const stage2_target = Number(__ENV.STAGE2_TARGET || 20000); 
const stage3_duration = __ENV.STAGE3_DURATION || &#39;30s&#39;; 
const stage3_target = Number(__ENV.STAGE3_TARGET || 20000);
const stage4_duration = __ENV.STAGE4_DURATION || &#39;10s&#39;; 
const stage4_target = Number(__ENV.STAGE4_TARGET || 0);

// 테스트 설정 
export let options = { 
    stages: [ 
        { duration: stage1_duration, target: stage1_target }, 
        { duration: stage2_duration, target: stage2_target }, 
        { duration: stage3_duration, target: stage3_target }, 
        { duration: stage4_duration, target: stage4_target }, 
    ], 
    tags: {
        team : &#39;server&#39;, 
        test_name: &#39;basic-test&#39; 
    }, 
};

// 커스텀 메트릭 정의 
const waitRequests = new Counter(&#39;wait_requests_total&#39;); 
const entryRequests = new Counter(&#39;entry_requests_total&#39;); 
const canEnterFalse = new Counter(&#39;can_enter_false_count&#39;); 
const canEnterTrue = new Counter(&#39;can_enter_true_count&#39;);

export function setup() { 
    console.log(&#39;Setup: Initializing test setup...&#39;);

    // 공통으로 사용할 헤더 초기화 
    let headers = { &#39;accept&#39;: &#39;/&#39;, &#39;Content-Type&#39;: &#39;application/json&#39;, };

    const waitPayload = JSON.stringify({ 
        &quot;zoneId&quot;: &quot;TEST_ZONE&quot;, 
        &quot;clientIp&quot;: &quot;127.0.0.1&quot;, 
        &quot;clientAgent&quot;: &quot;WEB&quot; 
    });

    return { 
        headers: headers, 
        waitPayload: waitPayload 
    } 
}

export default function (data) { 
    const randomSleepTime = Math.floor(Math.random() * 3000) + 1; 
    sleep(randomSleepTime / 1000);

    let token = null; let canEnter = false;

    group(&#39;POST /traffic/wait&#39;, function () { 
        let res = http.post(&#39;http://spring.abc.com:xxxxx/abc/api/test1&#39;, data.waitPayload, {headers: data.headers}); 
        waitRequests.add(1);

        check(res, {&#39;is WAIT status 200&#39;: (r) =&gt; r.status === 200 });

        let resBody = res.json();
        canEnter = resBody.canEnter;
        // console.log(`WAIT - canEnter: ${canEnter}, Status: ${res.status}, Body: ${res.body}, Duration: ${res.timings.duration}ms`);
        token = resBody.token;

        if (canEnter) {
            canEnterTrue.add(1);
        } else {
            canEnterFalse.add(1);
            const pollingPeriod = resBody.waiting?.pollingPeriod || 3000;
            sleep(pollingPeriod / 1000);
        }
    });

    if (!canEnter) { 
        group(&#39;POST /traffic/entry&#39;, function () { 
            const entryPayload = JSON.stringify({ &quot;zoneId&quot;: &quot;TEST_ZONE&quot;, &quot;token&quot;: token });

            while (!canEnter) {
                entryRequests.add(1);
                let res = http.post(&#39;http://spring.abc.com:xxxxx/abc/api/test2&#39;, entryPayload, {headers: data.headers});
                let resBody = res.json();
                canEnter = resBody.canEnter;

                check(res, {&#39;is ENTRY status 200&#39;: (r) =&gt; r.status === 200 });
                // console.log(`ENTRY - Status code: ${res.status}, Body: ${res.body}, Duration: ${res.timings.duration}ms`);

                if (canEnter) {
                    canEnterTrue.add(1);
                    break;  
                } else {
                    canEnterFalse.add(1);
                    const pollingPeriod = resBody.waiting?.pollingPeriod || 3000;
                    sleep(pollingPeriod / 1000);
                }
            }
        });
    } else { console.log(&quot;Skipping ENTRY request because canEnter was not true or token was not obtained.&quot;); }

console.log(&quot;1 user entered!\n\n&quot;) }</code></pre><br>

<h2 id="부하테스트-모니터링-대상">부하테스트 모니터링 대상</h2>
<p><strong>Client 입장의 API</strong></p>
<ul>
<li>호출 수</li>
<li>응답시간 (최소, 평균, 최대, P90, P95)</li>
<li>HTTP Connection 시간 (최소, 평균, 최대, P90, P95)</li>
</ul>
<p><strong>대기열 코어 서버</strong></p>
<ul>
<li>OS Prometheus Metric<ul>
<li>CPU</li>
<li>Memory</li>
<li>IO</li>
<li>Netstat (Socket Overflow 등의 TCP 오류 확인)</li>
</ul>
</li>
<li>Spring Boot Prometheus Metric<ul>
<li>GC</li>
<li>Server 의 API 응답시간</li>
</ul>
</li>
</ul>
<p><strong>Redis</strong></p>
<ul>
<li>CPU</li>
<li>Memory</li>
<li>command per second</li>
<li>slowlog</li>
</ul>
<br>

<h2 id="부하테스트-결과">부하테스트 결과</h2>
<p><strong>요약</strong>
Redis Node 하나 당 VUs = 600,000 명 가량일 때까지 지연 발생 없음. (그 이상부터는 지연 발생)</p>
<ul>
<li>VUs(가상 사용자) : 600,000 명 </li>
<li>API 호출 건수 : 1,013만 건 </li>
<li>대기열 코어 서버 <ul>
<li>OS CPU : max 50% </li>
<li>GC : 이상 없음 </li>
<li>API 응답시간 : <ul>
<li>평균 : 9.94ms </li>
<li>중앙값 : 2.74ms </li>
<li>P(90) : 19.40ms </li>
<li>P(95) : 36.48ms </li>
</ul>
</li>
</ul>
</li>
<li>Redis <ul>
<li>CPU : 250% </li>
<li>Ops/sec : 800K </li>
</ul>
</li>
</ul>
<p>특이사항 : Redis CPU 중간에 중간에 peak 500% 는 BGSAVE 시 발생한 것으로 예상.</p>
<p><br><br></p>
<p><strong>Metric 캡처</strong>
<img src="https://velog.velcdn.com/images/mud_cookie/post/2b8ae732-4354-41a6-8ffa-7098ff7703a6/image.png" alt="7-metric-1">
<img src="https://velog.velcdn.com/images/mud_cookie/post/b001728f-25df-47e9-81f7-f03f216afeca/image.png" alt="8-metric-2">
<img src="https://velog.velcdn.com/images/mud_cookie/post/3960e693-2b01-4396-ba47-65bfafc6db8d/image.png" alt="9-metric-3">
<img src="https://velog.velcdn.com/images/mud_cookie/post/7478b317-b813-4bf6-8c7d-9b2f6536fb8e/image.png" alt="10-metric-4"></p>
<hr>
<p><br><br></p>
<h1 id="관제모니터링">관제(모니터링)</h1>
<br>

<h2 id="모니터링-대상-grafana-대시보드-통합">모니터링 대상 (Grafana 대시보드 통합)</h2>
<p><strong>ALB (CloudWatch )</strong></p>
<ul>
<li>요청수, 대상 응답시간, 대상 (5XX, 4XX, 3XX, 2X) 응답 수, ALB (5XX, 4XX, 3XX) 응답 수</li>
</ul>
<p><strong>대기열 서비스</strong></p>
<ul>
<li>Zone 현황 (진입 수, 대기자 수, 임계치 등) </li>
</ul>
<p><strong>Redis</strong></p>
<ul>
<li>Master/Replica 현황</li>
<li>CPU, Command 수행 수 및 평균 수행 시간, SlowLog</li>
<li>INFO 명령어는 무거워 직접적으로 사용하는 것은 지양</li>
</ul>
<p><strong>대기열 코어 서버 Application</strong></p>
<ul>
<li>CPU</li>
<li>GC</li>
<li>TPS</li>
<li>API 응답시간</li>
</ul>
<p><strong>대기열 코어 서버 OS</strong></p>
<ul>
<li>CPU</li>
<li>TCP 오류</li>
</ul>
<h2 id="알림-자동화-teams">알림 자동화 (Teams)</h2>
<ul>
<li>임계치를 넘어 대기열 발생 시</li>
<li>특정 Metric 을 넘을 시 (CPU, Heap ~% 이상 유지 및 GC Duration, API 응답시간 등)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dc44a007-408b-4cee-a293-fe7cfbaad364/image.png" alt="11-monitoring-1">
<img src="https://velog.velcdn.com/images/mud_cookie/post/8ec58e0d-f6fe-4ca4-baf4-f6575bcbac36/image.png" alt="12-monitoring-2">
<img src="https://velog.velcdn.com/images/mud_cookie/post/f63127fe-70bc-4042-9a9e-60dc6f06a60f/image.png" alt="13-monitoring-3">
<img src="https://velog.velcdn.com/images/mud_cookie/post/09791c66-8311-4a05-bc2f-89705031fd50/image.png" alt="14-monitoring-4">
<img src="https://velog.velcdn.com/images/mud_cookie/post/1a1a9382-1751-4a6e-b313-60c4556fc8c0/image.png" alt="15-monitoring-5"></p>
<p><br><br></p>
<hr>
<p><br><br></p>
<h1 id="추가-작성-자료">추가 작성 자료</h1>
<blockquote>
<p><a href="https://velog.io/@mud_cookie/Socket-Overflow-%EB%B6%84%EC%84%9D-listen-queue-of-a-socket-overflowed">Socket Overflow 분석</a> <br>
<a href="https://velog.io/@mud_cookie/Terraform-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95">Terraform 부하테스트/모니터링 환경 구축</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Socket Overflow 분석 (listen queue of a socket overflowed)]]></title>
            <link>https://velog.io/@mud_cookie/Socket-Overflow-%EB%B6%84%EC%84%9D-listen-queue-of-a-socket-overflowed</link>
            <guid>https://velog.io/@mud_cookie/Socket-Overflow-%EB%B6%84%EC%84%9D-listen-queue-of-a-socket-overflowed</guid>
            <pubDate>Sun, 11 May 2025 00:37:43 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">개요</h1>
<br>

<p>기존 사용하던 Tracer 솔루션의 지연이 발생했지만 정확한 원인 분석이 되지 않고 있었다. <br>
사용자가 많이 몰리는 시간대에만 발생했고, 당시의 Andriod APP 로그는 아래와 같다. (구현체는 OkHttpClient 사용)</p>
<pre><code>•    java.net.SocketTimeoutException: failed to connect to host.name.co.kr/xxx.xxx.xxx.xxx (port xxxx) from /xxx.xxx.xxx.xxx (port xxxx) after 5000ms
    •    /TRACERAPI/inputQueue.do (5217ms)
    •    /TRACERAPI/inputQueue.do (4041ms)
    •    SocketTimeoutException: failed to connect to host.name.co.kr/xxx.xxx.xxx.xxx (port xxxx) from /xxx.xxx.xxx.xxx (port xxxx) after 5000ms</code></pre><p>이를 보고 단순히 Tracer 의 응답이 느려 SocketTimeoutException 이 발생했다고 생각할 수 있지만, <br>
부하테스트를 여러 번 수행해보며 여러 오류를 겪어본 입장에서는 <code>failed to connect to</code> 의 힌트가 Tracer 서버에 연결하는 과정에서 발생한 것으로 추측했다. <br></p>
<p>이 글에서는 특정 상황의 발생 시점을 파악하고 보다 상세한 원인을 분석하기 위해 직접 재현해본 과정을 공유한다. 
분석을 위해 방대한 자료를 찾아보았는데, 아쉽게도 유용하고 정확한 정보는 많지 않았으며 허위 자료들도 다수 발견되었다.
따라서 직접 Linux, Android 네이티브 코드를 분석하며 상황을 재현해야 했고, 이 과정에서 상당한 시간이 소요되었다.
이러한 어려움을 겪을 다른 개발자들이 시간을 절약하는 데 도움이 되기를 바라며, 직접 확인하고 분석한 내용을 공유한다.</p>
<p>참고로, 해당 Tracer 는 Tomcat 을 내장 서버로 둔 Spring Application 이었고, <br>
나는 사내 자체 구축하는 신규 프로젝트에도 Tomcat 을 내장 서버로 둔 Spring Application 을 사용해 재현해보고자 한다. <br></p>
<br>
<br>

<hr>
<br>
<br>


<h1 id="이론">이론</h1>
<br>

<p>결론부터 말하자면, SpringBoot 에서 내장 Tomcat 사용 시 아래 설정값에 따라 <br></p>
<ul>
<li>server.tomcat.max-connections</li>
<li>server.tomcat.accept-count</li>
</ul>
<p>Kernel 단에서 &#39;listen queue of a socket overflowed&#39;, &#39;SYNs to LISTEN sockets dropped&#39; 이 발생해 연결을 맺지 못할 수 있다. <br></p>
<p>우선 <code>java.net.SocketTimeoutException: failed to connect to ...</code> 는 왜 발생할까? 
<code>failed to connect to ...</code> 키워드로 서버에 연결을 실패한 것을 확인할 수 있다. <br></p>
<p>일반적으로 OkHttp 와 같은 HttpClient 구현체들은 ConnectTimeout, ReadTimeout 등의 설정을 할 수 있다. <br>
그리고 통상적으로 아는 개념으로는 </p>
<ul>
<li><code>Connection Timeout 은 3-way-handshake 가 완료되기 전에 발생한다.</code></li>
</ul>
<p>Connection 을 맺지 못하는 경우는 일반적으로 아래와 같은 상황들이 있다.</p>
<table>
<thead>
<tr>
<th>원인</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>DNS 확인 실패</td>
<td>클라이언트가 서버의 호스트 이름을 IP 주소로 확인할 수 없음.</td>
</tr>
<tr>
<td>네트워크 연결 문제</td>
<td>일반적인 네트워크 중단 또는 통신을 방해하는 라우팅 문제.</td>
</tr>
<tr>
<td>방화벽 제한</td>
<td>TCP 연결 시도를 차단하는 방화벽.</td>
</tr>
<tr>
<td>잘못된 서버 주소/포트</td>
<td>클라이언트가 잘못된 엔드포인트에 연결하려고 시도.</td>
</tr>
<tr>
<td>IPv4/IPv6 문제</td>
<td>어느 IP 프로토콜에든 문제가 있어 연결을 방해함.</td>
</tr>
<tr>
<td>프록시 문제</td>
<td>구성된 프록시 서버의 문제.</td>
</tr>
<tr>
<td>TLS/SSL Handshake 실패</td>
<td>보안 연결 설정 중 실패 (연결 오류로 나타날 수 있음).</td>
</tr>
<tr>
<td>실패한 3-Way Handshake</td>
<td>TCP 연결 설정 과정 중 하나 이상의 단계 실패.</td>
</tr>
<tr>
<td>서버 Accept Queue 포화</td>
<td>서버가 연결 요청으로 압도되어 새 연결을 수락할 수 없음.</td>
</tr>
<tr>
<td>프록시 문제</td>
<td>구성된 프록시 서버의 문제.</td>
</tr>
<tr>
<td>TLS/SSL Handshake 실패</td>
<td>보안 연결 설정 중 실패 (연결 오류로 나타날 수 있음).</td>
</tr>
<tr>
<td>실패한 3-Way Handshake</td>
<td>TCP 연결 설정 과정 중 하나 이상의 단계 실패.</td>
</tr>
<tr>
<td>서버 Accept Queue 포화</td>
<td>서버가 연결 요청으로 압도되어 새 연결을 수락할 수 없음.</td>
</tr>
</tbody></table>
<br>

<p>다만 평소에 잘 되던 것이 사용자가 몰릴 때에만 발생하는 것이라, 네트워크 자체 문제는 아닌 것으로 추측했다. <br>
또한 운영에서의 네트워크 대역폭은 10Gbps 또는 100Gbps로 매우 큰 대역폭을 가지고 있었다.</p>
<p>네트워크 문제도 아니라고 가정하면, 왜 클라이언트에서 3-way-handshake는 완료되었는데 Conncetion 을 맺지 못한다고 나올까?
(failed to connecto to ...) <br>
Linux Kernel 단의 문제인 것일까? 과연 Application 단과는 연관이 없는 것일까? <br></p>
<br>

<p>여러가지 가능성을 생각해 보았지만, 예전에 여러 번 수행한 부하테스트 경험 상 가장 유력한 것은 Server - Application 단의 병목으로 인해 Server - Kernel 단의 3-way-handshake 과정에서 발생하는 것이었다. <br>
그래서 Tracer 가 띄워져 있는 물리 서버의 아래 Metric들을 운영팀에 요청해보았다. <br>
<code>listen queue of a socket overflowed</code>
<code>SYNs to LISTEN sockets dropped</code></p>
<p>이는 <code>netstat -s | grep -i &quot;listen&quot;</code> 와 같은 명령어로 출력이 가능하다.
각각 의미하는 바는 아래와 같다.</p>
<ul>
<li><code>listen queue of a socket overflowed</code> : Accept Queue 가 가득 찬 상태에서 연결 요청이 들어왔을 때 발생</li>
<li><code>SYNs to LISTEN sockets dropped</code> : SYN Queue 가 가득 찬 상태에서 연결 요청이 들어왔을 때 발생</li>
</ul>
<pre><code class="language-bash"># netstat -s | grep -i &quot;listen&quot;

# 1.
2063660 times the listen queue of a socket overflowed
2245967 SYNs to LISTEN sockets dropped

# 2.
1611204 times the listen queue of a socket overflowed
1621418 SYNs to LISTEN sockets dropped

# 3.
1703204 times the listen queue of a socket overflowed
1703256 SYNs to LISTEN sockets dropped

# 4.
1592389 times the listen queue of a socket overflowed
1592447 SYNs to LISTEN sockets dropped</code></pre>
<br>
<br>

<p>이것만 안다고 문제가 해결되지는 않는다. 아래 그린 3-way-handshake 과정을 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5d56acb5-ac84-46e1-983b-a2dc7346f21c/image.png" alt="handshake.png"></p>
<ol>
<li>Server - bind() : 서버의 초기 설정 과정. 서버의 소켓에 특정 로컬 주소를 할당한다.</li>
<li>Server - listen() : 서버의 소켓을 LISTEN 상태로 만들어서, 연결 요청을 받을 준비를 한다.</li>
<li>Client - connect() : 클라이언트의 소켓을 생성하고, 서버의 주소로 연결 요청을 한다. 
이 때 SYN 패킷을 보내고 SYN_SENT 상태로 저장한다.</li>
<li>Server : 연결 요청을 받아, 새로운 소켓을 생성하여 연결을 수락한다.
이 때 SYN 패킷을 읽어 SYN_RECV 상태로 저장하며, 
SYN Queue 에 넣고, 
SYN + ACK 패킷을 응답한다.</li>
<li>Client : SYN + ACK 패킷을 받고, SYN 패킷을 ESTABLISHED 상태로 저장한다. 
이 때 ACK 패킷을 보낸다.</li>
<li>Server : ACK 패킷을 읽어 ESTABLISHED 상태로 저장한다. 
이 때 3-way-handshake 가 완료되었다고 인식해, Accept Queue 에 넣는다.</li>
<li>이제 Server 의 Application 단에서 Accept Queue 으로부터 소켓을 빼와서 로직을 수행한다.</li>
</ol>
<br>
<br>

<p>이 내용을 알고 보니 무언가 이상한 점이 보인다. <br>
각각의 Metric 들은 Accept Queue, SYN Queue 가 가득 찼을 때 발생한다고 했는데, <br>
<code>listen queue of a socket overflowed</code>, <code>SYNs to LISTEN sockets dropped</code> 의 발생 수가 거의 동일하다. <br></p>
<p>사실 Linux Kernel 의 구조는 버전에 따라 다르기도 하고, 매우 복잡해 <code>SYNs to LISTEN sockets dropped</code> 는 매우 다양한 상황에서 발생할 수 있다. <br>
특히, <strong>Accept Queue 가 가득찼을 때 SYN 패킷이 인입되는 경우에도 SYN Drop이 발생할 수 있다.</strong></p>
<p>아래 리눅스 코드를 까보자. (Linux Github 참고)
<a href="https://github.com/torvalds/linux/blob/master/net/ipv4/tcp_ipv4.c">Linux-Github-tcp_ipv4.c</a>
<a href="https://github.com/torvalds/linux/blob/master/include/net/tcp.h#L2619">Linux-Github-tcp.h</a></p>
<br>

<pre><code class="language-c">// net/ipv4/tcp_ipv4.c

...

/*
 * The three way handshake has completed - we got a valid synack -
 * now create the new socket.
 */
struct sock *tcp_v4_syn_recv_sock(const struct sock *sk, struct sk_buff *skb,
                  struct request_sock *req,
                  struct dst_entry *dst,
                  struct request_sock *req_unhash,
                  bool *own_req)
{
    struct inet_request_sock *ireq;
    bool found_dup_sk = false;
    struct inet_sock *newinet;
    struct tcp_sock *newtp;
    struct sock *newsk;
#ifdef CONFIG_TCP_MD5SIG
    const union tcp_md5_addr *addr;
    struct tcp_md5sig_key *key;
    int l3index;
#endif
    struct ip_options_rcu *inet_opt;

    if (sk_acceptq_is_full(sk))
        goto exit_overflow;

    newsk = tcp_create_openreq_child(sk, req, skb);
    if (!newsk)
        goto exit_nonewsk;

    newsk-&gt;sk_gso_type = SKB_GSO_TCPV4;
    inet_sk_rx_dst_set(newsk, skb);

    newtp              = tcp_sk(newsk);
    newinet              = inet_sk(newsk);
    ireq              = inet_rsk(req);
    inet_opt          = rcu_dereference(ireq-&gt;ireq_opt);
    RCU_INIT_POINTER(newinet-&gt;inet_opt, inet_opt);
    newinet-&gt;mc_index     = inet_iif(skb);
    newinet-&gt;mc_ttl          = ip_hdr(skb)-&gt;ttl;
    newinet-&gt;rcv_tos      = ip_hdr(skb)-&gt;tos;
    inet_csk(newsk)-&gt;icsk_ext_hdr_len = 0;
    if (inet_opt)
        inet_csk(newsk)-&gt;icsk_ext_hdr_len = inet_opt-&gt;opt.optlen;
    atomic_set(&amp;newinet-&gt;inet_id, get_random_u16());

    if (READ_ONCE(sock_net(sk)-&gt;ipv4.sysctl_tcp_reflect_tos))
        newinet-&gt;tos = tcp_rsk(req)-&gt;syn_tos &amp; ~INET_ECN_MASK;

    if (!dst) {
        dst = inet_csk_route_child_sock(sk, newsk, req);
        if (!dst)
            goto put_and_exit;
    } else {
        /* syncookie case : see end of cookie_v4_check() */
    }
    sk_setup_caps(newsk, dst);

    tcp_ca_openreq_child(newsk, dst);

    tcp_sync_mss(newsk, dst_mtu(dst));
    newtp-&gt;advmss = tcp_mss_clamp(tcp_sk(sk), dst_metric_advmss(dst));

    tcp_initialize_rcv_mss(newsk);

#ifdef CONFIG_TCP_MD5SIG
    l3index = l3mdev_master_ifindex_by_index(sock_net(sk), ireq-&gt;ir_iif);
    /* Copy over the MD5 key from the original socket */
    addr = (union tcp_md5_addr *)&amp;newinet-&gt;inet_daddr;
    key = tcp_md5_do_lookup(sk, l3index, addr, AF_INET);
    if (key &amp;&amp; !tcp_rsk_used_ao(req)) {
        if (tcp_md5_key_copy(newsk, addr, AF_INET, 32, l3index, key))
            goto put_and_exit;
        sk_gso_disable(newsk);
    }
#endif
#ifdef CONFIG_TCP_AO
    if (tcp_ao_copy_all_matching(sk, newsk, req, skb, AF_INET))
        goto put_and_exit;
#endif

    if (__inet_inherit_port(sk, newsk) &lt; 0)
        goto put_and_exit;
    *own_req = inet_ehash_nolisten(newsk, req_to_sk(req_unhash),
                       &amp;found_dup_sk);
    if (likely(*own_req)) {
        tcp_move_syn(newtp, req);
        ireq-&gt;ireq_opt = NULL;
    } else {
        newinet-&gt;inet_opt = NULL;

        if (!req_unhash &amp;&amp; found_dup_sk) {
            bh_unlock_sock(newsk);
            sock_put(newsk);
            newsk = NULL;
        }
    }
    return newsk;

exit_overflow:
    NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
exit_nonewsk:
    dst_release(dst);
exit:
    tcp_listendrop(sk);
    return NULL;
put_and_exit:
    newinet-&gt;inet_opt = NULL;
    inet_csk_prepare_forced_close(newsk);
    tcp_done(newsk);
    goto exit;
}
EXPORT_IPV6_MOD(tcp_v4_syn_recv_sock);

...
</code></pre>
<pre><code class="language-c">// include/net/tcp.h


...

static inline void tcp_listendrop(const struct sock *sk)
{
    atomic_inc(&amp;((struct sock *)sk)-&gt;sk_drops);
    __NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENDROPS);
}

...
</code></pre>
<br>

<p><code>net/ipv4/tcp_ipv4.c</code> 에서의 tcp_v4_syn_recv_sock 를 보면 metric 들을 어떻게 증가시키는지 확인할 수 있다.</p>
<br>

<pre><code class="language-c">    if (sk_acceptq_is_full(sk))   
        goto exit_overflow;   // Accept Queue 에서 Overflow 가 발생하면

exit_overflow:
    NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);   // LINUX_MIB_LISTENOVERFLOWS, 즉 listen queue overflow 를 증가시킨다.
exit_nonewsk:
    dst_release(dst);
exit:
    tcp_listendrop(sk);   // tcp_listendrop 를 수행한다.</code></pre>
<pre><code class="language-c">static inline void tcp_listendrop(const struct sock *sk)
{
    atomic_inc(&amp;((struct sock *)sk)-&gt;sk_drops);
    __NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENDROPS);   // LINUX_MIB_LISTENDROPS, 즉 SYNs to LISTEN sockets dropped 를 증가시킨다.
}</code></pre>
<br>

<p>그에 대한 증명을 맨 아래 테스트 결과로부터 확인할 수 있다. <br>
테스트 결과에서는 <code>listen queue of a socket overflowed</code>, <code>SYNs to LISTEN sockets dropped</code> 값이 동일하게 출력된다. <br></p>
<p>그러면 어떻게 해결해야 하는가? 아래 TCP 요청이 Tomcat 에 어떻게 들어가는지 확인해보자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f4e980b1-9994-4bca-bd75-35916cf71bf8/image.png" alt="tomcat.png"></p>
<br>

<h3 id="1-client-로부터-syn-패킷이-들어오면-kernel은-syn--ack-를-응답하며-syn-queue-에-추가된다">1. Client 로부터 SYN 패킷이 들어오면 Kernel은 SYN + ACK 를 응답하며 SYN Queue 에 추가된다.</h3>
<br>

<p>이 때, SYN Queue 의 사이즈는 통상적으로 net.ipv4.tcp_max_syn_backlog 값으로 결정되는 것으로 알려져 있으나, <br>
실제로는 Linux Kernel 2.6.20 까지만 유효했던 개념이고 이제는 net.ipv4.tcp_max_syn_backlog 말고도 여러 요인에 의해 값이 결정된다. (OS 별로, 버전 별로 다름) </p>
<br>

<h3 id="2-client-가-ack-패킷-보내면-kernel은-accept-queue-에-소켓을-담는다">2. Client 가 ACK 패킷 보내면 Kernel은 Accept Queue 에 소켓을 담는다.</h3>
<br>

<p>Accept Queue 의 사이즈는 net.core.somaxconn 으로 관리된다.
(각 listen 포트에 대한 기본값은 통상적으로 4096이다.) 
다만 Application 단에서 각각의 포트에 Accept Queue 크기를 다르게 설정할 수 있다. 
Accept Queue 에 들어온 것들은 <code>일반적으로</code> 3-way-handshake 가 완료된 것이라고 본다. <br></p>
<h3 id="3-tomcat-의-acceptor-쓰레드는-kernel-의-accept-queue-에-있는-소켓을-가져와-tomcat-내부의-poller-event-queue-에-넣는다">3. Tomcat 의 Acceptor 쓰레드는 Kernel 의 Accept Queue 에 있는 소켓을 가져와 Tomcat 내부의 Poller Event Queue 에 넣는다.</h3>
<br>

<p>Tomcat 의 acceptCount 프로퍼티 값이 Tomcat 이 listen 하는 포트의 Accept Queue 크기를 결정한다.
(default : 100) <br></p>
<h3 id="4-poller-쓰레드는-poller-event-queue-에-있는-소켓을-가져와-non-blocking-하게-worker-thread-에-할당한다">4. Poller 쓰레드는 Poller Event Queue 에 있는 소켓을 가져와 Non-Blocking 하게 Worker Thread 에 할당한다.</h3>
<br>

<p>Tomcat 이 Non-Blocking 하다는 의미는 여기에서 나온다. <br>
  기존 BIO (Blocking IO) Connector 는 하나의 소켓에 하나의 Worker Thread 가 할당되는데, 
  NIO (Non-Blocking IO) Connector 는 하나의 소켓에 여러 개의 Worker Thread 가 할당된다.
<strong>Tomcat 내에서 Worker Thread + Poller 가 담당할 수 있는 최대 소켓 수는 <code>maxConnections</code> 값으로 관리된다.</strong> <br>
(default : 8192)</p>
<br>

<p>그러면 NIO Tomcat 에서 연결을 수용할 수 있는 크기인 maxConnections 값을 늘리는 것이 3-way-handshake 오류가 발생하지 않게 하는 가장 효율적인 방법이라고 생각한다. <br>
AcceptCount 값을 조정하는 것은 개인적으로 아래와 같은 이유로 추천하지 않는다. <br></p>
<ul>
<li>Application 단에서는 본인이 사용하는 Accept Queue 는 특정 포트에 대해 size를 지정할 수 있다.</li>
<li>Linux Kernel 단에서는 특정 포트만 지정하는 것이 아니라 모든 포트에 대해 Accept Queue size 를 지정한다.</li>
<li><strong>Application 단에서 지정한 Accpet Queue size / Linux Kernel 단에서 지정한 Accept Queue size 중 작은 값으로 적용된다.</strong></li>
<li>그래서 Application 단에서만 Accpet Queue size 값을 늘린다고 근본적으로는 해결이 되지 않으며,</li>
<li>그렇다고 Linux Kernel 단에서도 전체 포트에 대해 Queue size 를 늘리는 것은 모양새가 조금 이상하다.</li>
<li>물론 OS 에 따라 Linux Kernel 단의 기본 Accept Queue size (somaxconn) 값이 1024 또는 4096 정도로 적당히 큰 값이 존재하니 이 정도 선까지는 Application 단의 Accept Queue size 값을 늘리는 것이 효과적일 수 있다.</li>
</ul>
<p>물론, worker thread 단에서 병목 발생과 동시에 TCP 요청이 계속 인입되면 maxConnections 큐도 가득찰 가능성이 높아진다. <br>
이러할 경우에는 maxConnections 수를 늘리는 것보다 인스턴스 수를 늘리는 것이 효율적이다. <br></p>
<br>
<br>

<p>아래는 위 내용과는 무관하지만 Springboot 내장 서버로 선택 가능한 Tomcat, Undertow, Netty에 대한 비교이다. <br>
이번에 신규로 개발하는 Tracer 대체 프로젝트는 SpringBoot MVC + Tomcat 을 사용한 것에 대해 의문을 가질 수 있어 미리 작성한다. <br>
성능만으로 보면 WebFlux + Netty 가 가장 좋을 것 같지만, </p>
<ul>
<li>실제로 짧은 기간 내에 개발에 참여한 모든 인원들이 Reactive Programming 에 대해 정확히 숙지하고 있어야 하며, 이를 지키지 못할 시 오히려 성능이 떨어지는 현상이 발생할 수 있다. </li>
<li>또한 디버깅이 어렵고 기존 사내 공통 라이브러리로 사용하던 모든 것들을 전부 새로 작성해야 되는 불편함이 존재한다.</li>
<li>따라서 시간이 여유있고 모든 개발 구성원들이 학습에 대한 의지가 있는 경우에 적용해야 된다는 것이 내 판단이다.</li>
</ul>
<br>

<p>그래서 이번 프로젝트에서는 <code>SpringBoot MVC + Tomcat 에 더해 Virtual Thread + Kotlin coroutine</code> 을 적극 사용한다. <br></p>
<ul>
<li>Virtual Thread 는 기존 무거운 Platform Thread 에서 벗어나 JVM 단에서 관리되는 매우 경량 쓰레드이다.</li>
<li>Kotlin coroutine 은 비동기 프로그래밍을 위한 효율적인 방법이다.</li>
<li>기존 MVC 개념을 유지해 개발에 대한 학습 곡선이 크지 않다.</li>
<li>Webflux + Netty 에 비해 성능이 뒤쳐지지 않는, 가볍고 빠른 비동기 프로그래밍을 효율적으로 적용할 수 있다.</li>
<li>Undertow 는 RedHat 에서 만든 오픈소스로, 현재 Virtual Thread 를 지원하지 않는다. (<a href="https://github.com/spring-projects/spring-boot/issues/39812">SpringBoot Github - Undertow Virtual Thread Memory Leak 이슈</a>)</li>
</ul>
<p>Virtual Thread + Kotlin coroutine 에 대한 내용은 차후 다른 곳에 상세히 작성할 예정이다.</p>
<br>
<br>

<blockquote>
<p><strong>참고: Tomcat 이 Non-Blocking 하다는 의미</strong> <br>
아주 예전의 Tomcat 이 아니라면 NIO 방식을 지원한다. </p>
<table>
<thead>
<tr>
<th>구분</th>
<th>BIO (Blocking I/O)</th>
<th>NIO (Non-blocking I/O)</th>
</tr>
</thead>
<tbody><tr>
<td>연결 처리 방식</td>
<td>연결당 하나의 Worker Thread 할당 및 점유</td>
<td>Poller가 다수의 연결 관리, 이벤트 발생 시 Worker Thread 활용</td>
</tr>
<tr>
<td>Worker Thread</td>
<td>연결의 전체 생명주기 동안 블로킹될 수 있음</td>
<td>I/O 이벤트 발생 시 작업 처리에만 사용, 블로킹 최소화</td>
</tr>
<tr>
<td>동시 연결 수</td>
<td>스레드 수에 강하게 비례하여 제한적</td>
<td>적은 수의 스레드로 더 많은 동시 연결 처리 가능</td>
</tr>
<tr>
<td>자원 효율성</td>
<td>유휴 연결도 스레드 점유하여 자원 낭비 발생</td>
<td>유휴 연결은 스레드 점유하지 않아 자원 효율적</td>
</tr>
<tr>
<td>확장성</td>
<td>동시 연결 증가 시 스레드 증가 부담 큼</td>
<td>적은 스레드로 확장성이 우수함</td>
</tr>
<tr>
<td>주요 컴포넌트</td>
<td>Acceptor, Worker Thread Pool</td>
<td>Acceptor, Poller (Selector), Worker Thread Pool</td>
</tr>
</tbody></table>
</blockquote>
<br>

<blockquote>
<p><strong>참고 : Springboot 내장 Tomcat, Undertow, Netty 비교</strong> <br></p>
<table>
<thead>
<tr>
<th>특징</th>
<th>NIO Tomcat (Spring MVC와 함께)</th>
<th>Undertow (Spring MVC와 함께)</th>
<th>Netty (Spring WebFlux와 함께)</th>
</tr>
</thead>
<tbody><tr>
<td>주요 사용 패러다임</td>
<td>전통적 Servlet (블로킹 API), Spring MVC</td>
<td>Servlet (블로킹 API), Spring MVC</td>
<td>리액티브 프로그래밍, Spring WebFlux</td>
</tr>
<tr>
<td>기반 I/O 모델</td>
<td>Java NIO (Selector)</td>
<td>XNIO (Java NIO 추상화)</td>
<td>Java NIO, 네이티브 전송 (epoll, kqueue 등)</td>
</tr>
<tr>
<td>I/O 스레드</td>
<td>Acceptor (연결 수락), Poller (I/O 이벤트 감지)</td>
<td>XNIO I/O 스레드 (연결 수락 및 I/O 이벤트 감지)</td>
<td>Netty Boss EventLoop (연결 수락), Netty Worker EventLoop (I/O 이벤트, 논블로킹 로직 실행)</td>
</tr>
<tr>
<td>애플리케이션 로직 실행 스레드</td>
<td>별도 Worker 스레드 풀 (server.tomcat.threads.max) 에서 Servlet/Controller 실행 (블로킹 가능)</td>
<td>별도 Worker 스레드 풀 (server.undertow.threads.worker) 에서 Servlet/Controller 실행 (블로킹 가능)</td>
<td>기본적으로 Worker EventLoop 스레드에서 논블로킹 로직 실행. 블로킹 작업은 Schedulers로 별도 스레드 풀에 위임 필수.</td>
</tr>
<tr>
<td>이벤트 루프 방식</td>
<td>부분적 (Poller는 이벤트 기반, 요청 처리는 스레드 풀)</td>
<td>I/O 스레드는 이벤트 루프. 요청 처리는 MVC의 경우 워커 스레드로 위임.</td>
<td>강력하고 명확한 이벤트 루프 (EventLoop 자체가 이벤트 루프)</td>
</tr>
<tr>
<td>블로킹 코드 처리</td>
<td>Worker 스레드가 블로킹됨. 풀 소진 시 요청 대기.</td>
<td>Worker 스레드가 블로킹됨 (MVC). 풀 소진 시 요청 대기.</td>
<td>EventLoop 스레드에서 블로킹 절대 금지. Schedulers로 위임.</td>
</tr>
<tr>
<td>요청 처리 효율성</td>
<td>다수 동시 요청 시 스레드 증가, 컨텍스트 스위칭 비용 발생 가능</td>
<td>Tomcat 대비 경량, 잠재적으로 더 나은 성능 (MVC).</td>
<td>높은 수준의 성능 및 확장성, 논블로킹 최적화, 리소스 효율 극대화</td>
</tr>
<tr>
<td>개발 복잡도</td>
<td>상대적으로 낮음 (전통적 블로킹 모델)</td>
<td>MVC 사용 시 Tomcat과 유사. WebFlux 시 Netty와 유사.</td>
<td>높음 (논블로킹, 비동기 패러다임, Netty 내부 구조 이해 필요, 디버깅 어려움)</td>
</tr>
</tbody></table>
</blockquote>
<br>
<br>

<hr>
<br>
<br>

<h2 id="sockettimeoutexception-failed-to-connect-to--재현">SocketTimeoutException: failed to connect to ... 재현</h2>
<br>

<p>이제 에러 현상을 이해하기 위한 사전지식을 설명했으니, <br>
 <code>SocketTimeoutException: failed to connect to ...</code> 발생을 재현해보자.</p>
<br>

<p>Android 환경에서 수행한다. <br>
앱에서 OkhttpClient 를 사용한 것이 확인되어, <br>
Native Java 인터페이스 환경에서 수행해 보았지만 동일한 에러 로그가 출력되지 않았다. <br></p>
<p>그래서 실제 Android 에뮬레이터를 띄우고 테스트를 수행했다.</p>
<ul>
<li>실제 앱: 안드로이드 OS 환경에서 실행되며, 안드로이드 시스템의 네트워킹 API와 libcore 같은 저수준 라이브러리를 활용한다. 특히 com.android.okhttp를 사용한다면 더욱 그렇다.</li>
<li>Native Java : JVM 환경에서 실행되며, 실제 네트워크 통신은 JVM의 표준 네트워킹 기능을 사용한다. <br>
okhttp3 라이브러리는 이러한 JVM 환경에 맞게 동작한다.</li>
</ul>
<br>

<p><strong>AndroidManifest.xml</strong></p>
<pre><code class="language-xml">
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;manifest xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    xmlns:tools=&quot;http://schemas.android.com/tools&quot;&gt;

    &lt;uses-permission android:name=&quot;android.permission.INTERNET&quot; /&gt;
    &lt;uses-permission android:name=&quot;android.permission.ACCESS_NETWORK_STATE&quot; /&gt;

&lt;application
    ...
    android:usesCleartextTraffic=&quot;true&quot;
    &gt;

&lt;/application&gt;
</code></pre>
<br>

<p><strong>build.gradle.kts</strong></p>
<pre><code class="language-kotlin">
...

android {
    compileSdk = 35

    defaultConfig {
        ...
        minSdk = 35
        targetSdk = 35
    }

    ...

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = &quot;11&quot;
    }
    testOptions {
    unitTests {
        isIncludeAndroidResources = true
    }
  }
}

dependencies {
    ...

    implementation(&quot;com.squareup.okhttp3:okhttp:4.14.1&quot;)

    // kotlin coroutine 필요에 따라 구성
    // Android 진영에서는 아직 JDK21 의 Virtual Thread 를 지원하지 않음에 참고.
    // implementation(&quot;org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1&quot;)
    // implementation(&quot;org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1&quot;)
}
</code></pre>
<br>


<p>아래 Andriod Kotlin 코드로 재현하고 증명하고자 한다.
환경은 아래와 같다.</p>
<ul>
<li>Kotlin, Kotlin coroutine</li>
<li>Android 에뮬레이터는 가상 라우터 뒤에서 실행되기 때문에, localhost 가 아닌 10.0.2.2 로 접근한다.</li>
<li>OkHttpClient<ul>
<li>ConnectTimeout : 5초</li>
<li>ReadTimeout : 60초</li>
</ul>
</li>
<li>Device API version : 35</li>
</ul>
<br>

<p><strong>SocketTimeoutException 재현 코드</strong></p>
<pre><code class="language-kotlin">
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {

    companion object {
        private const val TAG = &quot;SINGLE_CALL_TEST&quot;
    }

    private fun createTrafficRequestBodyJson(): String {
        return &quot;&quot;&quot;
            {
                ...
            }
        &quot;&quot;&quot;.trimIndent()
    }

    private suspend fun performHttpRequest(client: OkHttpClient, url: String, jsonBody: String): Response {
        return withContext(Dispatchers.IO) {
            val mediaType = &quot;application/json; charset=utf-8&quot;.toMediaType()
            val requestBody = jsonBody.toRequestBody(mediaType)
            val request = Request.Builder()
                .url(url)
                .post(requestBody)
                .build()
            client.newCall(request).execute()
        }
    }

    private suspend fun processSingleUserRequest(
        client: OkHttpClient,
        url: String,
        requestJson: String,
        userId: Int,
        successCounter: AtomicInteger,
        failureCounter: AtomicInteger
    ) {
        val startTime = System.currentTimeMillis()
        try {
            val response = performHttpRequest(client, url, requestJson)
            val endTime = System.currentTimeMillis()
            val elapsedTime = endTime - startTime

            response.use {
                if (it.isSuccessful) {
                    successCounter.incrementAndGet()
                    Log.i(TAG, &quot;유저 $userId, 호출 성공: ${it.code}, 시간: ${elapsedTime}ms&quot;)
                } else {
                    failureCounter.incrementAndGet()
                    Log.e(TAG, &quot;유저 $userId, 호출 실패: ${it.code} ${it.body?.string()}, 시간: ${elapsedTime}ms&quot;)
                }
            }
        } catch (e: Exception) {
            val endTime = System.currentTimeMillis()
            val elapsedTime = endTime - startTime
            failureCounter.incrementAndGet()
            Log.e(TAG, &quot;유저 $userId, 예외 발생: ${e.message}, 시간: ${elapsedTime}ms&quot;, e)
        }
    }

    @Test
    fun singleCallTest_PerUser_With_Coroutines() {
        runBlocking {
            val concurrentUsers = 1
            val targetUrl = &quot;http://172.20.188.27:21000/test&quot;    // wsl private ip
//            val targetUrl = &quot;http://10.0.2.2:21000/test

            val requestJson = createTrafficRequestBodyJson()

            Log.i(TAG, &quot;단일 호출 테스트 시작: 동시 유저=$concurrentUsers, URL=$targetUrl&quot;)

            val client = OkHttpClient.Builder()
                .connectTimeout(1, TimeUnit.SECONDS)
                .readTimeout(60, TimeUnit.SECONDS)
                .build()

            val successCount = AtomicInteger(0)
            val failureCount = AtomicInteger(0)

            val userJobs = List(concurrentUsers) { userId -&gt;
                launch {
                    Log.d(TAG, &quot;유저 $userId 시작 (단일 호출)&quot;)
                    processSingleUserRequest(
                        client,
                        targetUrl,
                        requestJson,
                        userId,
                        successCount,
                        failureCount
                    )
                    Log.d(TAG, &quot;유저 $userId 단일 호출 완료&quot;)
                }
            }

            userJobs.joinAll()

            Log.d(TAG, &quot;모든 유저 작업 완료&quot;)

            val finalSuccessCount = successCount.get()
            val finalFailureCount = failureCount.get()
            val totalCalls = finalSuccessCount + finalFailureCount

            Log.i(TAG, &quot;---------- 테스트 결과 ----------&quot;)
            Log.i(TAG, &quot;총 유저 수: $concurrentUsers&quot;)
            Log.i(TAG, &quot;총 요청 수 (유저당 1회): $totalCalls&quot;)
            Log.i(TAG, &quot;성공: $finalSuccessCount&quot;)
            Log.i(TAG, &quot;실패: $finalFailureCount&quot;)
            Log.i(TAG, &quot;-----------------------------&quot;)
        }
    }
}

</code></pre>
<p>그리고 부하를 받는 Spring Boot 의 내장 Tomcat 의 properties 는 아래와 같이 설정했다.</p>
<br>

<p><strong>Tomcat properties</strong></p>
<pre><code class="language-properties">server.tomcat.max-connections=1 (default : 8192)
server.tomcat.accept-count=1 (default : 100)</code></pre>
<br>

<p><strong>API 응답시간 : 1초</strong></p>
<br>

<p>이 조건에서 동시에 호출하는 유저 수를 변경해보면서, 어떠한 현상이 발생하는지 확인하고자 한다. <br>
아래는 <strong>동시 호출 유저 수 : 1</strong> 로 설정한 결과이다.</p>
<pre><code class="language-log">// 동시 호출 유저 수 : 1
05-17 04:27:42.098 28865 28880 D SINGLE_CALL_TEST: 유저 0 시작 (단일 호출)
05-17 04:27:43.178 28865 28880 I SINGLE_CALL_TEST: 유저 0, 호출 성공: 200, 시간: 1080ms
05-17 04:27:43.179 28865 28880 D SINGLE_CALL_TEST: 유저 0 단일 호출 완료
05-17 04:27:43.182 28865 28880 D SINGLE_CALL_TEST: 모든 유저 작업 완료
05-17 04:27:43.182 28865 28880 I SINGLE_CALL_TEST: ---------- 테스트 결과 ----------
05-17 04:27:43.182 28865 28880 I SINGLE_CALL_TEST: 총 유저 수: 1
05-17 04:27:43.182 28865 28880 I SINGLE_CALL_TEST: 총 요청 수 (유저당 1회): 1
05-17 04:27:43.182 28865 28880 I SINGLE_CALL_TEST: 성공: 1
05-17 04:27:43.182 28865 28880 I SINGLE_CALL_TEST: 실패: 0
05-17 04:27:43.182 28865 28880 I SINGLE_CALL_TEST: -----------------------------</code></pre>
<p>병목이 발생하지 않아 정상적으로 1초만에 응답받은 모습이다. <br>
그러면 이 때 발생하는 TCP 패킷을 Wireshark 로 분석해보자. <br></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1ded1e20-42bf-4a83-979a-444c0a227c02/image.png" alt="wireshark_vu1.png"></p>
<ol>
<li>Client -&gt; Server : SYN (TCP 연결 시작, 3-way-handshake 시작)</li>
<li>Server -&gt; Client : SYN-ACK (TCP 연결 수락)</li>
<li>Client -&gt; Server : ACK (TCP 연결 완료, 3-way-handshake 완료)</li>
<li>Client -&gt; Server : HTTP Request</li>
<li>Server -&gt; Client : ACK (위 Request 에 대해 데이터를 잘 받았다는 의미)</li>
<li>Server -&gt; Client : PSH, ACK (PSH : 수신 측에 데이터를 즉시 상위 애플리케이션으로 전달하라는 의미)</li>
<li>Server -&gt; Client : HTTP Response</li>
<li>Client -&gt; Server : ACK (위 Response 에 대해 데이터를 잘 받았다는 의미)</li>
<li>Client -&gt; Server : FIN, ACK (클라이언트 -&gt; 서버 연결 종료 요청, TCP Connection Teardown, 4-way-handshake)</li>
<li>Server -&gt; Client : FIN, ACK (서버가 FIN 요청을 수락(ACK)하고, 자신도 연결 종료(FIN)를 요청)</li>
<li>Client -&gt; Server : ACK (클라리언트가 서버의 FIN 에 대해 ACK 를 보내며 연결 정상 종료.)</li>
</ol>
<br>

<p>이로써 TCP 연결이 어떻게 이루어지는지 한 바퀴를 확인해보았다. <br>
그러면 동시 유저 수를 2로 늘려보자. <br></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/22fcad3d-9803-4f6c-84ae-cddec4abf827/image.png" alt="wireshark_vu2.png"></p>
<br>

<p><strong>동시 호출 유저 수 : 2</strong></p>
<pre><code class="language-log">05-17 04:54:01.525 29208 29225 I SINGLE_CALL_TEST: 유저 1, 호출 성공: 200, 시간: 1163ms
05-17 04:54:01.525 29208 29225 D SINGLE_CALL_TEST: 유저 1 단일 호출 완료
05-17 04:54:22.547 29208 29225 I SINGLE_CALL_TEST: 유저 0, 호출 성공: 200, 시간: 22195ms
05-17 04:54:22.547 29208 29225 D SINGLE_CALL_TEST: 유저 0 단일 호출 완료
05-17 04:54:22.549 29208 29225 D SINGLE_CALL_TEST: 모든 유저 작업 완료
05-17 04:54:22.549 29208 29225 I SINGLE_CALL_TEST: ---------- 테스트 결과 ----------
05-17 04:54:22.549 29208 29225 I SINGLE_CALL_TEST: 총 유저 수: 2
05-17 04:54:22.549 29208 29225 I SINGLE_CALL_TEST: 총 요청 수 (유저당 1회): 2
05-17 04:54:22.549 29208 29225 I SINGLE_CALL_TEST: 성공: 2
05-17 04:54:22.549 29208 29225 I SINGLE_CALL_TEST: 실패: 0
05-17 04:54:22.549 29208 29225 I SINGLE_CALL_TEST: -----------------------------</code></pre>
<br>

<p>결과가 약간 이상하다. <br>
유저 1이 1초만에 응답을 받았는데, 유저 2는 22초가 소요되었다. <br>
Tomcat 에서 설정한 프로퍼티는 아래를 의미한다. <br></p>
<ul>
<li>max-connections : Tomcat 내부적으로 동시에 처리할 수 있는 최대 연결 수 (설정값 : 1)</li>
<li>accept-count : Tomcat 에서 최대로 처리할 수 없어서 Kenral 단에서 기다리고 있는 (Accept Queue) 의 최대 개수 (설정값 : 1)</li>
</ul>
<br>

<p>wireshark 에서는 유저 1의 Client 임시 port 가 54878, 유저 2의 Client 임시 port 가 54879 로 확인된다. <br>
유저 1과 유저 2의 SYN 요청이 동시에 인입되었고, 둘 다 3-way-handshake 가 완료되었다. <br></p>
<br>

<p>유저 1의 요청이 1초만에 끝났으면, 유저 2의 요청도 1초를 기다렸다가 API 내부 로직인 1초 수행한 총 2초만에 수행되어야 하지 않는가? <br>
다만 Tomcat 에는 내부적으로 HTTP 1.1 이상부터는 Keep-alive 속성이 존재한다. <br>
이는 유저 1의 요청이 완료되었더라도, 기본값인 20초 동안 연결을 유지할 수 있도록 하는 것이다. <br>
그러므로 유저 2의 요청은 1초 + 20초 동안 기다렸다가 1초를 소요한 총 22초만에 응답을 받게 된다. <br></p>
<blockquote>
<p>*<em>참고 : 여기서 ConnectTimeout : 5초인데 ConnectTimeout 이 나지 않은 것은 이미 3-way-handshake 가 완료되었기 때문이다. *</em>
그러므로 ReadTimeout 설정인 최대 60초까지 계속 대기하게 된다. </p>
</blockquote>
<br>

<p>이를 증명해보이기 위해 <strong>동시 호출 유저 수 : 2, 각 유저 별 호출 수 : 2</strong> 로 설정한 상태에서 수행해보자. <br></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/47b2bfa9-7c92-43cd-b4d8-496e2a22b6ed/image.png" alt="wireshark_vu2_2.png"></p>
<br>

<pre><code class="language-log">05-17 05:21:25.559 29649 29663 I SINGLE_CALL_TEST: 유저 1, 호출 성공: 200, 시간: 1155ms
05-17 05:21:25.560 29649 29663 D SINGLE_CALL_TEST: 유저 1 시작 (단일 호출)
05-17 05:21:26.569 29649 29663 I SINGLE_CALL_TEST: 유저 1, 호출 성공: 200, 시간: 1009ms
05-17 05:21:26.570 29649 29663 D SINGLE_CALL_TEST: 유저 1 단일 호출 완료
05-17 05:21:47.597 29649 29663 I SINGLE_CALL_TEST: 유저 0, 호출 성공: 200, 시간: 23202ms
05-17 05:21:47.598 29649 29663 D SINGLE_CALL_TEST: 유저 0 시작 (단일 호출)
05-17 05:21:48.607 29649 29663 I SINGLE_CALL_TEST: 유저 0, 호출 성공: 200, 시간: 1009ms
05-17 05:21:48.607 29649 29663 D SINGLE_CALL_TEST: 유저 0 단일 호출 완료
05-17 05:21:48.609 29649 29663 D SINGLE_CALL_TEST: 모든 유저 작업 완료
05-17 05:21:48.609 29649 29663 I SINGLE_CALL_TEST: ---------- 테스트 결과 ----------
05-17 05:21:48.609 29649 29663 I SINGLE_CALL_TEST: 총 유저 수: 2
05-17 05:21:48.609 29649 29663 I SINGLE_CALL_TEST: 총 요청 수 (유저당 1회): 4
05-17 05:21:48.609 29649 29663 I SINGLE_CALL_TEST: 성공: 4
05-17 05:21:48.609 29649 29663 I SINGLE_CALL_TEST: 실패: 0
05-17 05:21:48.609 29649 29663 I SINGLE_CALL_TEST: -----------------------------</code></pre>
<br>

<p>예상이 맞았다. <br>
한 번 연결된 요청은 20초 동안 유지되어, Tomcat 의 connection 수를 차지한다. <br>
이는 아래와 같이 분석된다.</p>
<ul>
<li>유저 1_1 요청 : 1초</li>
<li>유저 2_1 요청 : 유저 1 의 연결이 끝날때까지 대기</li>
<li>유저 1_2 요청 : 1초</li>
<li>유저 2_1 요청 : 1_2 의 요청이 끝난 후 20초 대기 후 1초 수행 = 1초 + 1초 + 20초 + 1초 = 23초</li>
<li>유저 2_2 요청 : 유저 2가 이미 연결되어있으므로 1초만에 수행</li>
</ul>
<br>
<br>

<p>이제는 <strong>동시 호출 유저 수 : 3</strong> 로 수행해보자. <br></p>
<br>

<p>여기서부터는 동시에 3명을 wireshark 로 한 번에 보기 힘드므로, 각 유저별로 나누어서 보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ab8c4a3b-410e-47d4-9c80-aa2afe2ece7b/image.png" alt="wireshark_vu3_user1.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f569088e-71f1-43d5-9830-df1289d773a7/image.png" alt="wireshark_vu3_user2.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c74799bc-7ddf-463e-9219-f136b081d9a7/image.png" alt="wireshark_vu3_user3.png"></p>
<br>

<pre><code class="language-log">05-17 05:32:15.621 30000 30014 D SINGLE_CALL_TEST: 유저 0 시작 (단일 호출)
05-17 05:32:15.631 30000 30014 D SINGLE_CALL_TEST: 유저 1 시작 (단일 호출)
05-17 05:32:15.633 30000 30014 D SINGLE_CALL_TEST: 유저 2 시작 (단일 호출)
05-17 05:32:16.749 30000 30014 I SINGLE_CALL_TEST: 유저 2, 호출 성공: 200, 시간: 1116ms
05-17 05:32:16.750 30000 30014 D SINGLE_CALL_TEST: 유저 2 단일 호출 완료
05-17 05:32:37.763 30000 30014 I SINGLE_CALL_TEST: 유저 1, 호출 성공: 200, 시간: 22130ms
05-17 05:32:37.763 30000 30014 D SINGLE_CALL_TEST: 유저 1 단일 호출 완료
05-17 05:32:58.790 30000 30014 I SINGLE_CALL_TEST: 유저 0, 호출 성공: 200, 시간: 43169ms
05-17 05:32:58.791 30000 30014 D SINGLE_CALL_TEST: 유저 0 단일 호출 완료
05-17 05:32:58.793 30000 30014 D SINGLE_CALL_TEST: 모든 유저 작업 완료
05-17 05:32:58.793 30000 30014 I SINGLE_CALL_TEST: ---------- 테스트 결과 ----------
05-17 05:32:58.793 30000 30014 I SINGLE_CALL_TEST: 총 유저 수: 3
05-17 05:32:58.793 30000 30014 I SINGLE_CALL_TEST: 총 요청 수 (유저당 1회): 3
05-17 05:32:58.793 30000 30014 I SINGLE_CALL_TEST: 성공: 3
05-17 05:32:58.793 30000 30014 I SINGLE_CALL_TEST: 실패: 0
05-17 05:32:58.793 30000 30014 I SINGLE_CALL_TEST: -----------------------------</code></pre>
<pre><code class="language-log">Every 1.0s: netstat -s | grep -i &quot;listen&quot;
    0 times the listen queue of a socket overflowed
    0 SYNs to LISTEN sockets dropped</code></pre>
<br>

<p>여기서도 무언가 이상한 점이 보인다. <br>
동시 요청은 Tomcat 의 maxConnection 의 1 + AcceptCount 의 1 이므로, 2개만 허용하는 것으로 설정했는데, kernel 단의 overflow 가 발생하지 않았다. <br>
아주 중요한 내용은 아니지만, Linux Kernel 단의 Accpet Queue 는 비교값 연산자가 &gt; 가 아닌 &gt;= 으로 설정되어 있어 최대값 + 1개까지 Accpept Queue 에 허용된다. <br></p>
<p>아래 Linux Kernel 함수를 보면, Accept Queue 최대값을 1로 설정했을 때 <br>
이미 Accept Queue 가 가득 찬 (1) 상태에서 새로 Accept Queue 인입 요청을 위해 해당 메서드를 수행해도 <code>가득 차지 않음</code> 으로 판단하기 때문에 인입이 허용된다. <br>
다만 왜 이렇게 구현했는지는 이해할 수는 없다. <br></p>
<blockquote>
<p><a href="https://github.com/torvalds/linux/blob/172a9d94339cea832d89630b89d314e41d622bd8/include/net/sock.h#L1044">Github - Linux Kernel Accept Queue Full Check</a></p>
</blockquote>
<pre><code class="language-c">// sk_ack_backlog: 현재 Accept Queue에 대기 중인 연결 수.
// sk_max_ack_backlog: listen() 시스템 콜의 backlog 파라미터와 net.core.somaxconn 중 작은 값으로 설정된 최대 큐 크기.
// accept queue 가 가득찼는지 판단하는 리눅스 Kernel 메서드
static inline bool sk_acceptq_is_full(const struct sock *sk) {
    return READ_ONCE(sk-&gt;sk_ack_backlog) &gt; READ_ONCE(sk-&gt;sk_max_ack_backlog);
}</code></pre>
<br>

<p>그 부분을 제외하면 예상한대로 이전 요청의 Keep-Alive 의 유지가 끊기고 나서 다음 요청이 인입되게 동작한다. <br></p>
<br>
<br>

<p>이제는 <strong>동시 호출 유저 수 : 4</strong> 로 수행해보자. <br>
여기서부터는 overflow 가 발생할 것으로 예상된다. <br></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/023fb403-c887-40b5-a4b0-51e8efd8ba91/image.png" alt="wireshark_vu4_1.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/182015d5-60ac-40cd-b1fe-562e3ef2f876/image.png" alt="wireshark_vu4_2.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8e393480-411f-4ff4-a14f-ad746cadd357/image.png" alt="wireshark_vu4_3.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2d236688-573a-4c70-acfd-2bf4d4097e75/image.png" alt="wireshark_vu4_4.png"></p>
<br>

<pre><code class="language-log">Every 1.0s: netstat -s | grep -i &quot;listen&quot;
    5 times the listen queue of a socket overflowed
    5 SYNs to LISTEN sockets dropped</code></pre>
<pre><code class="language-log">05-17 06:18:55.451 30475 30490 D SINGLE_CALL_TEST: 유저 0 시작 (단일 호출)
05-17 06:18:55.456 30475 30490 D SINGLE_CALL_TEST: 유저 1 시작 (단일 호출)
05-17 06:18:55.458 30475 30490 D SINGLE_CALL_TEST: 유저 2 시작 (단일 호출)
05-17 06:18:55.461 30475 30490 D SINGLE_CALL_TEST: 유저 3 시작 (단일 호출)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST: 유저 0, 예외 발생: failed to connect to /172.20.188.27 (port 21000) from /10.0.2.16 (port 36122) after 1000ms, 시간: 1107ms
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST: java.net.SocketTimeoutException: failed to connect to /172.20.188.27 (port 21000) from /10.0.2.16 (port 36122) after 1000ms
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at libcore.io.IoBridge.connectErrno(IoBridge.java:235)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at libcore.io.IoBridge.connect(IoBridge.java:179)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at java.net.PlainSocketImpl.socketConnect(PlainSocketImpl.java:142)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:390)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:230)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:212)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:436)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at java.net.Socket.connect(Socket.java:646)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.platform.Platform.connectSocket(Platform.kt:128)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.RealConnection.connectSocket(RealConnection.kt:295)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.RealConnection.connect(RealConnection.kt:207)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.ExchangeFinder.findConnection(ExchangeFinder.kt:226)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.ExchangeFinder.findHealthyConnection(ExchangeFinder.kt:106)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.ExchangeFinder.find(ExchangeFinder.kt:74)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.RealCall.initExchange$okhttp(RealCall.kt:255)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.kt:32)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.cache.CacheInterceptor.intercept(CacheInterceptor.kt:95)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.http.BridgeInterceptor.intercept(BridgeInterceptor.kt:83)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(RetryAndFollowUpInterceptor.kt:76)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.RealCall.getResponseWithInterceptorChain$okhttp(RealCall.kt:201)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at okhttp3.internal.connection.RealCall.execute(RealCall.kt:154)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at com.example.myapplication.ExampleInstrumentedTest$performHttpRequest$2.invokeSuspend(ExampleInstrumentedTest.kt:61)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:111)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:99)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:811)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:715)
05-17 06:18:56.564 30475 30490 E SINGLE_CALL_TEST:     at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:702)
05-17 06:18:56.564 30475 30490 D SINGLE_CALL_TEST: 유저 0 단일 호출 완료
05-17 06:18:56.652 30475 30490 I SINGLE_CALL_TEST: 유저 2, 호출 성공: 200, 시간: 1191ms
05-17 06:18:56.653 30475 30490 D SINGLE_CALL_TEST: 유저 2 단일 호출 완료
05-17 06:19:17.675 30475 30490 I SINGLE_CALL_TEST: 유저 3, 호출 성공: 200, 시간: 22214ms
05-17 06:19:17.675 30475 30490 D SINGLE_CALL_TEST: 유저 3 단일 호출 완료
05-17 06:19:38.702 30475 30490 I SINGLE_CALL_TEST: 유저 1, 호출 성공: 200, 시간: 43245ms
05-17 06:19:38.702 30475 30490 D SINGLE_CALL_TEST: 유저 1 단일 호출 완료
05-17 06:19:38.703 30475 30490 D SINGLE_CALL_TEST: 모든 유저 작업 완료
05-17 06:19:38.703 30475 30490 I SINGLE_CALL_TEST: ---------- 테스트 결과 ----------
05-17 06:19:38.703 30475 30490 I SINGLE_CALL_TEST: 총 유저 수: 4
05-17 06:19:38.703 30475 30490 I SINGLE_CALL_TEST: 총 요청 수 (유저당 1회): 4
05-17 06:19:38.703 30475 30490 I SINGLE_CALL_TEST: 성공: 3
05-17 06:19:38.703 30475 30490 I SINGLE_CALL_TEST: 실패: 1
05-17 06:19:38.703 30475 30490 I SINGLE_CALL_TEST: -----------------------------</code></pre>
<br>

<p>예상대로 listen queue(Accept Queue) overflow 가 발생했고, 이로 인해 운영 상 발생한 <br>
<code>SocketTimeoutException: failed to connect to</code> 가 재현되었다. <br></p>
<p>다만 요청은 1번만 실패했는데 listen queue overflow 는 왜 5번이 발생한 것일까? <br></p>
<p>wireshark 패킷부터 분석해보자. 오류가 발생한 유저 (임시 Client 63719 포트) <br></p>
<ol>
<li>Client -&gt; Server : SYN (TCP 연결 시작, 3-way-handshake 시작)</li>
<li>Server : Accept Queue 가 가득 차 SYN 패킷을 Drop 한다.</li>
<li>Client : SYN 패킷을 보냈는데도 응답이 오지 않아, Retransmission Timeout(RTO) 가 발생한다.</li>
<li>Client -&gt; Server : 다시 SYN 패킷을 보낸다. 이 때, 이전 SYN 패킷 요청시간 보다 1초 뒤에 보낸다.</li>
<li>Server : Accept Queue 가 가득 차 SYN 패킷을 Drop 한다.</li>
<li>Client : SYN 패킷을 보냈는데도 응답이 오지 않아, Retransmission Timeout(RTO) 가 발생한다.</li>
<li>Client -&gt; Server : 다시 SYN 패킷을 보낸다. 이 때, 이전 SYN 패킷 요청시간 보다 2초 뒤에 보낸다.</li>
<li>Server : Accept Queue 가 가득 차 SYN 패킷을 Drop 한다.</li>
<li>Client : SYN 패킷을 보냈는데도 응답이 오지 않아, Retransmission Timeout(RTO) 가 발생한다.</li>
<li>Client -&gt; Server : 다시 SYN 패킷을 보낸다. 이 때, 이전 SYN 패킷 요청시간 보다 4초 뒤에 보낸다.</li>
<li>Server : Accept Queue 가 가득 차 SYN 패킷을 Drop 한다.</li>
<li>Client : SYN 패킷을 보냈는데도 응답이 오지 않아, Retransmission Timeout(RTO) 가 발생한다.</li>
<li>Client -&gt; Server : 다시 SYN 패킷을 보낸다. 이 때, 이전 SYN 패킷 요청시간 보다 8초 뒤에 보낸다.</li>
<li>Server : Accept Queue 가 가득 차 SYN 패킷을 Drop 한다.</li>
</ol>
<br>

<p>Retransmission Timeout(RTO) 는 무엇일까? <br>
Client 가 Server 에 최초 3-way-handshake를 수행하기 위해 SYN 패킷을 보냈는데, <br>
정상적인 응답을 받지 못한 경우를 의미한다. <br>
이 때 특정 주기마다 SYN 패킷을 재전송하는데, 이 주기는 min 1s 부터 * 2 를 하며 재수행한다. (backoff, 지수 증가 형식) <br>
재수행 횟수는 Client 가 지정하고 OS 별로 기본값이 다르며, Linux 기준 net.ipv4.tcp_syn_retries 값으로 변경 가능하다. <br></p>
<pre><code class="language-bash"># syn retry 설정값 조회
sysctl net.ipv4.tcp_syn_retries

# syn retry 설정값 적용
# /etc/sysctl.conf 
net.ipv4.tcp_syn_retries=?
# 반영
sudo sysctl -p</code></pre>
<br>

<blockquote>
<p><strong>참고 : net.ipv4.tcp_abort_on_overflow 값에 따라 서버가 바로 RST (거부) 패킷을 보낼 수도 있다.</strong> <br>
이 값은 1일 때, Accept Queue 가 가득 차는 순간 RST 패킷을 보내 Client 가 재시도 하지 않게 한다. <br>
0일 때에는 별다른 응답을 보내지 않아 Client 는 RTO 로 인한 SYN 패킷 재전송을 임계점까지 계속한다.
대체로 기본값은 0이다.</p>
</blockquote>
<br>

<p>추가로 <code>failed to connect to ... after 1000ms</code> 에서 1000ms 는 RTO 와는 관계없이 okHttpClient 의 ConnectTimeout 값이 적용된 것이다. <br>
ConnectTimeout = 5초로 설정 시 에러 로그도 5000ms 로 표시된 것이 확인된다. <br></p>
<p>참고 차 Andriod 에서 언제 SocktTimeoutException 을 발생시키는지 DeCompile한 Andriod Native 코드를 아래 첨부한다. <br>
이는 소켓 프로그래밍을 위한 학습에 도움이 되므로 한 번쯤은 읽어보는 것을 추천한다. <br></p>
<p>아래 코드를 이해한다면, 위에서 발생한 <code>SocketTimeoutException: failed to connecto to ...</code> 뿐만 아니라, <br>
ConnectTimouet 을 매우 길게 잡아놓았을 때, RTO 의 retry 횟수가 임계점까지 도달한 경우는 어느 에러가 발생하는지도 이해할 수 있다. <br></p>
<p>시간이 된다면 언제 Blocking / Non-Blocking 되는지도 살펴보면 더 좋을 듯 하다.</p>
<pre><code class="language-java">
package libcore.io;

...

@SystemApi(client = MODULE_LIBRARIES)
public final class IoBridge {

    private IoBridge() {
    }

    public static int available(FileDescriptor fd) throws IOException {
        try {
            int available = Libcore.os.ioctlInt(fd, FIONREAD);
            if (available &lt; 0) {
                available = 0;
            }
            return available;
        } catch (ErrnoException errnoException) {
            if (errnoException.errno == ENOTTY) {
                return 0;
            }
            throw errnoException.rethrowAsIOException();
        }
    }

    /**
    * connect가 SYN 패킷을 전송하기 전에 bind 수행
    * 소켓(파일 디스크립터 fd)에 로컬 IP 주소와 포트 번호를 할당한다. 
    * 이 작업은 전적으로 로컬 시스템 내에서 이루어지며, 네트워크 패킷을 발생시키지 않는다. 
    * 소켓이 &#39;어떤 로컬 주소와 포트를 사용할 것인가&#39;를 결정하는 단계이다.
    */
    public static void bind(FileDescriptor fd, InetAddress address, int port) throws SocketException {
        if (address instanceof Inet6Address) {
            Inet6Address inet6Address = (Inet6Address) address;
            if (inet6Address.getScopeId() == 0 &amp;&amp; inet6Address.isLinkLocalAddress()) {
                NetworkInterface nif = NetworkInterface.getByInetAddress(address);
                if (nif == null) {
                    throw new SocketException(&quot;Can&#39;t bind to a link-local address without a scope id: &quot; + address);
                }
                try {
                    address = Inet6Address.getByAddress(address.getHostName(), address.getAddress(), nif.getIndex());
                } catch (UnknownHostException ex) {
                    throw new AssertionError(ex);
                }
            }
        }
        try {
            Libcore.os.bind(fd, address, port);
        } catch (ErrnoException errnoException) {
            if (errnoException.errno == EADDRINUSE || errnoException.errno == EADDRNOTAVAIL ||
                errnoException.errno == EPERM || errnoException.errno == EACCES) {
                throw new BindException(errnoException.getMessage(), errnoException);
            } else {
                throw new SocketException(errnoException.getMessage(), errnoException);
            }
        }
    }

    /**
    * 실제 SYN 패킷을 전송하기 위한 connect 메서드
    * SocketTimeoutException 발생 시 로직을 강조한다.
    */
    public static void connect(FileDescriptor fd, InetAddress inetAddress, int port) throws SocketException {
        try {
            IoBridge.connect(fd, inetAddress, port, 0);
        } catch (SocketTimeoutException ex) {
            throw new AssertionError(ex);
        }
    }

    /**
    * 위 connect 에서 내부 호출되는 메서드
    * 여러 Exception 로직에 대한 분기를 담당한다.
    */
    public static void connect(FileDescriptor fd, InetAddress inetAddress, int port, int timeoutMs) throws SocketException, SocketTimeoutException {
        try {
            connectErrno(fd, inetAddress, port, timeoutMs);
        } catch (ErrnoException errnoException) {
            if (errnoException.errno == EHOSTUNREACH) {
                throw new NoRouteToHostException(&quot;Host unreachable&quot;);
            }
            if (errnoException.errno == EADDRNOTAVAIL) {
                throw new NoRouteToHostException(&quot;Address not available&quot;);
            }
            throw new ConnectException(createMessageForException(fd, inetAddress, port, timeoutMs,
                    errnoException), errnoException);
        } catch (SocketException ex) {
            throw ex;
        } catch (SocketTimeoutException ex) {
            throw ex;
        } catch (IOException ex) {
            throw new SocketException(ex);
        }
    }

    /**
    * 위 connect 에서 호출되는 connectErrorno 메서드
    *   timeout이 없는 경우, OS 의 기본 connect 콜을 직접 호출하며 blocking 하게 동작한다.
    *   timeout이 있는 경우, Socket 을 Non-Blocking 모드로 전환하고, SYN 패킷을 전송한다.
    *     연결이 즉시 가능하다면 다시 Blocking 모드로 전환하고 리턴한다.
    *     연결이 즉시 불가능할 때, EINPROGRESS (Operation now in progress) 상태라면 연결 과정이 백그라운드에서 시작되었음을 의미하므로 던지지 않고 이후 로직을 수행한다.
    *       남은 timeout 시간을 계산해 0 이하라면 SocketTimeoutException 을 발생시키며 에러메시지를 별도로 작성한다.
    *       이 때 남은 timeout 시간을 계산하는 것은 polling 방식으로 isConnected 를 통해 이루어진다.
    *       연결이 완료되면 다시 Blocking 모드로 전환한다.
    */
    private static void connectErrno(FileDescriptor fd, InetAddress inetAddress, int port, int timeoutMs) throws ErrnoException, IOException {
        if (timeoutMs &lt;= 0) {
            Libcore.os.connect(fd, inetAddress, port);
            return;
        }

        IoUtils.setBlocking(fd, false);

        long finishTimeNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
        try {
            Libcore.os.connect(fd, inetAddress, port);
            IoUtils.setBlocking(fd, true);
            return;
        } catch (ErrnoException errnoException) {
            if (errnoException.errno != EINPROGRESS) {
                throw errnoException;
            }
        }

        int remainingTimeoutMs;
        do {
            remainingTimeoutMs =
                    (int) TimeUnit.NANOSECONDS.toMillis(finishTimeNanos - System.nanoTime());
            if (remainingTimeoutMs &lt;= 0) {
                throw new SocketTimeoutException(
                        createMessageForException(fd, inetAddress, port, timeoutMs, null));
            }
        } while (!IoBridge.isConnected(fd, inetAddress, port, timeoutMs, remainingTimeoutMs));
        IoUtils.setBlocking(fd, true);
    }

    /**
    * SocketTimeoutException 발생 시 에러메시지를 작성. 
    * failed to connect to ... 에러 메시지는 여기서 나온 것임을 확인할 수 있다.
    */
    private static String createMessageForException(FileDescriptor fd, InetAddress inetAddress,
            int port, int timeoutMs, Exception causeOrNull) {
        InetSocketAddress localAddress = null;
        try {
            localAddress = getLocalInetSocketAddress(fd);
        } catch (SocketException ignored) {
        }

        StringBuilder sb = new StringBuilder(&quot;failed to connect&quot;)
              .append(&quot; to &quot;)
              .append(inetAddress)
              .append(&quot; (port &quot;)
              .append(port)
              .append(&quot;)&quot;);
        if (localAddress != null) {
            sb.append(&quot; from &quot;)
              .append(localAddress.getAddress())
              .append(&quot; (port &quot;)
              .append(localAddress.getPort())
              .append(&quot;)&quot;);
        }
        if (timeoutMs &gt; 0) {
            sb.append(&quot; after &quot;)
              .append(timeoutMs)
              .append(&quot;ms&quot;);
        }
        if (causeOrNull != null) {
            sb.append(&quot;: &quot;)
              .append(causeOrNull.getMessage());
        }
        return sb.toString();
    }

    ...

    /**
    * 실제 연결이 성공했는지 (ACK 응답을 받았는지) 확인
    * poll 시스템 콜에 전달할 file descriptor (소켓) 과 timeout 을 전달해, Non-Blocking 하게 소켓의 상태 변화를 감시한다.
    *   poll 시스템콜의 반환값 rc
    *     rc == 0 : timeout, 소켓의 상태 변화가 없었음을 의미한다. false 리턴 후 다시 while 문으로 시도 시 전체
    *               timeoutMs 시간이 초과되면 SocketTimeoutException 발생
    *     rc &gt; 0 : 연결 성공, write() 가 가능한 상태가 되었음을 의미한다.
    *   이후 socket 이 쓰기 가능한 상태가 되었더라도, 실제 연결이 성공했는지 검증한다. 
    *     connectError : 0 (오류 없음), != 0 (오류 발생)
    *   위 단계에서 연결 상태가 성공적이지 않은 경우, file descriptor 가 유효한지 않은지 (다른 Thread 에서 닫혔는지 등) 검증한다.
    *   이후 에러 원인이 ETIMEDOUT 임을 확인해 SocketTimeoutException 을 던진다.
    *   (rc == 0 과는 다름. 시스템 레벨의 timeout, 예시로 RTO 가 여러 번 발생해 시스템이 더 이상 재시도하지 않겠다 판단하는 등..)
    * 이 외의 경우에는 모두 ConnectException 으로 처리한다.
    *   
    */
    @UnsupportedAppUsage
    public static boolean isConnected(FileDescriptor fd, InetAddress inetAddress, int port,
            int timeoutMs, int remainingTimeoutMs) throws IOException {
        ErrnoException cause;
        try {
            StructPollfd[] pollFds = new StructPollfd[] { new StructPollfd() };
            pollFds[0].fd = fd;
            pollFds[0].events = (short) POLLOUT;
            int rc = Libcore.os.poll(pollFds, remainingTimeoutMs);
            if (rc == 0) {
                return false; // Timeout.
            }
            int connectError = Libcore.os.getsockoptInt(fd, SOL_SOCKET, SO_ERROR);
            if (connectError == 0) {
                return true; // Success!
            }
            throw new ErrnoException(&quot;isConnected&quot;, connectError); // The connect(2) failed.
        } catch (ErrnoException errnoException) {
            if (!fd.valid()) {
                throw new SocketException(&quot;Socket closed&quot;);
            }
            cause = errnoException;
        }
        String detail = createMessageForException(fd, inetAddress, port, timeoutMs, cause);
        if (cause.errno == ETIMEDOUT) {
            SocketTimeoutException e = new SocketTimeoutException(detail);
            e.initCause(cause);
            throw e;
        }
        throw new ConnectException(detail, cause);
    }

    ...
}</code></pre>
<br>
<br>

<p>이 이론대로라면, Accept Queue 가 가득찬 상태에서 N 개의 요청이 인입되면 5*N 번의 listen queue overflow 및 SYN drop 현상이 발생할 것이다. <br></p>
<p>한 번만 더 검증해보자. <br>
이번에는 <strong>동시 호출 유저 수 : 103</strong> 으로 설정해 3명이 Accept Queue 를 가득 채우고, <br>
나머지 100명이 overflow 되는 요청을 보내고 각각 4번의 retry 를 수행해 5 * 100 번의 overflow 및 SYN drop 현상이 발생할 것이다. <br></p>
<pre><code class="language-log">05-17 08:23:29.341 31525 31539 D SINGLE_CALL_TEST: 모든 유저 작업 완료
05-17 08:23:29.341 31525 31539 I SINGLE_CALL_TEST: ---------- 테스트 결과 ----------
05-17 08:23:29.341 31525 31539 I SINGLE_CALL_TEST: 총 유저 수: 103
05-17 08:23:29.341 31525 31539 I SINGLE_CALL_TEST: 총 요청 수 (유저당 1회): 103
05-17 08:23:29.341 31525 31539 I SINGLE_CALL_TEST: 성공: 3
05-17 08:23:29.341 31525 31539 I SINGLE_CALL_TEST: 실패: 100
05-17 08:23:29.341 31525 31539 I SINGLE_CALL_TEST: -----------------------------</code></pre>
<br>

<pre><code class="language-log">Every 1.0s: netstat -s | grep -i &quot;listen&quot;

    500 times the listen queue of a socket overflowed
    500 SYNs to LISTEN sockets dropped</code></pre>
<p>이론대로 맞아 떨어짐이 확인된다. <br>
그러면 운영 상에서 listen queue overflow 가 발생한 횟수로 얼마만큼의 Client 입장에서 얼마만큼의 API 요청이 실패했는지 역산이 가능하다. <br></p>
<pre><code class="language-log"># 1.
2063660 times the listen queue of a socket overflowed
2245967 SYNs to LISTEN sockets dropped

# 2.
1611204 times the listen queue of a socket overflowed
1621418 SYNs to LISTEN sockets dropped

# 3.
1703204 times the listen queue of a socket overflowed
1703256 SYNs to LISTEN sockets dropped

# 4.
1592389 times the listen queue of a socket overflowed
1592447 SYNs to LISTEN sockets dropped</code></pre>
<p>앞서 언급한 운영 상의 metric 들을 보았을 떄, 2063660 + 1611204 + 1703204 + 1592389 = <code>6,568,459</code> 라는 overflow 횟수가 확인된다. <br>
또한 APP 의 OkhttpClient 의 ConnectTimeout 은 5초로 설정되어 있다. </p>
<br>

<p>overflow가 발생 가능한 경우의 수를 살펴보자.</p>
<ul>
<li>Client 가 SYN 패킷을 보냈는데 ACK 응답을 받지 못한 경우, RTO 가 발생할 수 있다.</li>
<li>RTO 가 발생할 경우, 최초 SYN 패킷 전송 시점으로부터 <code>1초 뒤 SYN 패킷을 재전송</code>한다.<ul>
<li>이 때 다시 ACK 응답을 받지 못한 경우, 위 SYN 재전송 시점으로부터 <code>2초 뒤 SYN 패킷을 재전송</code>한다. <ul>
<li>이 때 다시 ACK 응답을 받지 못한 경우, 위 SYN 재전송 시점으로부터 <code>4초 뒤 SYN 패킷을 재전송</code>하려는데, 
이미 SYN 재전송 시점이 ConnectTimeout(5초)을 초과한 상태이므로, ConnectTimeout 이 발생한다. 
하지만 <code>Exception 이 발생해도 Client 는 SYN 패킷을 재전송하므로 overflow 에는 집계가 된다.</code><ul>
<li>이 때 다시 ACK 응답을 받지 못한 경우, 위 SYN 재전송 시점으로부터 <code>8초 뒤 SYN 패킷을 재전송</code>한다.</li>
<li>또는 ACK 응답을 받은 경우에는 정상 Connection 이 이루어진다.</li>
</ul>
</li>
<li>또는 ACK 응답을 받은 경우에는 정상 Connection 이 이루어진다.</li>
</ul>
</li>
<li>또는 ACK 응답을 받은 경우에는 정상 Connection 이 이루어진다.</li>
</ul>
</li>
</ul>
<p>요약하자면 RTO 가 발생해도 중간에 ACK 를 응답받으면 Connection 이 이루어질 수 있다는 것이다. <br>
그래서 꼭 1 + 4 retry 의 요청이 모두 실패했다고 가정하기 보다는, <br>
3번의 RTO 발생 후 ACK 가 응답되어 Client 는 Exception 이 발생해도 overflow 는 3번만 발생할 수 있으므로 아래와 같이 정의할 수 있다. <br>
<code>최소 6,568,459 / 5 (1,313,691) &lt; Client API 요청 실패 횟수 &lt; 최대 6,568,459 / 3 (2,189,486)</code></p>
<br>

<p>RTO 가 2번 발생하고 연결된 경우는 최초 SYN 패킷 전송 후 1초 + 2초 = 3초가 된 상태이므로 Client 는 연결이 성공한 것에 유의하자. <br></p>
<br>
<br>

<hr>
<br>
<br>


<h1 id="테스트">테스트</h1>
<br>
<br>

<h2 id="부하테스트-결과에-대한-오해">부하테스트 결과에 대한 오해</h2>
<p>다수의 개발자들이 부하테스트 결과의 TPS 만을 보고 성능과 임계치를 판단하는 경우가 많다. <br>
TPS 는 말 그대로 <code>Transactions Per Second</code>, 서버가 초당 몇 개의 요청을 처리했냐 라는 값이다. <br></p>
<p>하지만, 저 값은 결과값일 뿐이지 조건에 대한 명시는 없다. <br>
<code>몇 명의 유저가 동시에(VUs)</code>, <code>몇 초 동안</code>, <code>응답시간</code> 과 같은 조건들은 중요하지 않은가에 대해 생각해볼 필요가 있다. <br></p>
<p>한 가지 예시를 들어보자.</p>
<p><strong>부하테스트 예시 1</strong></p>
<ul>
<li>결과 : 서버는 10,000 TPS 를 처리</li>
<li>결과 : API 평균 응답시간 : 10ms</li>
<li>결과 : 처리량 : 100,000</li>
<li>조건 : 동시 유저 수 (VUs) : 100</li>
<li>조건 : 수행 시간 : 10초</li>
</ul>
<p><strong>부하테스트 예시 2</strong></p>
<ul>
<li>결과 : 서버는 10,000 TPS 를 처리</li>
<li>결과 : API 평균 응답시간 : 100ms</li>
<li>결과 : 처리량 : 1,000,000</li>
<li>조건 : 동시 유저 수 (VUs) : 1000</li>
<li>조건 : 수행 시간 : 100초</li>
</ul>
<p>위 두 부하테스트 결과를 보면, TPS 는 동일하다. <br>
하지만 API 평균 응답시간을 보자. 완전히 다른 양상을 보인다. <br>
또한 조건을 보면 사용자 수가 10배 이상 차이남에도 우연히 TPS 가 비슷할 뿐이다. <br></p>
<p>따라서, 부하테스트를 결과를 분석할 때에는 아래 사항들을 반드시 명시해야 한다.</p>
<ul>
<li><code>TPS</code></li>
<li><code>API 최소/평균/최대 응답시간 (+ 하위5/10% 응답시간 등..)</code></li>
<li><code>동시 유저 수 (VUs)</code></li>
<li><code>수행 시간</code></li>
<li><code>각 가상 유저의 API 호출 간 간격</code></li>
</ul>
<br>


<h2 id="k6-부하테스트-스크립트-및-수행-방식">k6 부하테스트 스크립트 및 수행 방식</h2>
<p><a href="https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95">k6 부하테스트 스크립트 작성법</a></p>
<p>K6 에 대해 궁금하다면, 위를 참고하자. Goroutine 기바능로 JMeter 보다 훨씬 경량으로 많은 수의 부하를 줄 수 있다. <br></p>
<p>부하테스트를 어떻게 수행했는지 공유한다. <br>
K6 부하테스트 인스턴스 2개를 사용해 Long Polling 방식으로 테스트를 수행했으며, <br>
K6 상의 가상 유저 수(VUs) 수와 Spring Boot 내장 Tomcat max-connections 값을 변경하며 테스트했다. <br></p>
<p><strong>부하테스트 스크립트</strong></p>
<pre><code class="language-javascript">import http from &#39;k6/http&#39;;
import { sleep, check, group } from &#39;k6&#39;;
import { Counter } from &#39;k6/metrics&#39;; 

// 외부 환경변수로부터 stages 값 주입
const stage1_duration = __ENV.STAGE1_DURATION || &#39;5s&#39;;
const stage1_target = Number(__ENV.STAGE1_TARGET || 2000);
const stage2_duration = __ENV.STAGE2_DURATION || &#39;20s&#39;;
const stage2_target = Number(__ENV.STAGE2_TARGET || 4000);
const stage3_duration = __ENV.STAGE3_DURATION || &#39;5s&#39;;
const stage3_target = Number(__ENV.STAGE3_TARGET || 0);

// 테스트 설정
export let options = {
  stages: [
    { duration: stage1_duration, target: stage1_target }, 
    { duration: stage2_duration, target: stage2_target }, 
    { duration: stage3_duration, target: stage3_target },  
  ],
  tags: {                            
    team : &#39;team1&#39;,
    test_name: &#39;load-test&#39; 
  }, 
};

// 커스텀 메트릭 정의
const waitRequests = new Counter(&#39;API1_requests_total&#39;);
const entryRequests = new Counter(&#39;API1_requests_total&#39;);
const canEnterFalse = new Counter(&#39;can_enter_false_count&#39;);
const canEnterTrue = new Counter(&#39;can_enter_true_count&#39;);

export function setup() {
  console.log(&#39;Setup: Initializing test setup...&#39;);

  // 공통으로 사용할 헤더 초기화
  let headers = {
    &#39;accept&#39;: &#39;*/*&#39;,
    &#39;Content-Type&#39;: &#39;application/json&#39;,
  };

  // 상황에 맞게 body 작성
  const waitPayload = JSON.stringify({
    &quot;body&quot;: &quot;value&quot;
  });

  return {
    headers: headers,
    waitPayload: waitPayload
  }
}

export default function (data) {
  let token = null;
  let canEnter = false;

  group(&#39;POST /wait&#39;, function () {
    let res = http.post(&#39;http://host.name.com:port/api/wait&#39;, data.waitPayload, {headers: data.headers});
    waitRequests.add(1); 

    check(res, {&#39;is WAIT status 200&#39;: (r) =&gt; r.status === 200 });

    let resBody = res.json(); 
    canEnter = resBody.canEnter;
    console.log(`WAIT - canEnter: ${canEnter}, Status: ${res.status}, Body: ${res.body}, Duration: ${res.timings.duration}ms`);
    token = resBody.token; 

    if (canEnter) {
      canEnterTrue.add(1);
    } else {
      canEnterFalse.add(1); 
      const pollingPeriod = resBody.waiting?.pollingPeriod || 5000;
      sleep(pollingPeriod / 1000); 
    }
  });

  if (!canEnter) {
    group(&#39;POST /entry&#39;, function () {
      const entryPayload = JSON.stringify({
        &quot;zoneId&quot;: &quot;KTC_TEST_ZONE&quot;,
        &quot;token&quot;: token 
      });

      while (!canEnter) {
        entryRequests.add(1); 
        let res = http.post(&#39;http://host.name.com:port/api/entry&#39;, entryPayload, {headers: data.headers});
        let resBody = res.json(); 
        canEnter = resBody.canEnter;

        check(res, {&#39;is ENTRY status 200&#39;: (r) =&gt; r.status === 200 });
        console.log(`ENTRY - Status code: ${res.status}, Body: ${res.body}, Duration: ${res.timings.duration}ms`);

        if (canEnter) {
          canEnterTrue.add(1);
          break;
        } else {
          canEnterFalse.add(1); 
          const pollingPeriod = resBody.waiting?.pollingPeriod || 5000;
          sleep(pollingPeriod / 1000); 
        }
      }
    });
  } else {
      console.log(&quot;Skipping ENTRY request because canEnter was not true or token was not obtained.&quot;);
  }

  console.log(&quot;1 user entered!\n\n&quot;)
}</code></pre>
<p><strong>AWS SSM 으로 k6 인스턴스에 일괄 스크립트 수행</strong></p>
<ul>
<li>k6-* 라는 tag 가 달린 인스턴스들을 일괄적으로 스크립트를 수행</li>
<li>조건에 따라 STAGE1_TARGET, STAGE2_TARGET 값을 변경하여 수행</li>
<li>monitoring.influxdb 는 부하테스트 결과를 저장하는 별도로 구축한 시계열 DB</li>
</ul>
<pre><code class="language-bash">INSTANCE_IDS=$(aws ec2 describe-instances \
  --filters &quot;Name=tag:Name,Values=k6-*&quot; &quot;Name=instance-state-name,Values=running&quot; \
  --query &quot;Reservations[*].Instances[*].InstanceId&quot; \
  --output text \
  --region ap-northeast-2 | grep . | paste -s -d &#39;,&#39;)

echo &quot;Found Instance IDs (comma-separated): $INSTANCE_IDS&quot;

aws ssm send-command \
  --document-name &quot;AWS-RunShellScript&quot; \
  --targets &quot;Key=InstanceIds,Values=$INSTANCE_IDS&quot; \
  --parameters &#39;{
    &quot;commands&quot;: [
      &quot;STAGE1_DURATION=5s&quot;,
      &quot;STAGE1_TARGET=2000&quot;,
      &quot;STAGE2_DURATION=20s&quot;,
      &quot;STAGE2_TARGET=4000&quot;,
      &quot;STAGE3_DURATION=5s&quot;,
      &quot;STAGE3_TARGET=0&quot;,
      &quot;k6 run --env STAGE1_DURATION=$STAGE1_DURATION --env STAGE1_TARGET=$STAGE1_TARGET --env STAGE2_DURATION=$STAGE2_DURATION --env STAGE2_TARGET=$STAGE2_TARGET --env STAGE3_DURATION=$STAGE3_DURATION --env STAGE3_TARGET=$STAGE3_TARGET --out influxdb=http://monitoring.influxdb:8086/metrics k6-sample.js | tee &gt;(split -b 10M -d - k6_log_)&quot;
    ]
  }&#39; \
  --comment &quot;Run k6 load test (targeting by Instance IDs)&quot; \
  --region ap-northeast-2</code></pre>
<h2 id="테스트-조건">테스트 조건</h2>
<br>

<p>충분한 Warm-Up 후 진행 <br>
(Spring Boot 기동, VUs4000 * 2 로 Warm-Up 부하테스트 후 결과 분석)</p>
<p><strong>고정값</strong></p>
<ul>
<li>AWS EC2 - 동일한 instance type</li>
<li>k6 인스턴스 2개</li>
<li>long polling 방식 스크립트</li>
</ul>
<br>

<p><strong>변동값</strong></p>
<ul>
<li>k6 부하테스트의 가상 유저 수 (VUs)</li>
<li>Spring Boot 내장 Tomcat max-connections 값 </li>
</ul>
<p><strong>유의사항</strong></p>
<ul>
<li>k6 는 수행 시간이 끝나도, 정의한 행동이 끝나지 않았으면 graceful 하게 shutdown 하는 30초 간의 대기시간을 가진다.</li>
<li>그래서 ramp-up time 5초, ramp-max 20초, ramp-down 5초로 설정했음에도 60초간 수행되는 것에 참고하자.</li>
</ul>
<br>

<p>Spring Boot 내장 Tomcat 의 max-connections 설정 방법</p>
<pre><code class="language-yml"># tomcat default : 8192
server:
  tomcat:
    max-connections: 8192</code></pre>
<br>


<h2 id="테스트-결과">테스트 결과</h2>
<h3 id="01-max-connections-기본값-가상유저-4000--2-로-테스트---overflow-발생-안함">01. max-connections 기본값, 가상유저 4000 * 2 로 테스트 -&gt; overflow 발생 안함</h3>
<br>
server.tomcat.max-connections: 8192 (default)       
max VUs : 4000 * 2

<br>

<p><strong>k6 부하테스트 결과 text</strong></p>
<pre><code class="language-bash">
    HTTP
    http_req_duration.......................................................: avg=23.14ms min=581.4µs med=9.12ms max=375.84ms p(90)=71.63ms p(95)=118.17ms
      { expected_response:true }............................................: avg=23.14ms min=581.4µs med=9.12ms max=375.84ms p(90)=71.63ms p(95)=118.17ms
    http_req_failed.........................................................: 0.00% 0 out of 50971
    http_reqs...............................................................: 50971 849.457156/s

    EXECUTION
    vus.....................................................................: 462   min=0          max=3999
    vus_max.................................................................: 4000  min=2478       max=4000

    HTTP
    http_req_duration.......................................................: avg=28.5ms min=593.51µs med=9.89ms max=654.55ms p(90)=82.01ms p(95)=142.29ms
      { expected_response:true }............................................: avg=28.5ms min=593.51µs med=9.89ms max=654.55ms p(90)=82.01ms p(95)=142.29ms
    http_req_failed.........................................................: 0.00% 0 out of 50260
    http_reqs...............................................................: 50260 837.591508/s

    EXECUTION
    vus.....................................................................: 453   min=0          max=3999
    vus_max.................................................................: 4000  min=2459       max=4000    </code></pre>
<br>

<p><strong>k6 부하테스트 결과 influxdb, grafana 시각화 image</strong></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f7b3c584-32b6-473c-9f6e-34b9c0971cc6/image.png" alt="test01-k6.png"></p>
<br>

<p><strong>SpringBoot 기동되는 OS의 tcp 오류 확인</strong></p>
<pre><code class="language-bash">netstat -s | grep -i &quot;listen&quot;
&gt;&gt; # 0이라 노출되지 않음</code></pre>
<br>

<p><strong>SpringBoot 기동되는 OS의 node_exporter metrics image</strong></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a14b286b-5b7a-452a-bd7b-9e621965d1ac/image.png" alt="test01-node-cpu.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/05186442-2404-4570-a24e-fc064f0cbd79/image.png" alt="test01-node-netstat.png"></p>
<br>



<h3 id="02-max-connections-기본값-가상유저-5000--2-로-테스트---overflow-발생">02. max-connections 기본값, 가상유저 5000 * 2 로 테스트 -&gt; overflow 발생</h3>
<br>
server.tomcat.max-connections: 8192 (default)
max VUs : 5000 * 2
<br>

<p><strong>k6 부하테스트 결과 text</strong></p>
<pre><code class="language-bash">
    HTTP
    http_req_duration.......................................................: avg=28.89ms min=0s       med=11.49ms max=466.09ms p(90)=94.77ms p(95)=133.74ms
      { expected_response:true }............................................: avg=29.36ms min=603.74µs med=11.72ms max=466.09ms p(90)=95.76ms p(95)=134.42ms
    http_req_failed.........................................................: 1.60% 852 out of 52977
    http_reqs...............................................................: 52977 882.863833/s

    EXECUTION
    iteration_duration......................................................: avg=32.09s  min=30s      med=32.16s  max=32.89s   p(90)=32.39s  p(95)=32.42s  
    iterations..............................................................: 624   10.398985/s
    vus.....................................................................: 63    min=0            max=5000
    vus_max.................................................................: 5000  min=2503         max=5000

    HTTP
    http_req_duration.......................................................: avg=27.58ms min=0s       med=12.01ms max=466.18ms p(90)=82.71ms p(95)=124.54ms
      { expected_response:true }............................................: avg=28.04ms min=619.48µs med=12.26ms max=466.18ms p(90)=83.61ms p(95)=125.43ms
    http_req_failed.........................................................: 1.62% 854 out of 52497
    http_reqs...............................................................: 52497 874.586077/s

    EXECUTION
    iteration_duration......................................................: avg=32.05s  min=30s      med=32.28s  max=33.48s   p(90)=32.67s  p(95)=32.78s  
    iterations..............................................................: 584   9.729285/s
    vus.....................................................................: 13    min=0            max=5000
    vus_max.................................................................: 5000  min=2568         max=5000</code></pre>
<br>

<p><strong>k6 부하테스트 결과 influxdb, grafana 시각화 image</strong>
<br></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5af9a0cb-91df-41bb-81ee-c45a40ede557/image.png" alt="test02-k6.png"></p>
<br>


<p><strong>SpringBoot 기동되는 OS의 tcp 오류 확인</strong></p>
<pre><code class="language-bash">netstat -s | grep -i &quot;listen&quot;
&gt;&gt;
    8535 times the listen queue of a socket overflowed
    8535 SYNs to LISTEN sockets dropped</code></pre>
<br>

<p><strong>SpringBoot 기동되는 OS의 node_exporter metrics image</strong></p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/3d473f84-4185-43de-923f-29258208d6df/image.png" alt="test02-node-cpu.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/405fa7dd-6a14-4b1b-b2a2-5748c1e6cab0/image.png" alt="test02-node-netstat.png"></p>
<br>



<h3 id="03-max-connections-16-가상-유저-4000--2-로-테스트---overflow-발생">03. max-connections 16, 가상 유저 4000 * 2 로 테스트 -&gt; overflow 발생</h3>
<br>
server.tomcat.max-connections: 16
max VUs : 4000 * 2

<br>

<p><strong>k6 부하테스트 결과 text</strong></p>
<pre><code class="language-bash">
    HTTP
    http_req_duration.......................................................: avg=479.19µs min=0s     med=0s     max=22.99ms p(90)=0s      p(95)=4.34ms 
      { expected_response:true }............................................: avg=6.29ms   min=2.04ms med=5.04ms max=22.99ms p(90)=10.52ms p(95)=13.61ms
    http_req_failed.........................................................: 92.38% 3882 out of 4202
    http_reqs...............................................................: 4202   70.028612/s

    EXECUTION
    iteration_duration......................................................: avg=30s      min=30s    med=30s    max=30.02s  p(90)=30s     p(95)=30s    
    iterations..............................................................: 3882   64.695638/s
    vus.....................................................................: 117    min=0            max=4000
    vus_max.................................................................: 4000   min=2493         max=4000

    HTTP
    http_req_duration................................: avg=0s  min=0s  med=0s  max=0s     p(90)=0s  p(95)=0s 
    http_req_failed..................................: 100.00% 3999 out of 3999
    http_reqs........................................: 3999    72.702344/s

    EXECUTION
    iteration_duration...............................: avg=30s min=30s med=30s max=30.01s p(90)=30s p(95)=30s
    iterations.......................................: 3999    72.702344/s
    vus..............................................: 75      min=0            max=4000
    vus_max..........................................: 4000    min=2487         max=4000    </code></pre>
<br>

<p><strong>k6 부하테스트 결과 influxdb, grafana 시각화 image</strong></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8758896a-701e-48e0-93d2-7b5065eaa156/image.png" alt="test03-k6.png"></p>
<br>

<p><strong>SpringBoot 기동되는 OS의 tcp 오류 확인</strong></p>
<pre><code class="language-bash">netstat -s | grep -i &quot;listen&quot;
&gt;&gt;
    39415 times the listen queue of a socket overflowed
    39415 SYNs to LISTEN sockets dropped</code></pre>
<br>

<p><strong>SpringBoot 기동되는 OS의 node_exporter metrics image</strong></p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f19743a3-4017-4185-a972-6ab725228250/image.png" alt="test03-node-cpu.png"></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5d49719c-848d-4789-87b6-cdb75400eeed/image.png" alt="test03-node-netstat.png"></p>
<br>



<h3 id="04-max-connections-16382-가상-유저-8000--2-로-테스트---overflow-발생-안함">04. max-connections 16382, 가상 유저 8000 * 2 로 테스트 -&gt; overflow 발생 안함</h3>
<br>
server.tomcat.max-connections: 16382
max VUs : 8000 * 2
<br>

<p><strong>k6 부하테스트 결과 text</strong></p>
<pre><code class="language-bash">    HTTP
    http_req_duration.......................................................: avg=33.06ms min=611.59µs med=11.75ms max=583.11ms p(90)=116.04ms p(95)=167.17ms
      { expected_response:true }............................................: avg=33.06ms min=611.59µs med=11.75ms max=583.11ms p(90)=116.04ms p(95)=167.17ms
    http_req_failed.........................................................: 0.00% 0 out of 55113
    http_reqs...............................................................: 55113 887.232557/s

    EXECUTION
    vus.....................................................................: 600   min=0          max=7999
    vus_max.................................................................: 8000  min=2454       max=8000

    HTTP
    http_req_duration.......................................................: avg=31.35ms min=631.21µs med=12.57ms max=500.04ms p(90)=95.61ms p(95)=156.54ms
      { expected_response:true }............................................: avg=31.35ms min=631.21µs med=12.57ms max=500.04ms p(90)=95.61ms p(95)=156.54ms
    http_req_failed.........................................................: 0.00% 0 out of 55907
    http_reqs...............................................................: 55907 889.296189/s

    EXECUTION
    vus.....................................................................: 330   min=0          max=7999
    vus_max.................................................................: 8000  min=2306       max=8000</code></pre>
<br>

<p><strong>k6 부하테스트 결과 influxdb, grafana 시각화 image</strong></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f2fcfeb1-b236-4ffc-9503-2684da54c7ab/image.png" alt="test04-k6.png"></p>
<br>

<p><strong>SpringBoot 기동되는 OS의 tcp 오류 확인</strong></p>
<pre><code class="language-bash">netstat -s | grep -i &quot;listen&quot;
&gt;&gt; # 0이라 노출되지 않음</code></pre>
<br>

<p><strong>SpringBoot 기동되는 OS의 node_exporter metrics image</strong></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/6a36c367-7281-4042-9298-51f93de839e4/image.png" alt="test04-node-cpu.png">
<img src="https://velog.velcdn.com/images/mud_cookie/post/0202baab-4cae-4be7-bfb2-5e16d9d1d675/image.png" alt="test04-node-netstat.png"></p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="결론">결론</h2>
<p>지금까지 Andriod 에서 발생한 <code>SocketTimeoutException: failed to connecto to ...</code> 에러를 재현하고, <br>
원인을 분석해 어떠한 방식으로 개선할 수 있는지까지 알아보았다. <br></p>
<p>그러면 과연 이게 끝일까? <br>
여기서 내가 강조하고자 하는 것은 OS (Kernel) 단의 모니터링도 필수라는 것이다. <br>
이 포스팅에서는 이미 결론부터 설명해, 보는 사람 입장에서는 그다지 어렵지 않게 느껴질 수 있으나 listen queue overflow, SYN Drop 과 같은 metric 들은 직접 찾아보고자 하지 않는 이상 눈치채기가 힘들다. <br>
또한 Kernel 에서는 수많은 metric 들이 존재하는데 반해, 모든 metric 들에 대해 개발자 또는 운영자가 모두 아는 것은 아니며, <br>
이를 위 캡처한 node_exporter 와 같은 metric 수집기를 통해 특정 metric 의 이상현상이 감지되었다 또는 차후 분석을 위해 값이라도 저장해야되는 것이 아닐까 생각한다. <br></p>
<p>다시 한 번 강조하지만, <br>
모든 오류는 Application 단에서만 발생하는 것이 아니며, 그 외적인 요소도 고려할 필요가 있다.</p>
<p><strong>Reference</strong></p>
<blockquote>
<p><a href="https://github.com/torvalds/linux/">Linux Github</a> <br>
<a href="https://tomcat.apache.org/tomcat-10.1-doc/config/http.html">Tomcat Config Docs</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform 부하테스트/모니터링 환경 구축]]></title>
            <link>https://velog.io/@mud_cookie/Terraform-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@mud_cookie/Terraform-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Sun, 11 May 2025 00:36:50 GMT</pubDate>
            <description><![CDATA[<div style="padding:56.25% 0 0 0;position:relative;"><iframe src="https://player.vimeo.com/video/1085390251?h=a100f20fcd&amp;badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media" style="position:absolute;top:0;left:0;width:100%;height:100%;" title="project"></iframe></div><script src="https://player.vimeo.com/api/player.js"></script>


<p>위 영상이 보이지 않을 경우, 아래 링크에서 시청 가능하다. <br>
<a href="https://vimeo.com/1085389717?share=copy">vimeo - Terraform 환경 구축</a></p>
<p>또한 Terraform 코드 예시는 <a href="https://github.com/isckd/ktc-terraform-public">Terraform example code - Github</a> 에서 확인 가능하다.</p>
<br>
<br>

<hr>
<br>
<br>

<h1 id="개요">개요</h1>
<p>위 영상은 부하테스트 구조에 대해 정리한 내용으로, 시청한 뒤 아래 내용을 읽는 것을 추천한다. </p>
<p>사내 가능한 많은 양의 부하를 견딜 수 있어야 하는 프로젝트를 담당하게 되었다. 
로직과 설계도 중요하지만, 어느만큼의 부하를 견딜 수 있는지도 필요했다.</p>
<br>

<p>기존 사내 부하테스트의 문제점은 아래와 같았다.</p>
<ul>
<li>JMeter 로 로컬PC 에서 적은 양의 부하를 주는 것 정도로는 적절한 양의 부하를 줄 수 없었다.</li>
<li>사내 대형 물리 서버 1개에 모든 application 에 띄워 정확한 리소스 사용량을 알 수 없었다.</li>
<li>OS, 부하량, application 의 지연시간 등에 대한 모니터링이 적절하게 수행되지 않고 있었다.</li>
</ul>
<p>그래서, 아래와 같이 신규 프로젝트에 대한 부하테스트 구조를 설계했다.</p>
<ul>
<li>JMeter 보다 훨씬 경량화 (Goroutine 기반) 된 K6 인스턴스를 여러 개 띄워 진정한 의미의 부하를 줄 수 있게 구성</li>
<li>AWS EC2 에 다수의 인스턴스를 각각 서버 스펙을 다르게 설정한다. (사내 Nutanix 도 결국은 VM 이므로)</li>
<li>OS 모니터링, 부하테스트 조건 및 결과에 대한 시각화, 오픈소스 기반 application metric 시각화</li>
<li>Terraform 으로 Network, 인스턴스 스펙, application 설치 및 환경 구축 자동화</li>
<li>AWS 비용 최적화</li>
</ul>
<p>여기서 공개한 Terraform 코드는 예시일 뿐, 실제 instance_type 및 port 는 다르게 구성했음에 참고하길 바란다.</p>
<p>추가로 S3 에 업로드해 사용한 패키지와, 사용한 Grafana Dashboard 들은 아래 첨부한다.</p>
<ul>
<li><a href="https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz">JDK 21.0.2</a></li>
<li><a href="https://download.java.net/java/GA/jdk17.0.2/dfd4a8d0985749f896bed50d7138ee7f/8/GPL/openjdk-17.0.2_linux-x64_bin.tar.gz">JDK 17.0.2</a></li>
<li><a href="https://archive.apache.org/dist/hbase/2.6.2/hbase-2.6.2-bin.tar.gz">HBase 2.6.2</a></li>
<li><a href="https://repo1.maven.org/maven2/com/navercorp/pinpoint/pinpoint-collector/3.0.0/pinpoint-collector-3.0.0-exec.jar">Pinpoint Collector 3.0.0</a></li>
<li><a href="https://repo1.maven.org/maven2/com/navercorp/pinpoint/pinpoint-web/3.0.0/pinpoint-web-3.0.0-exec.jar">Pinpoint Web 3.0.0</a></li>
<li><a href="https://repo1.maven.org/maven2/com/navercorp/pinpoint/pinpoint-agent/3.0.0/pinpoint-agent-3.0.0.tar.gz">Pinpoint Agent 3.0.0</a></li>
<li><a href="https://github.com/oliver006/redis_exporter/releases/download/v1.70.0/redis_exporter-v1.70.0.linux-amd64.tar.gz">Redis Exporter v1.70.0</a></li>
<li><a href="https://github.com/prometheus/prometheus/releases/download/v3.3.0/prometheus-3.3.0.linux-amd64.tar.gz">Prometheus v3.3.0</a></li>
<li><a href="https://dl.grafana.com/enterprise/release/grafana-enterprise-11.6.1-1.x86_64.rpm">Grafana v11.6.1</a></li>
<li><a href="https://grafana.com/docs/grafana/latest/administration/cli/#plugins-commands">Grafana Plugins</a></li>
<li><a href="https://grafana.com/api/plugins/redis-app/versions/2.2.1/download">Grafana Redis App v2.2.1</a></li>
<li><a href="https://download.influxdata.com/influxdb/releases/influxdb-1.11.8.x86_64.rpm">InfluxDB v1.11.8</a></li>
<li><a href="https://github.com/prometheus/node_exporter/releases/download/v1.5.0/node_exporter-1.5.0.linux-amd64.tar.gz">Node Exporter v1.5.0</a></li>
</ul>
<p>Grafana Dashboard</p>
<ul>
<li><a href="https://grafana.com/grafana/dashboards/763-redis-dashboard-for-prometheus-redis-exporter-1-x/">Redis Dashboard for Prometheus Redis Exporter 1.x</a></li>
<li><a href="https://github.com/isckd/integration-monitoring/blob/main/grafana-custom-dashboard/k6%20Load%20Testing%20Results-with-test_name.json">직접 커스텀한 K6 Dashboard</a></li>
<li><a href="https://grafana.com/grafana/dashboards/12486-node-exporter-full/">Node Exporter Full</a></li>
</ul>
<br>
<br>

<hr>
<br>
<br>


<h1 id="사전-개념">사전 개념</h1>
<br>

<h3 id="aws-s3-simple-storage-service">AWS S3 (Simple Storage Service)</h3>
<p>AWS에서 제공하는 객체 스토리지 서비스. 인터넷을 통해 언제 어디서든 원하는 양의 데이터를 저장하고 검색할 수 있다.</p>
<ul>
<li><p>주요 특징:</p>
<ul>
<li>확장성: 거의 무제한의 데이터 저장 용량.</li>
<li>내구성 및 가용성: 매우 높은 내구성과 가용성 제공.</li>
<li>비용 효율성: 사용량 기반 과금 및 다양한 스토리지 클래스 제공.</li>
</ul>
</li>
<li><p>주요 용도:</p>
<ul>
<li>정적 웹사이트 호스팅.</li>
<li>데이터 백업 및 복구.</li>
<li>애플리케이션 파일 저장소 활용.</li>
</ul>
</li>
</ul>
<br>

<h3 id="terraform">Terraform</h3>
<p>HashiCorp에서 개발한 오픈 소스 IaC (Infrastructure as Code) 도구. 코드를 사용하여 클라우드 및 온프레미스 리소스를 프로비저닝하고 관리한다.</p>
<ul>
<li>주요 특징:<ul>
<li>선언적 구문: 원하는 인프라의 최종 상태를 코드로 정의.</li>
<li>다양한 프로바이더 지원: AWS, Azure, Google Cloud Platform 등 여러 클라우드 및 서비스 지원.</li>
<li>상태 관리: 인프라의 현재 상태 추적 및 효율적인 변경 관리.</li>
<li>모듈화: 재사용 가능한 코드 작성을 통한 인프라 구성 표준화 및 단순화.</li>
</ul>
</li>
<li>주요 용도:<ul>
<li>클라우드 인프라 자동 프로비저닝.</li>
<li>인프라 변경 관리 및 버전 관리.</li>
<li>개발, 스테이징, 프로덕션 환경 일관성 유지.</li>
<li>멀티 클라우드 환경 관리.</li>
</ul>
</li>
</ul>
<p>Terraform 은 AWS Console 의 기본값만 사용하던 유저들에게는 조금 학습이 필요할 수 있다.
예시로 EC2 의 인스턴스 생성을 하는 데에도 수십가지의 옵션이 존재하는데, 이를 일일이 지정해주어야 한다. </p>
<br>


<h3 id="aws-ssm-systems-manager">AWS SSM (Systems Manager)</h3>
<p>AWS 환경 및 온프레미스 환경에서 인프라를 가시화하고 제어하는 서비스. 운영 체제 수준에서 인프라를 관리하고 자동화하는 데 사용된다.</p>
<ul>
<li>주요 특징:<ul>
<li>중앙 집중식 관리: 여러 서버와 인스턴스를 한 곳에서 관리.</li>
<li>자동화: 패치 적용, 소프트웨어 설치 등 반복 작업 자동화.</li>
<li>보안: SSH 접속 없이 원격 명령 실행 및 세션 시작 기능 제공.</li>
<li>가시성: 인스턴스 인벤토리 및 상태 파악 용이.</li>
</ul>
</li>
<li>주요 용도:<ul>
<li>EC2 인스턴스 및 온프레미스 서버 패치 관리.</li>
<li>소프트웨어 배포 및 구성 관리.</li>
<li>원격 명령 실행 및 세션 관리.</li>
<li>인스턴스 인벤토리 수집 및 관리.</li>
<li>자동화된 운영 워크플로우 생성.</li>
</ul>
</li>
</ul>
<br>
<br>

<hr>
<br>
<br>

<h1 id="사전-준비">사전 준비</h1>
<br>

<h3 id="aws-첫-계정이라면-console-에서-기본적으로-세팅해야될-것들">AWS 첫 계정이라면 Console 에서 기본적으로 세팅해야될 것들</h3>
<ol>
<li>aws cli 로 접속하기 위한 Access key, Private key 생성</li>
<li>ssh 접속 키를 사용자마다 변경하기 힘드므로, ssh .pem 키를 최초 생성한 (예진욱M) 에게 키를 요청한다.</li>
<li>관련 파일들을 s3 에 업로드</li>
</ol>
<br>

<h3 id="s3-에-k6-바이너리-업로드-예시">S3 에 k6 바이너리 업로드 예시</h3>
<p>(public access 차단되지 않은 경우, 만약 차단되었다면 수동 업로드)</p>
<pre><code class="language-powershell"># powershell
# 변수 설정
$k6Version = &quot;v0.58.0&quot;
$k6FileName = &quot;k6-$k6Version-linux-amd64.tar.gz&quot;
$k6Url = &quot;https://github.com/grafana/k6/releases/download/$k6Version/$k6FileName&quot;
$localPath = &quot;$PSScriptRoot\$k6FileName&quot;
$s3Bucket = &quot;your-bucket-name&quot;   # ← 실제 S3 버킷명으로 변경
$s3Key = $k6FileName

# k6 바이너리 다운로드
Invoke-WebRequest -Uri $k6Url -OutFile $localPath

# S3에 파일이 이미 있는지 확인
$exists = aws s3 ls &quot;s3://$s3Bucket/$s3Key&quot;

if (-not $exists) {
    Write-Host &quot;S3에 파일이 없으므로 업로드합니다.&quot;
    aws s3 cp $localPath &quot;s3://$s3Bucket/$s3Key&quot;
} else {
    Write-Host &quot;이미 S3에 파일이 존재합니다. 업로드하지 않습니다.&quot;
}</code></pre>
<br>

<h3 id="terraform-install-windows">Terraform install (Windows)</h3>
<p><a href="https://developer.hashicorp.com/terraform/install">https://developer.hashicorp.com/terraform/install</a> 에서 다운로드
-&gt; C:\terraform 압축 해제 후 시스템 변수 -&gt; Path -&gt; C:\terraform 추가.</p>
<pre><code># terraform 설치 확인
terraform -version</code></pre><p>기본적으로 main.tf 파일에 정의한다.
적용은 terraform apply, 취소는 terraform destory 명령어로 수행한다.
옵션으로는</p>
<ul>
<li>-auto-apoprove : 명령 수행 여부에 대한 질문을 스킵한다.</li>
<li>-target=&quot;abc.def&quot; : abc.def 에 대한 것만 apply 또는 destroy 한다.</li>
</ul>
<p>또한 apply 된 것에 대해 중복 또는 crash 가 발생하지 않도록 .lock 파일로 정합성을 관리한다. <br></p>
<p>이번에는 나 혼자 수행해 여러 환경에서의 terraform 정합성을 맞출 필요가 없었지만, 여러 명이서 작업하는 경우에는 <code>state locking</code> 이라는 개념을 활용한다.</p>
<p>필요 시 S3, DynamoDB 를 활용한 <code>Terraform state locking</code> 에 대해 찾아보면 된다.</p>
<br>


<h3 id="aws-cli-install-windows">AWS CLI install (Windows)</h3>
<p>가이드 : <a href="https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html">https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html</a> </p>
<pre><code class="language-powershell"># 설치
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
# 설치 확인
aws --version
# 자격 증명
aws configure
&gt; AWS Access Key ID (보안 자격 증명(Security credentials) - Access Key)
&gt; AWS Secret Access Key (Access Key 발급 시에만 볼 수 있으므로 없는 경우 새로 생성)
&gt; region (예: ap-northeast-2)
&gt; output format: json</code></pre>
<br>

<h3 id="aws-ssm-plugin-install-windows">AWS SSM Plugin install (Windows)</h3>
<p>가이드 : <a href="https://docs.aws.amazon.com/ko_kr/systems-manager/latest/userguide/install-plugin-windows.html">https://docs.aws.amazon.com/ko_kr/systems-manager/latest/userguide/install-plugin-windows.html</a>
설치 파일 다운로드</p>
<ul>
<li><a href="https://s3.amazonaws.com/session-manager-downloads/plugin/latest/windows/SessionManagerPluginSetup.exe">https://s3.amazonaws.com/session-manager-downloads/plugin/latest/windows/SessionManagerPluginSetup.exe</a></li>
</ul>
<br>

<h3 id="vs-code-terraform-플러그인-설치">VS Code Terraform 플러그인 설치</h3>
<p><code>HashiCorp Terraform</code> 설치</p>
<p>이후 main.tf 생성</p>
<pre><code class="language-powershell">terraform init  # Terraform 초기화
terraform plan  # 실행 계획 확인 (사전 오류 확인)</code></pre>
<h3 id="자동화-도구-선택">자동화 도구 (선택)</h3>
<p><a href="https://former2.com/">Former2</a>
AWS 리소스를 Terraform 코드로 변환해주는 도구. 
현재 EC2 인스턴스를 선택하면 자동으로 코드가 생성.
-&gt; AWS 접근권한을 줘야되기 때문에.. 아무리 읽기 권한이라도 좀 꺼림칙해서 pass</p>
<br>
<br>

<hr>
<br>
<br>


<h1 id="terraform-코드-작성">Terraform 코드 작성</h1>
<br>

<p><strong>기본적으로 main.tf 코드에 작성한다.</strong></p>
<br>

<h3 id="기본-네트워크-변수-구성">기본 네트워크 변수 구성</h3>
<p>vpc 및 subnet 이 어느 region 을 사용할 것인가,
CIDR block 을 어느 범위까지 허용할 것인가를 지정한다.</p>
<p>variable 은 변수로, 나중에 실제 resource 를 생성할 때 활용할 수 있게 해준다.
결국 terraform 도 언어이므로, 변수 지정 및 재활용하기 편하게 구성할 수 있다. </p>
<p>기존에 만들어둔 VPC 또는 subnet 이 있다면 하드코딩해도 되지만, 네트워크 설정까지 자동화하면 나중에 편하다.</p>
<p><code>172.31.0.0/16</code> - 이 값은 AWS의 기본 VPC CIDR 블록 중 하나.
<code>172.31.32.0/20</code> - VPC CIDR 블록 (/16) 내에서 서브넷 CIDR 블록 (/20)이 할당.</p>
<pre><code class="language-terraform">variable &quot;vpc_cidr_block&quot; {
  description = &quot;CIDR block for the VPC&quot;
  type        = string
  default     = &quot;172.31.0.0/16&quot;
}

variable &quot;subnet_cidr_block&quot; {
  description = &quot;CIDR block for the public subnet&quot;
  type        = string
  default     = &quot;172.31.32.0/20&quot;
}

variable &quot;availability_zone&quot; {
  description = &quot;Availability Zone for the subnet&quot;
  type        = string
  default     = &quot;ap-northeast-2c&quot;
}

# AWS Provider 설정 (서울 리전)
provider &quot;aws&quot; {
  region = &quot;ap-northeast-2&quot;
}</code></pre>
<br>

<h3 id="네트워크-구성">네트워크 구성</h3>
<br>

<p>위 설정한 변수를 활용해 vpc, igw, public subnet 을 생성한다. <br></p>
<p>여기서 <code>resource</code>란 <code>terraform apply</code> 또는 <code>terraform destory</code> 으로 생성 또는 제거할 수 있는 자원을 뜻한다. <br></p>
<p>꼭 EC2 인스턴스 뿐 아니라, 네트워크 구성도 resource 로 할당할 수 있음에 참고하자.</p>
<p>중간에 <code>depends_on</code> 이라는 것은 해당 리소스를 생성하기 전에 먼저 생성되어야 할 것을 명시한다. 
<code>depends_on</code> 을 명시하면 resource 생성 순서를 보장할 수 있고, terraform apply 시 혹여 이전 설정이 누락됐을 때 depondes_on 안에 있는 resource 를 먼저 생성해준다.</p>
<pre><code class="language-terraform"># VPC 생성
resource &quot;aws_vpc&quot; &quot;main&quot; {
  cidr_block           = var.vpc_cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = &quot;ktc-vpc&quot;
  }
}

# Internet Gateway 생성
resource &quot;aws_internet_gateway&quot; &quot;gw&quot; {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = &quot;ktc-igw&quot;
  }
}

# Public Subnet 생성
resource &quot;aws_subnet&quot; &quot;public&quot; {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.subnet_cidr_block
  availability_zone       = var.availability_zone
  map_public_ip_on_launch = true

  tags = {
    Name = &quot;ktc-public-subnet&quot;
  }
}

# --- 기본 라우팅 테이블 관리 (경로 및 태그) ---
resource &quot;aws_default_route_table&quot; &quot;main&quot; {
  default_route_table_id = aws_vpc.main.main_route_table_id

  route {
    cidr_block = &quot;0.0.0.0/0&quot;
    gateway_id = aws_internet_gateway.gw.id
  }

  tags = {
    Name = &quot;ktc-main-route-table&quot; # 기본 라우팅 테이블 이름 지정
  }

  # 인터넷 게이트웨이가 생성된 후에 이 설정이 적용되도록 의존성 명시
  depends_on = [aws_internet_gateway.gw]
}</code></pre>
<br>

<h3 id="security-group-보안-그룹-생성">Security Group (보안 그룹) 생성</h3>
<br>

<p>기본적으로 EC2 인스턴스가 띄워질 때, 모든 Access 가 차단된다. <br></p>
<p>물론 AWS Console 에서는 설정에 따라 SSH 용 22 port 를 열거나 Outbound 는 열어두게 할 수 있으나, 
이를 Terraform 에서 자동화하기 위해선 Security Group 에 명시해야 한다. <br></p>
<p>또한 동일 private network 에 있다고 하더라도 특정 port 만 열게 구성해야 하며,
아래 코드와 같이 작성할 수 있다.</p>
<p>추가로 <code>from_port</code> 와 <code>to_port</code> 는 &lt;= 해당 범위 &lt;= 안에 있는 모든 port 에 적용한다는 의미이다. 
AWS Console 에서는 9991-9999 라는 뜻이 여기서는 from_port = 9991  to_port = 9999 를 의미한다.</p>
<pre><code class="language-terraform">resource &quot;aws_security_group&quot; &quot;main_sg&quot; {
  name        = &quot;ktc-sg&quot;
  description = &quot;KTC main security group based on screenshot&quot;
  vpc_id      = aws_vpc.main.id

  # Allow SSH from Anywhere
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = &quot;tcp&quot;
    cidr_blocks = [&quot;0.0.0.0/0&quot;]
    description = &quot;Allow SSH access from anywhere&quot;
  }

  # Allow Pinpoint-Web (8080) from Anywhere
  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = &quot;tcp&quot;
    cidr_blocks = [&quot;0.0.0.0/0&quot;]
    description = &quot;Allow Pinpoint-Web access from anywhere&quot;
  }

  # Allow Grafana (9090) from Anywhere
  ingress {
    from_port   = 9090
    to_port     = 9090
    protocol    = &quot;tcp&quot;
    cidr_blocks = [&quot;0.0.0.0/0&quot;]
    description = &quot;Allow Grafana access from anywhere&quot;
  }

  # Allow Grafana (3000) from Anywhere
  ingress {
    from_port   = 3000
    to_port     = 3000
    protocol    = &quot;tcp&quot;
    cidr_blocks = [&quot;0.0.0.0/0&quot;]
    description = &quot;Allow Grafana access from anywhere&quot;
  }

  # Allow node_exporter (9100) from Self
  ingress {
    from_port = 9100
    to_port   = 9100
    protocol  = &quot;tcp&quot;
    self      = true
    description = &quot;Allow node_exporter traffic from self&quot;
  }

  # Allow influxdb (8086) from Self
  ingress {
    from_port = 8086
    to_port   = 8086
    protocol  = &quot;tcp&quot;
    self      = true
    description = &quot;Allow influxdb traffic from self&quot;
  }

  # Allow Pinpoint-Collector (9991-9999) from Self
  ingress {
    from_port = 9991
    to_port   = 9999
    protocol  = &quot;tcp&quot;
    self      = true
    description = &quot;Allow Pinpoint Collector traffic from self&quot;
  }

  # Allow External-to-KTC (12345-12346) from Self (Simplified)
  ingress {
    from_port = 12345
    to_port   = 12346
    protocol  = &quot;tcp&quot;
    self      = true
    description = &quot;Allow KTC internal communication (Simplified)&quot;
  }

  # Allow Redis-to-Redis (17000-17021) from Self
  ingress {
    from_port = 17000
    to_port   = 17021
    protocol  = &quot;tcp&quot;
    self      = true
    description = &quot;Allow Redis Cluster communication&quot;
  }

  # Allow External-to-Redis (7000-7021) from Self (Simplified)
  ingress {
    from_port = 7000
    to_port   = 7021
    protocol  = &quot;tcp&quot;
    self      = true
    description = &quot;Allow internal access to Redis (Simplified)&quot;
  }

  # Allow Redis Exporter
  ingress {
    from_port = 9121
    to_port   = 9121
    protocol  = &quot;tcp&quot;
    self      = true
    description = &quot;Allow Redis Exporter&quot;
  }

  # 모든 아웃바운드 트래픽 허용 (Default)
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = &quot;-1&quot;
    cidr_blocks = [&quot;0.0.0.0/0&quot;]
  }

  tags = {
    Name = &quot;ktc-sg&quot;
  }
}</code></pre>
<br>

<h3 id="iam-role">IAM Role</h3>
<br>

<p>여기서는 아웃바운드 트래픽으로 인한 비용을 최소화하기 위해 S3 를 사용한다. 
예를 들어 특정 패키지를 외부로부터 download 받는 행위 자체가 Outbound 트래픽이며, 보통 하나의 패키지 당 GB 단위이므로 이것도 쌓이면 무시할 수 없다.</p>
<p>AWS 에서 관리되는 여러 서비스에서, 같은 계정이라도 각각의 서비스에 접근하려면 특정 권한이 필요하다.</p>
<p>그래서 S3 및 EC2 접근 권한을 부여하며, 나중에 모든 인스턴스들에 특정 Shell Script 명령을 수행하기 위한 SSM 도 추가한다.</p>
<p>role(policy) 를 생성했음에도 별도로 profile 을 생성한 이유는 EC2 에서 Role 을 할당하기 위해선 별도의 profile 로 설정해야 하기 때문이다.</p>
<pre><code class="language-terraform"># S3 접근 관련 (아웃바운드 트래픽 비용 최소화를 위해 내부 네트워크인 S3 접근권한 부여)

# EC2에 접근할 수 있도록 IAM Role 생성
data &quot;aws_iam_policy_document&quot; &quot;ec2_assume_role_policy&quot; {
  statement {
    actions = [&quot;sts:AssumeRole&quot;]
    principals {
      type        = &quot;Service&quot;
      identifiers = [&quot;ec2.amazonaws.com&quot;]
    }
  }
}

# S3 읽기 권한이 있는 IAM Role 생성
resource &quot;aws_iam_role&quot; &quot;ec2_s3_readonly&quot; {
  name               = &quot;ec2-s3-readonly-role&quot;
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role_policy.json
}

# S3 ReadOnlyAccess 정책 연결
resource &quot;aws_iam_role_policy_attachment&quot; &quot;s3_readonly_attach&quot; {
  role       = aws_iam_role.ec2_s3_readonly.name
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess&quot;
}

resource &quot;aws_iam_role_policy_attachment&quot; &quot;ssm_core_attach&quot; {
  role       = aws_iam_role.ec2_s3_readonly.name
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore&quot;
}

# EC2 DescribeInstances 권한 정책 생성
resource &quot;aws_iam_policy&quot; &quot;ec2_describe_instances_policy&quot; {
  name        = &quot;ec2-describe-instances-policy&quot;
  description = &quot;Allows EC2 instances to describe other EC2 instances&quot;

  policy = jsonencode({
    Version = &quot;2012-10-17&quot;,
    Statement = [
      {
        Action   = &quot;ec2:DescribeInstances&quot;,
        Effect   = &quot;Allow&quot;,
        Resource = &quot;*&quot;
      }
    ]
  })
}

# 생성한 DescribeInstances 정책 연결
resource &quot;aws_iam_role_policy_attachment&quot; &quot;ec2_describe_attach&quot; {
  role       = aws_iam_role.ec2_s3_readonly.name
  policy_arn = aws_iam_policy.ec2_describe_instances_policy.arn
}

# EC2 인스턴스 프로파일 생성
resource &quot;aws_iam_instance_profile&quot; &quot;ec2_profile&quot; {
  name = &quot;ec2-s3-readonly-profile&quot;
  role = aws_iam_role.ec2_s3_readonly.name
}</code></pre>
<br>

<hr>
<h3 id="ec2-인스턴스-생성-pinpoint">EC2 인스턴스 생성 (pinpoint)</h3>
<br>

<p>이제 ec2 인스턴스를 생성해보자.
우선 다른 ec2 인스턴스의 depends_on 하지 않는 pinpoint 인스턴스부터 살펴보자.</p>
<p><strong>AMI</strong></p>
<p><code>AMI</code> 는 어느 OS 를 사용할 건지를 의미한다.
Amazon Linux 뿐 아니라 Ubuntu, Windows, Red Hat, SUSE, Debian 계열이 사용 가능하며, EC2 인스턴스를 생성할 때 이미 OS 가 설치된 상태로 나온다. 
Amazon Linux 를 선택하면 나중에 Aws Console 웹뷰에서 바로 SSH 접속하는 기능을 기본적으로 지원하므로, 이를 선택했다.</p>
<blockquote>
<p>참고 : Amazon Linux 가 아니더라도 다른 AMI 에서 특정 플러그인을 설치하면 AWS Console 웹뷰의 SSH 접속을 이용할 수 있다. <br>
그리고, Amazon Linux 의 패키지 매니저는 Red Hat 계열의 패키지 매니저와 동일하므로 CentOS 또는 Rocky Linux 를 이용하던 사용자라면 더 익숙할 것이다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ce7861c0-63f4-4f29-84ed-1ba864cb35d6/image.png" alt=""></p>
<p><strong>instance_type</strong></p>
<p><code>instance_type</code> 은 하나의 EC2 인스턴스가 컴퓨팅 리소스를 어떻게 할당받을 것인지 지정한다. 
각 타입별로 무엇을 의미하는지는 <a href="https://aws.amazon.com/ko/ec2/pricing/on-demand/">AWS - EC2 instance_type</a> 에서 확인이 가능하다.</p>
<p>간략하게 말하자면, </p>
<ul>
<li>맨 앞의 알파벳은 어느 작업에 특화되어있는 인스턴스 타입인지 명시한다. (일반, DB, 빅데이터, CPU 최적화 ...)</li>
<li>그 뒤의 숫자는 세대를 의미한다. 일반적으로 세대가 올라갈수록 가격이 조금 내려가고 성능이 올라간다. </li>
<li>숫자 뒤의 알파벳 (옵션) 은 그 안에서도 세부적으로 특정 작업에 특화된 인스턴스 타입을 의미한다. 여기에는 Storage 를 HDD 가 아닌 SSD 기반으로 설정되어있거나, 대량의 네트워크 대역폭을 지원하는 등의 옵션에 따라 분기될 수 있다.</li>
<li>맨 뒤의 nano, micro, medium... 은 CPU, RAM 할당량을 AWS 가 직접 지정해 <code>이 타입에서는 이 정도를 제공한다</code> 라는 의미로 받아들이면 된다.</li>
</ul>
<p>또한 AMI (OS) 별로 가격이 다르니 참고하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/3b8d19f7-54d6-4b05-b722-57b4c5100be7/image.png" alt=""></p>
<br>

<blockquote>
<p>key_name 에는 기존 AWS Console 에서 생성한 SSH Key 를 활용한다. 
Terraform 에서는 직접 SSH Key 를 생성할 수 없다.</p>
</blockquote>
<br>

<p>이후 위에서 설정한 네트워크 변수 및 profile 등을 지정할 수 있다.</p>
<p><code>tags</code> 에는 생성되는 인스턴스들의 이름을 각각 지정할 수 있고, 이 tag 를 통해 나중에 SSM 또는 Lambda 에서 활용이 가능하다. (특정 tag 를 가진 인스턴스에서만 특정 명령을 수행하는 등)</p>
<p><code>root_block_device</code> 에서는 storage (volume) 에 대한 속성을 지정할 수 있다. 
volume_size 는 GB 단위이며, 최근 EC2 는 대부분 gp3 기반의 EBS volume 을 사용한다.</p>
<p>EC2 의 instance_type 별로 지원되는 volume 형태가 다르므로 참고하자.
<a href="https://aws.amazon.com/ko/ec2/pricing/on-demand/">AWS - EC2 instance_type</a></p>
<br>

<p><code>metadata_options</code> 는 모든 EC2 인스턴스에 동일하게 적용했는데, 
이는 일반적인 상황에서 해당 기본값 외에 사용되는 용도는 거의 없으니 저대로 사용해도 무방하다.</p>
<br>

<p><code>user_data</code> 는 인스턴스를 띄운 후 어떤 동작을 수행할 것인지 정의할 수 있다.
레퍼런스 검색 시에는 .tf 파일 안에 단순히 몇 줄의 코드를 넣는 예시가 존재하지만, 
여기서는 수십 줄 이상의 shell script 를 사용할 예정이므로 가독성과 편의를 위해 별도 파일로 분리했다.</p>
<p>${path.module} 은 main.tf 디렉토리가 위치한 곳이다.
.tpl 은 terraform 에서 사용할 shell script 를 파일로 관리하기 위한 확장자이다.</p>
<pre><code class="language-terraform">resource &quot;aws_instance&quot; &quot;pinpoint&quot; {
  ami                         = &quot;ami-0a463f27534bdf246&quot; # Amazon Linux 2 AMI
  instance_type               = &quot;c6i.large&quot;              # 인스턴스 타입
  key_name                    = &quot;your-ssh-key-name&quot;        # SSH 키페어 이름
  subnet_id                   = aws_subnet.public.id # 생성된 서브넷 사용
  vpc_security_group_ids      = [aws_security_group.main_sg.id] # 생성된 보안 그룹 사용
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name # S3 접근 권한 추가
  depends_on = [
    aws_iam_role_policy_attachment.s3_readonly_attach, 
    aws_iam_role_policy_attachment.ec2_describe_attach
  ]

  tags = {
    Name = &quot;Pinpoint-Server&quot;
    Role = &quot;pinpoint&quot;
  }

  root_block_device {
    delete_on_termination = true           # 인스턴스 종료 시 EBS 볼륨 삭제
    volume_size = 100                      # 루트 볼륨 크기(GB)
    volume_type = &quot;gp3&quot;                   # 루트 볼륨 타입
  }

  monitoring        = false     # 상세 모니터링 비활성화 (기본 5분 단위)
  ebs_optimized     = false     # EBS 최적화 비활성화

  metadata_options {
    http_tokens                  = &quot;required&quot;   # IMDSv2 필수
    http_put_response_hop_limit  = 2            # 메타데이터 응답 홉 제한
    http_endpoint                = &quot;enabled&quot;    # 인스턴스 메타데이터 엔드포인트 활성화
    http_protocol_ipv6           = &quot;disabled&quot;   # IPv6 메타데이터 비활성화
    instance_metadata_tags       = &quot;disabled&quot;   # 인스턴스 메타데이터 태그 비활성화
  }

  # Use templatefile for user_data
  user_data = templatefile(&quot;${path.module}/scripts/pinpoint_setup.sh.tpl&quot;, {})
}</code></pre>
<br>

<p><strong>pinpoint_setup.sh.tpl</strong></p>
<p>아래는 위 EC2 인스턴스에 pinoint 를 설치하기 위한 shell script 이다.</p>
<ul>
<li>UTC time 은 Asiz/Seoul 로 설정하고, </li>
<li>S3 에서 설치파일들을 가져와 여러 패키지들을 설치하고,</li>
<li>이를 나중에 쉽게 관리하기 위한 스크립트를 생성하고, </li>
<li>실행하는 명령어까지 존재한다.</li>
</ul>
<p>여기서 굳이 ec2-user 권한으로 실행한 이유는, 해당 .sh.tpl 은 모두 <code>root</code> 유저로 실행되어 나중에 ssh 접속 시 해당 스크립트로 설치/생성된 모든 것들이 root 권한으로 되어있어
일일이 sudo 권한으로 조회/수정 해야하는 번거로움 때문에 기본적으로 제공하는 ec2-user 유저로 수행하는 것이다.</p>
<p>또한 해당 스크립트들은 EC2 인스턴스가 생성되어 접속이 가능해진 상태더라도, 계속해서 스크립트는 실행 중일 수 있다.
그래서 EC2 인스턴스 SSH 접속 시 아직 해당 스크립트가 수행 중인지 확인하려면,
<code>tail -F /var/log/cloud-init-output.log</code> 명령어로 지금 어느 단계까지 왔는지 확인이 가능하다. </p>
<p>중간중간 echo 명령어를 출력한 이유가 위 때문이다.</p>
<p>pinpoint 설치 과정에 대해서는 포스팅의 주제와 벗어나 설명하지는 않는다. </p>
<pre><code class="language-bash">#!/bin/bash
# Set timezone to KST
sudo timedatectl set-timezone Asia/Seoul

# 모든 명령을 ec2-user 권한으로 실행
runuser -l ec2-user -c &#39;
  # S3에서 OpenJDK 17.0.2 다운로드 및 설치
  echo &quot;Downloading and installing OpenJDK 17.0.2...&quot;
  cd /home/ec2-user
  aws s3 cp s3://your-bucket-name/pinpoint/openjdk-17.0.2_linux-x64_bin.tar.gz /home/ec2-user/
  tar -zxvf /home/ec2-user/openjdk-17.0.2_linux-x64_bin.tar.gz
  sudo mkdir -p /usr/lib/jvm
  sudo mv jdk-17.0.2 /usr/lib/jvm/jdk-17.0.2
  sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/jdk-17.0.2/bin/java 1
  sudo update-alternatives --install /usr/bin/javac javac /usr/lib/jvm/jdk-17.0.2/bin/javac 1
  echo &quot;export JAVA_HOME=/usr/lib/jvm/jdk-17.0.2&quot; &gt;&gt; ~/.bashrc
  source ~/.bashrc
  echo &quot;export PATH=$PATH:$JAVA_HOME/bin&quot; &gt;&gt; ~/.bashrc
  source ~/.bashrc
  java -version
  echo &quot;OpenJDK 17.0.2 installed.&quot;

  # S3에서 HBase 다운로드 및 설치
  echo &quot;Downloading and installing HBase...&quot;
  aws s3 cp s3://your-bucket-name/pinpoint/hbase-2.6.2-bin.tar.gz /home/ec2-user/
  tar -zxvf /home/ec2-user/hbase-2.6.2-bin.tar.gz
  sudo mkdir -p /usr/lib/hbase
  sudo mv hbase-2.6.2 /usr/lib/hbase/hbase-2.6.2
  echo &quot;export HBASE_HOME=/usr/lib/hbase/hbase-2.6.2&quot; &gt;&gt; ~/.bashrc
  source ~/.bashrc
  echo &quot;export PATH=$PATH:$HBASE_HOME/bin&quot; &gt;&gt; ~/.bashrc
  source ~/.bashrc
  hbase version
  echo &quot;HBase 2.6.2 installed.&quot;

  # HBase 환경 변수 설정
  echo &quot;export JAVA_HOME=/usr/lib/jvm/jdk-17.0.2&quot; &gt;&gt; /usr/lib/hbase/hbase-2.6.2/conf/hbase-env.sh
  # HBase 시작
  /usr/lib/hbase/hbase-2.6.2/bin/start-hbase.sh
  echo &quot;HBase started.&quot;

  # HBase 테이블 생성 스크립트 다운로드
  echo &quot;Waiting for HBase to start...&quot;
  sleep 10
  echo &quot;Creating HBase pinpoint table...&quot;
  wget https://raw.githubusercontent.com/pinpoint-apm/pinpoint/master/hbase/scripts/hbase-create.hbase
  /usr/lib/hbase/hbase-2.6.2/bin/hbase shell hbase-create.hbase
  echo &quot;HBase pinpoint table created.&quot;

  # pinpoint 설치
  echo &quot;Installing Pinpoint web/collector...&quot;
  mkdir -p /home/ec2-user/pinpoint
  cd /home/ec2-user/pinpoint
  aws s3 cp s3://your-bucket-name/pinpoint/pinpoint-collector-3.0.0-exec.jar /home/ec2-user/pinpoint
  aws s3 cp s3://your-bucket-name/pinpoint/pinpoint-web-3.0.0-exec.jar /home/ec2-user/pinpoint
  chmod +x pinpoint-collector-3.0.0-exec.jar
  chmod +x pinpoint-web-3.0.0-exec.jar
  echo &quot;Pinpoint web/collector installed.&quot;

  echo &quot;create pinpoint start.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/pinpoint/start.sh
#!/bin/bash
nohup java -jar -Dpinpoint.zookeeper.address=localhost pinpoint-collector-3.0.0-exec.jar &gt;/dev/null 2&gt;&amp;1 &amp;
nohup java -jar -Dpinpoint.zookeeper.address=localhost pinpoint-web-3.0.0-exec.jar &gt;/dev/null 2&gt;&amp;1 &amp;
EOF

  echo &quot;create pinpoint stop.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/pinpoint/stop.sh
#!/bin/bash
kill -9 $(ps aux | grep &quot;pinpoint-collector-3.0.0-exec.jar&quot; | grep -v grep | awk &quot;{print $2}&quot;)
kill -9 $(ps aux | grep &quot;pinpoint-web-3.0.0-exec.jar&quot; | grep -v grep | awk &quot;{print $2}&quot;)
EOF

  echo &quot;create pinpoint status.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/pinpoint/status.sh
#!/bin/bash
ps aux | grep &quot;pinpoint-collector-3.0.0-exec.jar&quot; | grep -v grep | awk &quot;{print $2}&quot;)
ps aux | grep &quot;pinpoint-web-3.0.0-exec.jar&quot; | grep -v grep | awk &quot;{print $2}&quot;)
EOF

  echo &quot;Setting pinpoint permissions...&quot;
  chmod +x $HOME/pinpoint/start.sh
  chmod +x $HOME/pinpoint/stop.sh
  chmod +x $HOME/pinpoint/status.sh

  echo &quot;Starting Pinpoint collector / web...&quot;
  $HOME/pinpoint/start.sh

  echo &quot;waiting for pinpoint to start...&quot;
  sleep 20

&#39; </code></pre>
<br>

<hr>
<h3 id="ec2-인스턴스-생성-redis">EC2 인스턴스 생성 (redis)</h3>
<br>

<p>여기서는 추가 변수를 사용한다. </p>
<p>Redis 를 Cluster 형태로 구축할 것인데, 각 Node 가 어느포트를 할당할 것이고, 어느 포트가 master / replica 일지를 사전에 지정해서 shell script 에서 활용하고자 한다.</p>
<p>또한 EC2 resource 의 count 에는 몇 개의 인스턴스를 띄울 것인지도 지정할 수 있고,
tag 역시 변수를 사용해 각각의 인스턴스에 이름을 다르게 지정할 수 있다.</p>
<p>첨언하자면, Redis 는 In-memory 기반이므로 Storage 는 적게 할당, Memory 용량을 많이 할당하는 것이 이론 상 적절하다. 
또한 CPU 집약적인 연산이 들어간다면 적절한 instance_type 을 설정하길 바란다.</p>
<blockquote>
<p>Redis 는 backup 을 위해 RDB 또는 AOP 로깅 방식을 지원한다.
장애 대응을 위해 backup 사용 시 RDB 는 압축 형식이라 용량을 적게 차지하고 사람이 알아보기 힘들고 유실 가능성이 다소 있는 데 반해,
AOP 로깅은 압축 없이 사람이 볼 수 있는 형태이면서 유실 가능성이 거의 없는 대신 차지하는 용량이 많아질 뿐더러 성능에 영향이 갈 수 있음에 참고하자.</p>
</blockquote>
<pre><code class="language-terraform">variable &quot;redis_ports&quot; {
  description = &quot;Redis cluster ports&quot;
  type        = list(number)
  default     = [7000, 7001, 7010, 7011, 7020, 7021]
}

variable &quot;redis_master_ports&quot; {
  description = &quot;Redis master ports&quot;
  type        = list(number)
  default     = [7000, 7010, 7020]
}

variable &quot;redis_replica_ports&quot; {
  description = &quot;Redis replica ports&quot;
  type        = list(number)
  default     = [7001, 7011, 7021]
}

variable &quot;redis_replica_mapping&quot; {
  description = &quot;Redis replica to master mapping (index based)&quot;
  type        = map(number)
  default     = {
    &quot;7001&quot; = 0  # 7001은 7000의 레플리카
    &quot;7011&quot; = 1  # 7011은 7010의 레플리카
    &quot;7021&quot; = 2  # 7021은 7020의 레플리카
  }
}

resource &quot;aws_instance&quot; &quot;redis&quot; {
  count                       = length(var.redis_ports) # 포트 개수만큼 인스턴스 생성
  ami                         = &quot;ami-0a463f27534bdf246&quot; # Amazon Linux 2 AMI
  instance_type               = &quot;t3.medium&quot;              # 인스턴스 타입
  key_name                    = &quot;your-ssh-key-name&quot;        # SSH 키페어 이름
  subnet_id                   = aws_subnet.public.id # 생성된 서브넷 사용
  vpc_security_group_ids      = [aws_security_group.main_sg.id] # 생성된 보안 그룹 사용
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name # S3 접근 권한 추가
  depends_on = [
    aws_iam_role_policy_attachment.s3_readonly_attach, 
    aws_iam_role_policy_attachment.ec2_describe_attach
  ]

  tags = {
    Name = &quot;Redis-Server-${var.redis_ports[count.index]}&quot; # 포트 번호를 인스턴스 이름에 추가
    RedisPort = &quot;${var.redis_ports[count.index]}&quot;         # 포트 번호 태그
    RedisCluster = &quot;redis-cluster&quot;                        # 클러스터 식별 태그
    RedisRole = contains(var.redis_master_ports, var.redis_ports[count.index]) ? &quot;master&quot; : &quot;replica&quot; # 역할 식별
    RedisIndex = &quot;${count.index}&quot;                         # 인덱스 식별용 태그
  }

  root_block_device {
    delete_on_termination = true
    volume_size           = 8
    volume_type           = &quot;gp3&quot;
  }

  monitoring        = false
  ebs_optimized     = false

  metadata_options {
    http_tokens                  = &quot;required&quot;
    http_put_response_hop_limit  = 2
    http_endpoint                = &quot;enabled&quot;
    http_protocol_ipv6           = &quot;disabled&quot;
    instance_metadata_tags       = &quot;disabled&quot;
  }

  user_data = templatefile(&quot;${path.module}/scripts/redis_setup.sh.tpl&quot;, {
    redis_port = var.redis_ports[count.index],  # 현재 인스턴스의 포트
    is_master = contains(var.redis_master_ports, var.redis_ports[count.index]), # 마스터 여부
    is_first_master = var.redis_ports[count.index] == var.redis_master_ports[0], # 첫 번째 마스터인지 여부
    redis_password = &quot;123456&quot; # Redis 비밀번호
  })
}</code></pre>
<br>

<p>위에서 설정한 redis port, master/replica 여부 등을 활용해 redis 를 shell script 로 설치할 수 있게 .sh.tpl 을 구성했다.</p>
<p>추가로 현재 .sh.tpl 을 수행하는 인스턴스의 IP 를 가져오는 것을 TOKEN 및 MY_IP 변수에 담아두는 과정이 포함되어있으니 참고하면 좋을 듯 하다.</p>
<p>이 역시 상세 설치과정은 설명하지는 않겠지만, 변수를 어떻게 활용했는지 정도는 참고하길 바란다.</p>
<p><strong>redis_setup.sh.tpl</strong></p>
<pre><code class="language-bash">#!/bin/bash

# Set timezone to KST
sudo timedatectl set-timezone Asia/Seoul

# 로그 파일 설정
exec &gt; &gt;(tee /home/ec2-user/redis_install.log) 2&gt;&amp;1
echo &quot;Redis 설치 스크립트 시작: $(date)&quot;

# Redis 포트 및 역할 설정
REDIS_PORT=${redis_port}
IS_MASTER=${is_master}
IS_FIRST_MASTER=${is_first_master}
REDIS_PASSWORD=${redis_password}

# Redis 설치
yum update -y
yum install -y gcc make jemalloc-devel tcl jq aws-cli

# Redis 소스 다운로드 및 설치
mkdir -p /home/ec2-user/redis-cluster
cd /home/ec2-user/redis-cluster
aws s3 cp s3://your-bucket-name/redis-7.4.0.tar.gz ./redis-7.4.0.tar.gz
tar xzf redis-7.4.0.tar.gz
cd redis-7.4.0
make distclean
make
make install

# 포트에 대한 디렉토리 생성
mkdir -p /home/ec2-user/redis-cluster/node-$REDIS_PORT/data
mkdir -p /home/ec2-user/redis-cluster/node-$REDIS_PORT/log

# EC2 메타데이터 획득
TOKEN=$(curl -s -X PUT &quot;http://169.254.169.254/latest/api/token&quot; -H &quot;X-aws-ec2-metadata-token-ttl-seconds: 21600&quot;)
MY_IP=$(curl -s -H &quot;X-aws-ec2-metadata-token: $TOKEN&quot; http://169.254.169.254/latest/meta-data/local-ipv4)
MY_INSTANCE_ID=$(curl -s -H &quot;X-aws-ec2-metadata-token: $TOKEN&quot; http://169.254.169.254/latest/meta-data/instance-id)
REGION=$(curl -s -H &quot;X-aws-ec2-metadata-token: $TOKEN&quot; http://169.254.169.254/latest/meta-data/placement/region)

echo &quot;My IP: $MY_IP&quot; &gt;&gt; /home/ec2-user/redis_install.log
echo &quot;My Instance ID: $MY_INSTANCE_ID&quot; &gt;&gt; /home/ec2-user/redis_install.log
echo &quot;Region: $REGION&quot; &gt;&gt; /home/ec2-user/redis_install.log

# redis.conf 파일 생성
tee /home/ec2-user/redis-cluster/node-$REDIS_PORT/redis.conf &lt;&lt;EOF
port $REDIS_PORT
bind 127.0.0.1 $MY_IP
cluster-enabled yes
cluster-config-file nodes-$REDIS_PORT.conf
cluster-node-timeout 5000
dbfilename dump-$REDIS_PORT.rdb
appendonly yes
daemonize yes
dir /home/ec2-user/redis-cluster/node-$REDIS_PORT/data
logfile /home/ec2-user/redis-cluster/node-$REDIS_PORT/log/redis-$REDIS_PORT.log
requirepass $REDIS_PASSWORD
masterauth $REDIS_PASSWORD
EOF

# Redis 서버 시작
echo &quot;Starting Redis on port $REDIS_PORT&quot; &gt;&gt; /home/ec2-user/redis_install.log
redis-server /home/ec2-user/redis-cluster/node-$REDIS_PORT/redis.conf

# 상태 확인
sleep 5
if redis-cli -p $REDIS_PORT -a $REDIS_PASSWORD ping | grep -q PONG; then
  echo &quot;Redis is running on port $REDIS_PORT&quot; &gt;&gt; /home/ec2-user/redis_install.log
else
  echo &quot;Failed to start Redis on port $REDIS_PORT&quot; &gt;&gt; /home/ec2-user/redis_install.log
  exit 1
fi

# 클러스터 구성 (첫 번째 마스터 노드에서만 실행)
if [ &quot;$IS_FIRST_MASTER&quot; = &quot;true&quot; ]; then
  echo &quot;This is the first master node, waiting for all instances to be ready...&quot; &gt;&gt; /home/ec2-user/redis_install.log

  # 모든 Redis 인스턴스를 찾기 위해 최대 30번 시도
  for attempt in {1..30}; do
    echo &quot;Attempt $attempt to find all Redis instances...&quot; &gt;&gt; /home/ec2-user/redis_install.log

    # AWS CLI를 사용하여 같은 클러스터에 속한 모든 Redis 인스턴스 가져오기
    INSTANCES=$(aws ec2 describe-instances \
      --region $REGION \
      --filters &quot;Name=tag:RedisCluster,Values=redis-cluster&quot; &quot;Name=instance-state-name,Values=running&quot; \
      --query &quot;Reservations[*].Instances[*].[InstanceId,PrivateIpAddress,Tags[?Key==&#39;RedisPort&#39;].Value|[0],Tags[?Key==&#39;RedisRole&#39;].Value|[0]]&quot; \
      --output json)

    # 인스턴스 수 확인 (마스터 3개, 레플리카 3개)
    INSTANCE_COUNT=$(echo $INSTANCES | jq &#39;. | flatten | length / 4&#39; | awk &#39;{print int($1)}&#39;)
    echo &quot;Found $INSTANCE_COUNT Redis instances&quot; &gt;&gt; /home/ec2-user/redis_install.log

    if [ &quot;$INSTANCE_COUNT&quot; -eq &quot;6&quot; ]; then
      echo &quot;All 6 Redis instances are running&quot; &gt;&gt; /home/ec2-user/redis_install.log
      break
    fi

    if [ $attempt -eq 30 ]; then
      echo &quot;Timed out waiting for all Redis instances!&quot; &gt;&gt; /home/ec2-user/redis_install.log
      exit 1
    fi

    echo &quot;Waiting for all Redis instances, retrying in 10 seconds...&quot; &gt;&gt; /home/ec2-user/redis_install.log
    sleep 10
  done

  # 추가 시간 대기 (모든 인스턴스가 완전히 준비될 때까지)
  echo &quot;Waiting additional time for instances to fully initialize...&quot; &gt;&gt; /home/ec2-user/redis_install.log
  sleep 60

  # 마스터 및 레플리카 노드 정보 수집
  MASTER_INFO=$(echo $INSTANCES | jq -c &#39;[.[][] | select(.[3] == &quot;master&quot;) | [.[1], .[2]]]&#39;)
  REPLICA_INFO=$(echo $INSTANCES | jq -c &#39;[.[][] | select(.[3] == &quot;replica&quot;) | [.[1], .[2]]]&#39;)

  echo &quot;Master info: $MASTER_INFO&quot; &gt;&gt; /home/ec2-user/redis_install.log
  echo &quot;Replica info: $REPLICA_INFO&quot; &gt;&gt; /home/ec2-user/redis_install.log

  # 모든 Redis 노드가 응답하는지 확인
  echo &quot;Checking if all Redis nodes are responsive...&quot; &gt;&gt; /home/ec2-user/redis_install.log
  ALL_RESPONSIVE=true

  # 마스터 노드 확인
  for node in $(echo &quot;$MASTER_INFO&quot; | jq -c &#39;.[]&#39;); do
    IP=$(echo $node | jq -r &#39;.[0]&#39;)
    PORT=$(echo $node | jq -r &#39;.[1]&#39;)

    if ! redis-cli -h $IP -p $PORT -a $REDIS_PASSWORD ping | grep -q PONG; then
      echo &quot;Master node $IP:$PORT is not responsive!&quot; &gt;&gt; /home/ec2-user/redis_install.log
      ALL_RESPONSIVE=false
    else
      echo &quot;Master node $IP:$PORT is responsive&quot; &gt;&gt; /home/ec2-user/redis_install.log
    fi
  done

  # 레플리카 노드 확인
  for node in $(echo &quot;$REPLICA_INFO&quot; | jq -c &#39;.[]&#39;); do
    IP=$(echo $node | jq -r &#39;.[0]&#39;)
    PORT=$(echo $node | jq -r &#39;.[1]&#39;)

    if ! redis-cli -h $IP -p $PORT -a $REDIS_PASSWORD ping | grep -q PONG; then
      echo &quot;Replica node $IP:$PORT is not responsive!&quot; &gt;&gt; /home/ec2-user/redis_install.log
      ALL_RESPONSIVE=false
    else
      echo &quot;Replica node $IP:$PORT is responsive&quot; &gt;&gt; /home/ec2-user/redis_install.log
    fi
  done

  if [ &quot;$ALL_RESPONSIVE&quot; = &quot;false&quot; ]; then
    echo &quot;Not all Redis nodes are responsive. Exiting.&quot; &gt;&gt; /home/ec2-user/redis_install.log
    exit 1
  fi

  # 클러스터 생성 (마스터 노드만 사용)
  echo &quot;Creating Redis cluster...&quot; &gt;&gt; /home/ec2-user/redis_install.log
  master_nodes=&quot;&quot;
  for node in $(echo &quot;$MASTER_INFO&quot; | jq -c &#39;.[]&#39;); do
    IP=$(echo $node | jq -r &#39;.[0]&#39;)
    PORT=$(echo $node | jq -r &#39;.[1]&#39;)
    master_nodes=&quot;$master_nodes $IP:$PORT&quot;
  done

  # 마스터 노드로 클러스터 생성
  echo &quot;Creating cluster with masters:$master_nodes&quot; &gt;&gt; /home/ec2-user/redis_install.log
  redis-cli --cluster create $master_nodes -a $REDIS_PASSWORD --cluster-yes &gt;&gt; /home/ec2-user/redis_install.log 2&gt;&amp;1
  sleep 10

  # 레플리카 노드 추가
  echo &quot;Adding replica nodes to the cluster...&quot; &gt;&gt; /home/ec2-user/redis_install.log

  # 레플리카-마스터 매핑 (포트 기준)
  # 7001 -&gt; 7000, 7011 -&gt; 7010, 7021 -&gt; 7020
  declare -A REPLICA_TO_MASTER
  REPLICA_TO_MASTER[&quot;7001&quot;]=&quot;7000&quot;
  REPLICA_TO_MASTER[&quot;7011&quot;]=&quot;7010&quot;
  REPLICA_TO_MASTER[&quot;7021&quot;]=&quot;7020&quot;

  # 레플리카 노드 추가
  for node in $(echo &quot;$REPLICA_INFO&quot; | jq -c &#39;.[]&#39;); do
    REPLICA_IP=$(echo $node | jq -r &#39;.[0]&#39;)
    REPLICA_PORT=$(echo $node | jq -r &#39;.[1]&#39;)

    # 마스터 포트 찾기
    MASTER_PORT=$${REPLICA_TO_MASTER[$REPLICA_PORT]}

    if [ -z &quot;$MASTER_PORT&quot; ]; then
      echo &quot;No master port mapping found for replica port $REPLICA_PORT&quot; &gt;&gt; /home/ec2-user/redis_install.log
      continue
    fi

    # 마스터 IP 찾기
    MASTER_IP=&quot;&quot;
    for master_node in $(echo &quot;$MASTER_INFO&quot; | jq -c &#39;.[]&#39;); do
      IP=$(echo $master_node | jq -r &#39;.[0]&#39;)
      PORT=$(echo $master_node | jq -r &#39;.[1]&#39;)
      if [ &quot;$PORT&quot; = &quot;$MASTER_PORT&quot; ]; then
        MASTER_IP=$IP
        break
      fi
    done

    if [ ! -z &quot;$MASTER_IP&quot; ]; then
      echo &quot;Adding replica $REPLICA_IP:$REPLICA_PORT to master $MASTER_IP:$MASTER_PORT&quot; &gt;&gt; /home/ec2-user/redis_install.log
      redis-cli --cluster add-node $REPLICA_IP:$REPLICA_PORT $MASTER_IP:$MASTER_PORT --cluster-slave -a $REDIS_PASSWORD &gt;&gt; /home/ec2-user/redis_install.log 2&gt;&amp;1
      sleep 5
    else
      echo &quot;Could not find master IP for port $MASTER_PORT&quot; &gt;&gt; /home/ec2-user/redis_install.log
    fi
  done

  # 클러스터 상태 확인
  echo &quot;Checking cluster status...&quot; &gt;&gt; /home/ec2-user/redis_install.log
  FIRST_MASTER=$(echo &quot;$MASTER_INFO&quot; | jq -c &#39;.[0]&#39;)
  FIRST_MASTER_IP=$(echo $FIRST_MASTER | jq -r &#39;.[0]&#39;)
  FIRST_MASTER_PORT=$(echo $FIRST_MASTER | jq -r &#39;.[1]&#39;)
  redis-cli -h $FIRST_MASTER_IP -p $FIRST_MASTER_PORT -a $REDIS_PASSWORD cluster info &gt;&gt; /home/ec2-user/redis_install.log 2&gt;&amp;1
  redis-cli -h $FIRST_MASTER_IP -p $FIRST_MASTER_PORT -a $REDIS_PASSWORD cluster nodes &gt;&gt; /home/ec2-user/redis_install.log 2&gt;&amp;1

  echo &quot;Cluster configuration completed!&quot; &gt;&gt; /home/ec2-user/redis_install.log
else
  echo &quot;This is not the first master node, skipping cluster configuration.&quot; &gt;&gt; /home/ec2-user/redis_install.log
fi

echo &quot;Redis 설치 및 구성 완료: $(date)&quot; &gt;&gt; /home/ec2-user/redis_install.log
</code></pre>
<br>

<hr>
<h3 id="ec2-인스턴스-생성-ktc-spring-boot">EC2 인스턴스 생성 (ktc, spring boot)</h3>
<br>

<p>springboot, pinpoint-agent, node_exporter ec2 인스턴스를 띄우는 단순 코드이지만, 
특이사항으로는 <code>depends_on</code> 에 redis 가 존재한다.</p>
<p>이 의미는 <code>terraform apply -auto-approve -target=&quot;aws_instance.ktc&quot;</code> 와 같이 ktc ec2 인스턴스만 띄우고 싶다는 명령어를 수행할 때, 
<code>aws_instance.ktc</code> 인스턴스를 먼저 띄우고 ktc 인스턴스를 띄우게 된다.</p>
<p>또한 redis 에 의존성이 걸려있으므로 redis 인스턴스들이 가진 private ip 들을 가져와서 ktc 를 설치하는 .sh.tpl 파일에 적용할 수 있게 된다.</p>
<pre><code class="language-terraform"># 생성할 ktc EC2 인스턴스의 개수를 지정하는 변수
variable &quot;ktc_instance_count&quot; {
  description = &quot;Number of EC2 instances to create&quot;
  type        = number
  default     = 2
}

# s3 에서 ktc 디렉토리를 가져와서 /bin/start.sh 를 수행하는 리소스
resource &quot;aws_instance&quot; &quot;ktc&quot; {
  count                       = var.ktc_instance_count  # 인스턴스 개수만큼 반복 생성
  ami                         = &quot;ami-0a463f27534bdf246&quot; # 사용할 AMI ID
  instance_type               = &quot;t3.medium&quot;              # 인스턴스 타입 (메모리 키운 인스턴스 타입)
  # instance_type               = &quot;t2.medium&quot;             
  key_name                    = &quot;your-ssh-key-name&quot;        # SSH 키페어 이름
  subnet_id                   = aws_subnet.public.id # 생성된 서브넷 사용
  vpc_security_group_ids      = [aws_security_group.main_sg.id] # 생성된 보안 그룹 사용
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name    # S3 읽기권한 허용
  depends_on = [
    aws_iam_role_policy_attachment.s3_readonly_attach, 
    aws_iam_role_policy_attachment.ec2_describe_attach,
    aws_instance.redis,         # Added dependency on redis instances
  ]

  tags = {
    Name = &quot;KTC-Server-${count.index + 1}&quot;
    Role = &quot;ktc&quot;
  }

  root_block_device {
    delete_on_termination = true
    volume_size           = 8
    volume_type           = &quot;gp3&quot;
  }

  monitoring        = false     # 상세 모니터링 비활성화 (기본 5분 단위)
  ebs_optimized     = false     # EBS 최적화 비활성화

  metadata_options {
    http_tokens                  = &quot;required&quot;
    http_put_response_hop_limit  = 2
    http_endpoint                = &quot;enabled&quot;
    http_protocol_ipv6           = &quot;disabled&quot;
    instance_metadata_tags       = &quot;disabled&quot;
  }

  # Use templatefile for user_data
  user_data = templatefile(&quot;${path.module}/scripts/ktc_setup.sh.tpl&quot;, {
    redis_ports       = var.redis_ports
    redis_private_ips = aws_instance.redis.*.private_ip
  })
}</code></pre>
<br>

<p><strong>ktc_setup.sh.tpl</strong></p>
<p>여기서는 pinpoint 에 데이터를 보내는 pinpoint-agent 뿐 아니라 redis instance 들의 private ip 를 /etc/hosts 에 등록해 자동화하는 과정이 들어있다.</p>
<p>추가로 node_exporter 설치 과정도 들어있지만, 
역시 자세한 설치 설명은 생략한다.</p>
<pre><code class="language-bash">#!/bin/bash
# Set timezone to KST
sudo timedatectl set-timezone Asia/Seoul

# Add Redis hosts entries
echo &quot;Adding Redis entries to /etc/hosts&quot;
%{ for i, port in redis_ports ~}
echo &quot;${redis_private_ips[i]} redis.${port}.com&quot; | sudo tee -a /etc/hosts
%{ endfor ~}
echo &quot;Finished adding Redis entries to /etc/hosts&quot;

# 모든 명령을 ec2-user 권한으로 실행
runuser -l ec2-user -c &#39;
# S3 ktc-load-test-kona 버킷의 ktc/ 디렉토리 전체 복사
aws s3 cp --recursive s3://your-bucket-name/ktc/ $HOME/
# jdk 디렉토리 실행권한 부여
chmod -R +x $HOME/jdk-21.0.5/bin
# bin 디렉토리 실행권한 부여
chmod 755 $HOME/bin/*
# 디렉토리 없으면 생성
mkdir -p $HOME/log
mkdir -p $HOME/gclogs/backup
# 심볼릭 설정 (jdk-21.0.5 -&gt; jdk)
# Check if symlink exists before creating
if [ ! -L $HOME/jdk ]; then
  ln -s $HOME/jdk-21.0.5 $HOME/jdk
fi

# Pinpoint Agent 설치
mkdir -p $HOME/pinpoint-agent
aws s3 cp s3://your-bucket-name/pinpoint/pinpoint-agent-3.0.0.tar.gz $HOME/pinpoint-agent/
tar -zxvf $HOME/pinpoint-agent/pinpoint-agent-3.0.0.tar.gz -C $HOME/pinpoint-agent

# Pinpoint Agent config 파일 수정
echo &quot;Configuring Pinpoint Agent...&quot;
sed -i &quot;s/^profiler.transport.grpc.collector.ip=.*/profiler.transport.grpc.collector.ip=${pinpoint_private_ip}/&quot; $HOME/pinpoint-agent/pinpoint-agent-3.0.0/pinpoint-root.config
sed -i &quot;s/^profiler.sampling.counting.sampling-rate=.*/profiler.sampling.counting.sampling-rate=1/&quot; $HOME/pinpoint-agent/pinpoint-agent-3.0.0/pinpoint-root.config
# 로그로 인한 과부하 방지.. 로그레벨 전부 INFO 로 변경
sed -i &#39;s/DEBUG/INFO/g&#39; $HOME/pinpoint-agent/pinpoint-agent-3.0.0/log4j2-agent.xml

# start.sh 실행 및 로그 저장
echo &quot;Starting application...&quot;
$HOME/bin/start.sh &gt; $HOME/bin/start.log 2&gt;&amp;1

# node_exporter 설치 (OS 모니터링)
aws s3 cp s3://your-bucket-name/monitoring/node_exporter-1.9.1.linux-amd64.tar.gz $HOME/

# node_exporter-1.9.1 에 압축 해제
tar -zxvf $HOME/node_exporter-1.9.1.linux-amd64.tar.gz
mv node_exporter-1.9.1.linux-amd64 $HOME/node_exporter-1.9.1

# node_exporter.service 생성
cat &lt;&lt; &quot;EOF&quot; | sudo tee /etc/systemd/system/node_exporter.service
[Unit]
Description=Node Exporter
Wants=network-online.target
After=network-online.target

[Service]
User=root
Group=root
Type=simple
ExecStart=/home/ec2-user/node_exporter-1.9.1/node_exporter

[Install]
WantedBy=multi-user.target
EOF

  echo &quot;create node_exporter start.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/node_exporter-1.9.1/start.sh
#!/bin/bash
sudo systemctl start node_exporter
EOF

  echo &quot;create node_exporter stop.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/node_exporter-1.9.1/stop.sh
#!/bin/bash
sudo systemctl stop node_exporter
EOF

  echo &quot;create node_exporter status.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/node_exporter-1.9.1/status.sh
#!/bin/bash
sudo systemctl status node_exporter
EOF

echo &quot;Setting node_exporter permissions...&quot;
chmod +x $HOME/node_exporter-1.9.1/start.sh
chmod +x $HOME/node_exporter-1.9.1/stop.sh
chmod +x $HOME/node_exporter-1.9.1/status.sh

echo &quot;start node_exporter...&quot;
sudo systemctl daemon-reload
sudo systemctl enable node_exporter
sudo systemctl start node_exporter

&#39;</code></pre>
<br>


<hr>
<h3 id="ec2-인스턴스-생성-nginx">EC2 인스턴스 생성 (nginx)</h3>
<br>

<p>nginx 는 여러 개의 ktc (springboot) 인스턴스들의 IP 들을 하나로 묶어 <code>Reverse Proxy</code> 하기 위한 용도로 띄웠다.</p>
<p>AWS ELB 를 사용해도 되지만, 사내에서 사용하는 형태가 아니므로 Nginx 로 구현한다.</p>
<p>당연하게도 ktc 인스턴스들의 private IP 들을 전부 가져와서 Nginx 설정 파일에 집어넣어야 하는 과정이 들어간다.</p>
<pre><code class="language-terraform">resource &quot;aws_instance&quot; &quot;nginx&quot; {
  ami                         = &quot;ami-0a463f27534bdf246&quot; # 사용할 AMI ID
  instance_type               = &quot;t3.medium&quot;              # 인스턴스 타입 (메모리 키운 인스턴스 타입)
  key_name                    = &quot;your-ssh-key-name&quot;        # SSH 키페어 이름
  subnet_id                   = aws_subnet.public.id # 생성된 서브넷 사용
  vpc_security_group_ids      = [aws_security_group.main_sg.id] # 생성된 보안 그룹 사용
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name    # S3 읽기권한 허용
  depends_on = [
    aws_iam_role_policy_attachment.s3_readonly_attach, 
    aws_iam_role_policy_attachment.ec2_describe_attach,
    aws_instance.redis,         # Added dependency on redis instances
    aws_instance.ktc            # Added dependency on ktc instance
  ]

  tags = {
    Name = &quot;Nginx-Server&quot;
    Role = &quot;nginx&quot;
  }

  root_block_device {
    delete_on_termination = true
    volume_size           = 8
    volume_type           = &quot;gp3&quot;
  }

  monitoring        = false     # 상세 모니터링 비활성화 (기본 5분 단위)
  ebs_optimized     = false     # EBS 최적화 비활성화

  metadata_options {
    http_tokens                  = &quot;required&quot;
    http_put_response_hop_limit  = 2
    http_endpoint                = &quot;enabled&quot;
    http_protocol_ipv6           = &quot;disabled&quot;
    instance_metadata_tags       = &quot;disabled&quot;
  }

  user_data = templatefile(&quot;${path.module}/scripts/nginx_setup.sh.tpl&quot;, {
    ktc_private_ips = aws_instance.ktc.*.private_ip
  })
}</code></pre>
<br>

<p><strong>nginx_setup.sh.tpl</strong></p>
<p>nginx 는 본인의 private IP 로 호출되는 것을 특정 IP 로 proxy 하는 구성이기 때문에,
.sh.tpl 이 수행되는 nginx 의 private IP 와 ktc instance 들의 private IP 들이 필요하다. </p>
<p>추가로 SSE 방식은 sticky 한 세션을 유지해야 하므로 (한 번 연결된 것은 계속 동일한 인스턴스로 라우팅 되어야 함) 동일한 IP 에서 호출된 것은 동일한 인스턴스로 유지되게 하는 ip_hash 방식을 사용했다.
Nginx 자체적으로 제공하는 stikcy 기능을 활용하려면 상용 버전을 사용해야 하므로, 여기서는 생략한다.</p>
<p>그 과정은 아래 포함되어있고, 역시 설치 과정까지 자세히 설명하지는 않는다.</p>
<pre><code class="language-bash">#!/bin/bash
# Set timezone to KST
sudo timedatectl set-timezone Asia/Seoul

# 모든 명령을 ec2-user 권한으로 실행
TOKEN=$(curl -s -X PUT &quot;http://169.254.169.254/latest/api/token&quot; -H &quot;X-aws-ec2-metadata-token-ttl-seconds: 21600&quot;)
MY_IP=$(curl -s -H &quot;X-aws-ec2-metadata-token: $TOKEN&quot; http://169.254.169.254/latest/meta-data/local-ipv4)

sudo dnf update -y # 시스템 패키지 목록 업데이트 (선택 사항이지만 권장)
sudo dnf install nginx -y # Nginx 설치

sudo systemctl start nginx # Nginx 서비스 시작
sudo systemctl enable nginx # 시스템 부팅 시 Nginx 자동 실행 설정

# Create KTC Nginx configuration file
cat &lt;&lt; EOF | sudo tee /etc/nginx/conf.d/ktc.conf
# 일반적인 endpoint (예: /, /api 등)를 위한 백엔드 그룹
# 기본 round-robin 방식 또는 다른 방식 (least_conn 등) 사용
upstream ktc_common {
%{ for ip in ktc_private_ips ~}
    server ${ip}:12345;
%{ endfor ~}
    # ...
}

# SSE 엔드포인트 (예: /sse-stream)를 위한 백엔드 그룹
# ip_hash 방식 적용하여 세션 유지 시도
upstream ktc_ip_hash {
    ip_hash; # &lt;-- 이 upstream 그룹에 ip_hash 적용
%{ for ip in ktc_private_ips ~}
    server ${ip}:12345;
%{ endfor ~}
    # ...
}

server {
    listen 12345; # Nginx가 클라이언트 요청을 받을 포트
    server_name $MY_IP spring.ktc.com; # 서버 이름 또는 IP 주소

    # 일반 HTTP 요청 처리
    location / {
        proxy_pass http://ktc_common; # 정의한 upstream 그룹으로 요청 전달
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        # 다른 필요한 일반 proxy 설정들...
    }

    # SSE 엔드포인트 설정 (예시: /sse-stream 경로)
    location /sse-stream { # 실제 SSE 엔드포인트 경로로 변경
        proxy_pass http://ktc_ip_hash;

        # SSE를 위해 추가 설정 
        proxy_buffering off; # &lt;-- 매우 중요! SSE 스트리밍을 위해 버퍼링 비활성화
        proxy_cache off;     # &lt;-- 캐싱 비활성화 (스트리밍 데이터에 불필요)
        proxy_set_header Connection &quot;&quot;;

        # SSE 연결 유지를 위한 타임아웃 설정 (기본값보다 길게)
        proxy_read_timeout 300s; # 백엔드로부터 응답 읽기 타임아웃
        proxy_send_timeout 300s; # 백엔드로 요청 보내기 타임아웃

        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        # 다른 필요한 일반 proxy 설정들...
    }

    # SSL/TLS 설정 (HTTPS 사용 시 주석 해제 및 인증서 경로 설정)
}
EOF


sudo nginx -t # 문법오류 검증
sudo systemctl reload nginx  # 설정 리로드 (재기동 X)
</code></pre>
<br>


<hr>
<h3 id="ec2-인스턴스-생성-monitoring">EC2 인스턴스 생성 (monitoring)</h3>
<br>

<p>monitoring 인스턴스는 prometheus (pull 방식 시계열DB), Grafana (데이터 시각화), InfluxDB(push 방식 시계열 DB), redis_exporter 를 설치하기 위한 인스턴스이다.</p>
<p>이를 위해 redis/ktc 인스턴스들의 private 를 .sh.tpl 에 변수로 넘긴다.
모니터링을 위한 데이터가 많이 적재될 예정이므로 instance_type 은 적절히 큰 값으로 수행하자.</p>
<pre><code class="language-terraform">resource &quot;aws_instance&quot; &quot;monitoring&quot; {
  ami                         = &quot;ami-0a463f27534bdf246&quot; # 사용할 AMI ID
  instance_type               = &quot;c6i.xlarge&quot;              # 인스턴스 타입 (메모리 키운 인스턴스 타입)
  key_name                    = &quot;your-ssh-key-name&quot;        # SSH 키페어 이름
  subnet_id                   = aws_subnet.public.id # 생성된 서브넷 사용
  vpc_security_group_ids      = [aws_security_group.main_sg.id] # 생성된 보안 그룹 사용
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name    # S3 읽기권한 허용
  depends_on = [
    aws_iam_role_policy_attachment.s3_readonly_attach, 
    aws_iam_role_policy_attachment.ec2_describe_attach,
    aws_instance.redis,         # Added dependency on redis instances
    aws_instance.ktc            # Added dependency on ktc instance
  ]

  tags = {
    Name = &quot;Monitoring&quot;
    Role = &quot;monitoring&quot;
  }

  root_block_device {
    delete_on_termination = true           # 인스턴스 종료 시 EBS 볼륨 삭제
    volume_size = 100                        # 루트 볼륨 크기(GB)
    volume_type = &quot;gp3&quot;                   # 루트 볼륨 타입
  }

  monitoring        = false     # 상세 모니터링 비활성화 (기본 5분 단위)
  ebs_optimized     = false     # EBS 최적화 비활성화

  metadata_options {
    http_tokens                  = &quot;required&quot;   # IMDSv2 필수
    http_put_response_hop_limit  = 2            # 메타데이터 응답 홉 제한
    http_endpoint                = &quot;enabled&quot;    # 인스턴스 메타데이터 엔드포인트 활성화
    http_protocol_ipv6           = &quot;disabled&quot;   # IPv6 메타데이터 비활성화
  }

  user_data = templatefile(&quot;${path.module}/scripts/monitoring_setup.sh.tpl&quot;, {
    redis_ports       = var.redis_ports
    redis_private_ips = aws_instance.redis.*.private_ip
    ktc_private_ip = aws_instance.ktc[0].private_ip
  })
}</code></pre>
<br>

<p><strong>monitoring_setup.sh.tpl</strong></p>
<p>Grafana / Prometheus / InfluxDB / redis_exporter 들을 설치하고, 
플러그인 설치 및 설정 적용, DB schema 생성, shell script 생성 및 수행까지의 과정이 포함되어 다소 내용이 길다.</p>
<p>역시 자세한 설치 과정은 생략한다.</p>
<pre><code class="language-bash">#!/bin/bash
# Set timezone to KST
sudo timedatectl set-timezone Asia/Seoul

# Add KTC host entry
echo &quot;Adding KTC entry to /etc/hosts&quot;
echo &quot;${ktc_private_ip} spring.ktc.com&quot; | sudo tee -a /etc/hosts
echo &quot;Finished adding KTC entry to /etc/hosts&quot;

# Add Redis hosts entries
echo &quot;Adding Redis entries to /etc/hosts&quot;
%{ for i, port in redis_ports ~}
echo &quot;${redis_private_ips[i]} redis.${port}.com&quot; | sudo tee -a /etc/hosts
%{ endfor ~}
echo &quot;Finished adding Redis entries to /etc/hosts&quot;

# 모든 명령을 ec2-user 권한으로 실행
runuser -l ec2-user -c &#39;
  echo &quot;Downloading and installing redis_exporter, prometheus, grafana, influxdb..&quot;

  aws s3 cp s3://your-bucket-name/monitoring/redis_exporter-v1.70.0.linux-amd64.tar.gz $HOME/redis_exporter-v1.70.0.linux-amd64.tar.gz
  mkdir -p $HOME/redis_exporter-v1.70.0 &amp;&amp; tar -xvf $HOME/redis_exporter-v1.70.0.linux-amd64.tar.gz -C $HOME/redis_exporter-v1.70.0 --strip-components=1

  echo &quot;setting redis_exporter...&quot;

  # /etc/systemd/system/redis_exporter.service 에 아래 내용 삽입
  cat &lt;&lt; &quot;EOF&quot; | sudo tee /etc/systemd/system/redis_exporter.service
[Unit]
Description=Redis Exporter
Wants=network-online.target
After=network-online.target

[Service]
User=ec2-user
Group=ec2-user
Type=simple
ExecStart=/home/ec2-user/redis_exporter-v1.70.0/redis_exporter \
    -web.listen-address &quot;:9121&quot; \
    -redis.addr &quot;redis.7000.com:7000&quot; --is-cluster\
    -redis.password &quot;123456&quot;

[Install]
WantedBy=multi-user.target

EOF

  echo &quot;create redis_exporter start.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/redis_exporter-v1.70.0/start.sh
#!/bin/bash
sudo systemctl start redis_exporter
EOF

  echo &quot;create redis_exporter stop.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/redis_exporter-v1.70.0/stop.sh
#!/bin/bash
sudo systemctl stop redis_exporter
EOF

  echo &quot;create redis_exporter status.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/redis_exporter-v1.70.0/status.sh
#!/bin/bash
sudo systemctl status redis_exporter
EOF

  echo &quot;Setting redis_exporter permissions...&quot;
  chmod +x $HOME/redis_exporter-v1.70.0/start.sh
  chmod +x $HOME/redis_exporter-v1.70.0/stop.sh
  chmod +x $HOME/redis_exporter-v1.70.0/status.sh

  echo &quot;Starting redis_exporter...&quot;
  sudo systemctl daemon-reload
  sudo systemctl enable redis_exporter
  sudo systemctl start redis_exporter



  echo &quot;install prometheus...&quot;

  aws s3 cp s3://your-bucket-name/monitoring/prometheus-3.3.0.linux-amd64.tar.gz $HOME/prometheus-3.3.0.linux-amd64.tar.gz
  mkdir -p $HOME/prometheus-3.3.0 &amp;&amp; tar -xvf $HOME/prometheus-3.3.0.linux-amd64.tar.gz -C $HOME/prometheus-3.3.0 --strip-components=1

  echo &quot;Creating prometheus.yml...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/prometheus-3.3.0/prometheus.yml
global:
  scrape_interval: 5s
  external_labels:
    monitor: &quot;monitor&quot;
scrape_configs:
  - job_name: &quot;ktc&quot;
    metrics_path: /ktc/actuator/prometheus
    scrape_interval: 5s
    static_configs:
      - targets: [&quot;spring.ktc.com:12346&quot;]
  - job_name: &quot;node_exporter&quot;
    static_configs:
      - targets: [&quot;spring.ktc.com:9100&quot;]
  - job_name: &quot;redis_exporter_cluster_nodes&quot;
    http_sd_configs:
      - url: http://localhost:9121/discover-cluster-nodes
        refresh_interval: 10m
    metrics_path: /scrape
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: localhost:9121
  - job_name: &quot;redis_exporter&quot;
    static_configs:
      - targets:
        - localhost:9121

EOF

  mkdir -p $HOME/prometheus-3.3.0/log

  echo &quot;Creating start.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/prometheus-3.3.0/start.sh
#!/bin/bash
LOG_PREFIX=&quot;$HOME/prometheus-3.3.0/log/prometheus.log&quot;
mkdir -p &quot;$(dirname &quot;$LOG_PREFIX&quot;)&quot;
$HOME/prometheus-3.3.0/prometheus \
    --config.file=$HOME/prometheus-3.3.0/prometheus.yml \
    --storage.tsdb.path=$HOME/prometheus-3.3.0/data \
    &gt; &gt;(split -b 100M -d - &quot;$LOG_PREFIX&quot;) \
    2&gt;&amp;1 &amp;
EOF

  echo &quot;Creating stop.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/prometheus-3.3.0/stop.sh
#!/bin/bash
kill -9 $(ps aux | grep &quot;prometheus&quot; | grep -v grep | awk &quot;{print $2}&quot;)
EOF

  echo &quot;Setting prometheus permissions...&quot;
  chmod +x $HOME/prometheus-3.3.0/start.sh
  chmod +x $HOME/prometheus-3.3.0/stop.sh

  echo &quot;Starting prometheus...&quot;
  $HOME/prometheus-3.3.0/start.sh

  echo &quot;waiting for prometheus to start...&quot;
  sleep 10


  echo &quot;install Grafana...&quot;
  aws s3 cp s3://your-bucket-name/monitoring/grafana-enterprise-11.6.1-1.x86_64.rpm $HOME/grafana-enterprise-11.6.1-1.x86_64.rpm

  # $HOME/grafana-enterprise-11.6.1-1 에 설치
  mkdir -p $HOME/grafana-enterprise-11.6.1-1
  sudo rpm -Uvh $HOME/grafana-enterprise-11.6.1-1.x86_64.rpm

  echo &quot;create grafana start.sh...&quot;
  sudo systemctl daemon-reload
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/grafana-enterprise-11.6.1-1/start.sh
#!/bin/bash
sudo systemctl start grafana-server
EOF

  echo &quot;create grafana stop.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/grafana-enterprise-11.6.1-1/stop.sh
#!/bin/bash
sudo systemctl stop grafana-server
EOF

  echo &quot;create grafana status.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/grafana-enterprise-11.6.1-1/status.sh
#!/bin/bash
sudo systemctl status grafana-server
EOF

  sudo systemctl daemon-reload
  sudo systemctl enable grafana-server

  echo &quot;Setting Grafana permissions...&quot;
  chmod +x $HOME/grafana-enterprise-11.6.1-1/start.sh
  chmod +x $HOME/grafana-enterprise-11.6.1-1/stop.sh
  chmod +x $HOME/grafana-enterprise-11.6.1-1/status.sh

  echo &quot;Starting Grafana...&quot;
  $HOME/grafana-enterprise-11.6.1-1/start.sh

  echo &quot;waiting for grafana to start...&quot;
  sleep 10

  echo &quot;Setting Grafana log symlink...&quot;
  mkdir -p $HOME/grafana-enterprise-11.6.1-1/log
  sudo ln -s /var/log/grafana $HOME/grafana-enterprise-11.6.1-1/log

  echo &quot;Setting Grafana provisioning...&quot;
  sudo ln -s /etc/grafana/provisioning/ $HOME/grafana-enterprise-11.6.1-1/provisioning

  echo &quot;add Grafana plugins..&quot;
  sudo ln -s /var/lib/grafana/plugins $HOME/grafana-enterprise-11.6.1-1/plugins

  sudo aws s3 cp s3://your-bucket-name/monitoring/redis-app-2.2.1.zip $HOME/grafana-enterprise-11.6.1-1/plugins/
  sudo unzip $HOME/grafana-enterprise-11.6.1-1/plugins/redis-app-2.2.1.zip -d $HOME/grafana-enterprise-11.6.1-1/plugins/
  sudo aws s3 cp s3://your-bucket-name/monitoring/redis-datasource-2.2.0.zip $HOME/grafana-enterprise-11.6.1-1/plugins/
  sudo unzip $HOME/grafana-enterprise-11.6.1-1/plugins/redis-datasource-2.2.0.zip -d $HOME/grafana-enterprise-11.6.1-1/plugins/

  echo &quot;add prometheus datasource localhost:9090&quot;
  cat &lt;&lt; &quot;EOF&quot; | sudo tee /etc/grafana/provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
  - name: prometheus
    type: prometheus
    url: http://localhost:9090
    isDefault: true
    access: proxy
    readOnly: false
    orgId: 1
EOF

  echo &quot;restart Grafana..&quot;
  $HOME/grafana-enterprise-11.6.1-1/stop.sh
  echo &quot;waiting for grafana to stop...&quot;
  sleep 10

  $HOME/grafana-enterprise-11.6.1-1/start.sh
  echo &quot;waiting for grafana to start...&quot;
  sleep 10


  echo &quot;install InfluxDB...&quot;
  aws s3 cp s3://your-bucket-name/monitoring/influxdb-1.11.8.x86_64.rpm $HOME/influxdb-1.11.8.x86_64.rpm
  mkdir -p $HOME/influxdb-1.11.8.x86_64
  sudo rpm -Uvh $HOME/influxdb-1.11.8.x86_64.rpm

  echo &quot;create influxdb start.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/influxdb-1.11.8.x86_64/start.sh
#!/bin/bash
sudo systemctl start influxdb
EOF

  echo &quot;create influxdb stop.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/influxdb-1.11.8.x86_64/stop.sh
#!/bin/bash
sudo systemctl stop influxdb
EOF

  echo &quot;create influxdb status.sh...&quot;
  cat &lt;&lt; &quot;EOF&quot; &gt; $HOME/influxdb-1.11.8.x86_64/status.sh
#!/bin/bash
sudo systemctl status influxdb
EOF

  echo &quot;Setting InfluxDB permissions...&quot;
  chmod +x $HOME/influxdb-1.11.8.x86_64/start.sh
  chmod +x $HOME/influxdb-1.11.8.x86_64/stop.sh
  chmod +x $HOME/influxdb-1.11.8.x86_64/status.sh

  echo &quot;Starting InfluxDB...&quot;
  $HOME/influxdb-1.11.8.x86_64/start.sh

  echo &quot;waiting for influxdb to start...&quot;
  sleep 10

  echo &quot;Creating InfluxDB databases...&quot;
  influx -execute &quot;CREATE DATABASE metrics&quot;

&#39;</code></pre>
<br>

<hr>
<h3 id="ec2-인스턴스-생성-k6">EC2 인스턴스 생성 (k6)</h3>
<br>

<p>K6 는 부하테스트를 수행하는 인스턴스이므로, 여러개의 인스턴스를 띄울 필요가 있다.
하나의 클라이언트(서버) 에서는 최대 65535 개의 소켓을 생성할 수 있으므로, 인스턴스는 많을 수록 좋다.</p>
<p>참고로, K6 는 JMeter 에 비해 매우 경량화된 Goroutine 기반이라 동시 유저 (VUs) 수를 JMeter 보다 이론 상 10배 이상 가져갈 수 있다. 
이는 그만큼 더 부하를 많이 줄 수 있다는 의미가 된다.
물론 k6 의 공식 문서 상에서는 하나의 인스턴스 당 VUs 를 30,000 정도까지 처리할 수 있다고 명시되어 있기는 한데,
이 말인 즉슨 JMeter 는 가상 유저 수 (VUs)를 3000명을 넘기기 힘들다는 방증이 되기도 한다.</p>
<p>아래 K6 인스턴스는 Nginx 에 요청을 보내야 하므로 Nginx 의 private IP 와,
부하테스트 결과를 Monitoring (InfluxDB) 에 저장해야 하므로 monitoring 인스턴스의 IP 를 .sh.tpl 에 변수로 넘긴다.</p>
<pre><code class="language-terraform">variable &quot;k6_instance_count&quot; {
  description = &quot;Number of EC2 instances to create&quot;
  type        = number
  default     = 2
}

resource &quot;aws_instance&quot; &quot;k6&quot; {
  count                       = var.k6_instance_count  # 인스턴스 개수만큼 반복 생성
  ami                         = &quot;ami-0a463f27534bdf246&quot; # 사용할 AMI ID
  instance_type               = &quot;t3.medium&quot;              # 인스턴스 타입
  key_name                    = &quot;your-ssh-key-name&quot;        # SSH 키페어 이름
  subnet_id                   = aws_subnet.public.id # 생성된 서브넷 사용
  vpc_security_group_ids      = [aws_security_group.main_sg.id] # 생성된 보안 그룹 사용
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name    # S3 읽기권한 허용
  depends_on = [
    aws_iam_role_policy_attachment.s3_readonly_attach, 
    aws_iam_role_policy_attachment.ec2_describe_attach,
    aws_iam_role_policy_attachment.ssm_core_attach, 
    aws_instance.redis, 
    aws_instance.ktc,
    aws_instance.monitoring,
    aws_instance.nginx
  ]

  tags = {
    # 인스턴스 이름에 순번을 붙임 (예: k6-1, k6-2 ...)
    Name = &quot;k6-${count.index + 1}&quot;
    Role = &quot;k6&quot;
  }

  root_block_device {
    delete_on_termination = true           # 인스턴스 종료 시 EBS 볼륨 삭제
    volume_size = 8                        # 루트 볼륨 크기(GB)
    volume_type = &quot;gp3&quot;                   # 루트 볼륨 타입
  }

  monitoring        = false     # 상세 모니터링 비활성화 (기본 5분 단위)
  ebs_optimized     = false     # EBS 최적화 비활성화

  metadata_options {
    http_tokens                  = &quot;required&quot;   # IMDSv2 필수
    http_put_response_hop_limit  = 2            # 메타데이터 응답 홉 제한
    http_endpoint                = &quot;enabled&quot;    # 인스턴스 메타데이터 엔드포인트 활성화
    http_protocol_ipv6           = &quot;disabled&quot;   # IPv6 메타데이터 비활성화
    instance_metadata_tags       = &quot;disabled&quot;   # 인스턴스 메타데이터 태그 비활성화
  }

  # Use templatefile for user_data
  user_data = templatefile(&quot;${path.module}/scripts/k6_setup.sh.tpl&quot;, {
    nginx_private_ip = aws_instance.nginx.private_ip,
    monitoring_private_ip = aws_instance.monitoring.private_ip,
    instance_name = &quot;k6-${count.index + 1}&quot;
  })
}</code></pre>
<br>

<p><strong>k6_setup.sh.tpl</strong></p>
<p>S3 에 저장된 k6 수행 스크립트를 다운받고, 
/etc/hosts 에 nginx 의 private IP 를 등록해 바로 부하테스트를 수행할 수 있게 한다.
monitoring 의 private IP 는 나중에 SSM 을 통해 어떻게 부하테스트 결과 데이터를 저장하는지 따로 설명하겠다.</p>
<pre><code class="language-bash">#!/bin/bash
# Set timezone to KST
sudo timedatectl set-timezone Asia/Seoul

# Add KTC host entry
echo &quot;Adding KTC entry to /etc/hosts&quot;
echo &quot;${nginx_private_ip} spring.ktc.com&quot; | sudo tee -a /etc/hosts
echo &quot;Finished adding KTC entry to /etc/hosts&quot;

echo &quot;Adding monitoring entry to /etc/hosts&quot;
echo &quot;${monitoring_private_ip} monitoring.influxdb&quot; | sudo tee -a /etc/hosts
echo &quot;Finished adding monitoring entry to /etc/hosts&quot;

# 모든 명령을 ec2-user 권한으로 실행
runuser -l ec2-user -c &#39;
  # S3에서 k6 바이너리 다운로드 및 설치
  echo &quot;Downloading and installing k6...&quot;
  aws s3 cp s3://your-bucket-name/k6-v0.58.0-linux-amd64.tar.gz /tmp/k6.tar.gz
  cd /tmp
  tar -xzf k6.tar.gz
  sudo mv k6-v0.58.0-linux-amd64/k6 /usr/local/bin/k6
  sudo chmod +x /usr/local/bin/k6
  rm k6.tar.gz
  rm -rf k6-v0.58.0-linux-amd64
  echo &quot;k6 installation complete.&quot;

  # k6 script 복사  
  echo &quot;k6 script copy start.&quot;
  aws s3 cp --recursive s3://your-bucket-name/k6-script/ /home/ec2-user/
  echo &quot;k6 script copy complete.&quot;

  # test_name 값을 현재 인스턴스 이름으로 변경
  sed -i &quot;s/ktc-basic-test/${instance_name}/g&quot; /home/ec2-user/k6-sample.js
&#39;
</code></pre>
<br>
<br>

<hr>
<br>
<br>


<h2 id="ssm-으로-여러-개의-k6-instance-에-일괄-부하테스트-명령어-수행">SSM 으로 여러 개의 K6 Instance 에 일괄 부하테스트 명령어 수행</h2>
<br>

<p>K6 는 기본적으로 shell script 로 수행이 가능하다.
ex) <code>k6 run k6-sample.js --out influxdb=http://monitoring.influxdb:8086/metrics</code></p>
<p>AWS 에서는 인스턴스에 자동화된 명령 수행을 위해 <code>AWS Lambda</code> 혹은 <code>SSM</code> 이라는 도구를 제공한다.</p>
<p>다만 AWS Lambda 는 API 에 특화된 도구라, API 를 제공하지 않는 k6 에 적용하려면 오히려 번거로운 상황이 발생해 <code>SSM(AWS Systems Manager)</code>을 사용한다.</p>
<p>그래서 <code>SSM(AWS Systems Manager)</code> 이란?</p>
<ul>
<li>AWS CLI 기반으로, 원격으로 인스턴스에 스크립트나 명령을 실행할 수 있게 한다.</li>
<li>Shell Script 를 수행하는 형식이지만, 그렇다고 매번 SSH Key 기반으로 접속하는 과정이 필요없다.</li>
<li>띄워진 인스턴스들의 정보를 수집하고, 필터링하고, 특정 인스턴스들에게만 명령어를 수행할 수 있는 기능을 제공한다.</li>
</ul>
<p>참고로, SSM 의 명령어 수행에 따른 과금 비용은 무시해도 될 만큼 매우 작다.
하루에 수만 건 이상 요청하지 않는 이상 그냥 넘어가자.</p>
<br>

<p><strong>SSM 명령어 설명 전 가정</strong></p>
<p>위 Terraform 코드로 EC2 인스턴스들을 띄웠다고 가정한다.
우리는 여기서 K6 인스턴스들에게 명령을 수행하라고 보내야 한다.
k6 인스턴스들이 잘 띄워졌다면, <code>k6-1</code>,<code>k6-2</code>,<code>k6-3</code> ... 과 같은 Tag 가 붙어있을 것이다.</p>
<br>

<p><strong>SSM 사전 준비</strong></p>
<p>AWS CLI 에 연결된 상황이어야 하며, 아래 공식 가이드대로 수행한다.</p>
<p>가이드</p>
<ul>
<li><a href="https://docs.aws.amazon.com/ko_kr/systems-manager/latest/userguide/install-plugin-windows.html">https://docs.aws.amazon.com/ko_kr/systems-manager/latest/userguide/install-plugin-windows.html</a></li>
</ul>
<p>설치 파일 다운로드 (Windows)</p>
<ul>
<li><a href="https://s3.amazonaws.com/session-manager-downloads/plugin/latest/windows/SessionManagerPluginSetup.exe">https://s3.amazonaws.com/session-manager-downloads/plugin/latest/windows/SessionManagerPluginSetup.exe</a></li>
</ul>
<br>

<p><strong>SSM 명령어 수행 예시</strong></p>
<p>요구사항은 <code>k6-*</code> 라는 패턴의 태그명을 가진 EC2 인스턴스들에게 명령을 수행하는 것이다.
이는 <code>--filters</code> 로 필터링하고,
해당 인스턴스들에게 명령을 수행하려면 우선 InstanceId 를 가져와야 하는데, <code>--query</code> 로 이를 수행한다.
또한 region 을 지정하고 수행 결과값을 INSTANCE_IDS 라는 변수에 담는다.</p>
<p>위 과정을 수행했다면 <code>aws ssm send-command</code> 명령어로 특정 스크립트를 수행하도록 전송할 수 있다.
<code>--document-name &quot;AWS-RunShellScript&quot;</code> 라는 속성이 명시되어야 하며, 
<code>--targets &quot;Key=InstanceIds,Values=$INSTANCE_IDS&quot;</code> 와 같이 변수에 지정한 타겟들을 지정한다.
<code>--parameters</code> 에는 실행할 shell script 를 넣으면 된다.</p>
<p>참고로, powershell 에서는 줄바꿈 인식이 잘 되지 않아, 별도 스크립트를 만들거나 bash terminal 에서 수행하는 것을 추천한다.</p>
<p>k6 를 수행하는 부하테스트 스크립트 작성법과 변수 지정법은 내가 작성한 아래 링크를 참고하자.</p>
<p><a href="https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95">K6 부하테스트 스크립트 작성법</a></p>
<pre><code class="language-bash">INSTANCE_IDS=$(aws ec2 describe-instances \
  --filters &quot;Name=tag:Name,Values=k6-*&quot; &quot;Name=instance-state-name,Values=running&quot; \
  --query &quot;Reservations[*].Instances[*].InstanceId&quot; \
  --output text \
  --region ap-northeast-2 | grep . | paste -s -d &#39;,&#39;)

echo &quot;Found Instance IDs (comma-separated): $INSTANCE_IDS&quot;

aws ssm send-command \
  --document-name &quot;AWS-RunShellScript&quot; \
  --targets &quot;Key=InstanceIds,Values=$INSTANCE_IDS&quot; \
  --parameters &#39;{
    &quot;commands&quot;: [
      &quot;cd /home/ec2-user&quot;,
      &quot;STAGE1_DURATION=5s&quot;,
      &quot;STAGE1_TARGET=5000&quot;,
      &quot;STAGE2_DURATION=20s&quot;,
      &quot;STAGE2_TARGET=10000&quot;,
      &quot;STAGE3_DURATION=5s&quot;,
      &quot;STAGE3_TARGET=0&quot;,
      &quot;k6 run --env STAGE1_DURATION=$STAGE1_DURATION --env STAGE1_TARGET=$STAGE1_TARGET --env STAGE2_DURATION=$STAGE2_DURATION --env STAGE2_TARGET=$STAGE2_TARGET --env STAGE3_DURATION=$STAGE3_DURATION --env STAGE3_TARGET=$STAGE3_TARGET --out influxdb=http://monitoring.influxdb:8086/metrics k6-sample.js | tee &gt;(split -b 10M -d - k6_log_)&quot;
    ]
  }&#39; \
  --comment &quot;Run k6 load test (targeting by Instance IDs)&quot; \
  --region ap-northeast-2</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSAI (Server Side Ads Insert) 적용하기 -2 (AWS MediaLive, MediaTailor, CloudFront)]]></title>
            <link>https://velog.io/@mud_cookie/SSAI-Server-Side-Ads-Insert-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-1-AWS-MediaLive-MediaTailor-CloudFront</link>
            <guid>https://velog.io/@mud_cookie/SSAI-Server-Side-Ads-Insert-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-1-AWS-MediaLive-MediaTailor-CloudFront</guid>
            <pubDate>Tue, 04 Mar 2025 12:49:18 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@mud_cookie/SSAI-Server-Side-Ads-Insert-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-AWS-MediaTailor-%EC%9E%91%EC%84%B1%EC%A4%91">SSAI (Server Side Ads Insert) 적용하기 - 1 (AWS S3, MediaConvert, MediaPackage, MediaTailor, CloudFront)</a></p>
<p>위 포스팅에 이어서 작성한다.
사전지식이나 개념은 이전에 대부분 설명했으니, 
간단하게 MediaLive 와 RTMP, SCTE-35, CUE  가 무엇이고 구성은 어떻게 할지만 간략하게 소개하고 구현해보자.</p>
<br>
<br>


<h1 id="사전지식">사전지식</h1>
<br>

<h3 id="aws-medialive">AWS MediaLive?</h3>
<p>클라우드 기반의 방송 수준 라이브 비디오 처리 서비스이다. 
즉, 실시간으로 들어오는 비디오와 오디오 신호를 다양한 장치에서 재생할 수 있도록 변환(인코딩)하는 역할을 한다. 
전통적인 방송 장비 없이도 고품질의 라이브 스트리밍을 구축하고 운영할 수 있도록 지원하며, 안정성, 확장성, 유연성을 제공한다.</p>
<p>주요 기능:</p>
<ul>
<li>다양한 입력 소스(SDI, RTP, RTMP 등) 지원
이전 포스팅에서 HLS 가 출력형식임을 배웠다. 여기서는 스트리밍 영상을 클라우드에 전송하기 위해 <code>RTMP</code> 프로토콜을 사용할 예정이다.</li>
<li>다양한 출력 형식(HLS, DASH 등) 지원</li>
<li>고품질 비디오 인코딩 및 트랜스코딩</li>
<li>채널별 설정 및 관리</li>
<li>광고 삽입 및 워터마킹 기능</li>
</ul>
<br>



<h3 id="rtmp">RTMP?</h3>
<p><code>Real-Time Messaging Protocol</code>의 약자로, Adobe 에서 개발한 스트리밍 프로토콜이다. 
주로 라이브 스트리밍에서 비디오와 오디오 데이터를 서버로 전송하는 데 사용된다. 
낮은 지연 시간과 높은 안정성을 제공하여 실시간 방송에 적합하다.</p>
<br>



<h3 id="scte-35">SCTE-35?</h3>
<p><code>Society of Cable Telecommunications Engineers 35</code>의 약자로, 디지털 프로그램 삽입 신호를 정의하는 표준이다. 
즉, 광고 삽입, 블랙아웃, 콘텐츠 대체와 같은 이벤트를 트리거하는 데 사용되는 신호라고 할 수 있다. 
라이브 스트리밍에서 광고를 정확한 시간에 삽입하거나 특정 지역의 콘텐츠를 제한하는 데 필수적인 역할을 한다.</p>
<p>주요 기능:</p>
<ul>
<li>광고 삽입 신호 전송</li>
<li>콘텐츠 블랙아웃 신호 전송</li>
<li>콘텐츠 대체 신호 전송</li>
<li>정확한 시간 기반 이벤트 트리거</li>
</ul>
<br>



<h3 id="cue-outin-marker">CUE-OUT/IN Marker?</h3>
<br>

<p>SCTE-35 신호의 일부로, 광고 삽입 또는 콘텐츠 대체 시점을 나타내는 마커라고 할 수 있다. 
<code>CUE-OUT</code> 마커는 광고 또는 대체 콘텐츠가 시작되는 시점을, 
<code>CUE-IN</code> 마커는 광고 또는 대체 콘텐츠가 종료되는 시점을 나타낸다. 
이 마커를 통해 라이브 스트리밍 플랫폼은 정확한 시간에 광고를 삽입하거나 콘텐츠를 대체할 수 있\다.</p>
<p>역할:</p>
<ul>
<li>정확한 광고 삽입 시점 지정</li>
<li>정확한 콘텐츠 대체 시점 지정</li>
<li>매끄러운 광고 및 콘텐츠 전환 제공</li>
</ul>
<br>
<br>

<hr>
<br>
<br>



<h1 id="구성도">구성도</h1>
<br>


<p>구성도 스크린샷 및 설명...</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="obs-studio-로-테스트-스트리밍-세팅">OBS Studio 로 테스트 스트리밍 세팅</h2>
<br>

<p><a href="https://obsproject.com/">https://obsproject.com/</a> 에서 OS 에 맞는 파일을 다운받아 설치한다.</p>
<p>이후 카메라 또는 화면을 송출하도록 <code>소스 목록</code> 에 추가한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/207f0779-209d-48be-a056-33c23b7de1ad/image.png" alt=""></p>
<br>

<p><code>설정</code> 에서는 <code>방송</code>, <code>출력</code>, <code>비디오</code> 탭만 신경쓰면 된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dce85385-eb3c-4abb-8b62-2c031041b89a/image.png" alt=""></p>
<p><code>방송</code> 은 라이브 스트리밍을 어느 서버에 전송할 것인지 설정하는 탭이다.
아직 서버 설정이 되지 않았으므로, 이런 설정이 있다는 것만 인지해두자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/82994c4b-800e-464a-b9e9-98a457209bda/image.png" alt=""></p>
<p><code>출력</code> 은 라이브 스트리밍 영상의 비트레이트, 인코더 등을 설정할 수 있다.
OBS 를 시작하면 사용자의 목적, 송출할 영상에 따라 설정을 추천해주어 기본값을 지정해주지만.. 그래도 설정은 한 번 보고 넘어가보자.
비트레이트 값이 너무 크면 영상의 크기가 커져 AWS 비용이 많이 나올 수 있어 적절한 값으로 설정했고,
인코더는 OBS 에서 기본적으로 추천한 값을 사용했다.
오디오까지는 디테일하게 테스트하지 않을 것이므로 오디오 관련 설정은 건너뛰자.</p>
<br>

<p><code>비디오</code> 탭에서는 영상의 해상도 및 FPS(Frame Per Sec) 를 지정할 수 있다.
이 역시 AWS 과금이 두려워 적절하게 낮은 값으로 설정했다.</p>
<p>이제 MediaPackage, MediaLive 를 설정해보자.</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="mediapackage-채널-생성">MediaPackage 채널 생성</h2>
<br>

<p>앞선 포스팅에서 Mediapackage 는 VOD 대상이었으므로, S3 로부터 Assets 을 설정했었다.
하지만 여기선 라이브 스트리밍을 송출할 것이므로, <code>채널</code> 을 생성해보자.</p>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediapackage/home?region=ap-northeast-2#/channels">AWS MediaPackage</a> 의 좌측 탭의 라이브 v1 -&gt; 채널 -&gt; 채널 생성으로 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/93fca973-b050-4d75-818a-b4a586593e12/image.png" alt=""></p>
<p>나는 미리 테스트용으로 하나 만들어 둔 상태라 원래는 채널이 없는 상태여야 한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f2d860c7-a1a1-41db-a374-903319d40b16/image.png" alt=""></p>
<p>이후 채널의 ID 및 설명을 이것이 라이브 스트리밍 테스트용이라는 것을 쉽게 인지할 수 있게 지정해 생성해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/16974282-a164-468b-bda4-86c53635220b/image.png" alt=""></p>
<p>그러면 HLS 수집 endpoint 가 자동으로 두 개 생성된다.
여기서 Origin endpoint 를 따로 추가해야 한다.
이 Origin endpoint 역시 테스트용으로 미리 하나 만들어 둔 것으로, 원래는 채널을 생성했다고 바로 origin endpoint 가 생성되지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ef8781bd-916f-4733-a169-80345d653118/image.png" alt=""></p>
<p>이제 origin enpoint 에 대한 ID, 설명을 작성하고
패키징을 이전 포스팅과 같이 HLS 로 설정, 
세그먼트 duration 은 적절하게 설정한다. (10초 이내)</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="medialive-생성-및-mediapackage-obs-studio-연동">MediaLive 생성 및 MediaPackage, OBS Studio 연동</h2>
<br>

<p><a href="https://ap-northeast-2.console.aws.amazon.com/medialive/home?region=ap-northeast-2#/inputs">AWS MediaLive</a> 에서 <code>입력</code> 을 우선 생성해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5b569ea8-fd1f-47c1-9084-8066e902a90c/image.png" alt=""></p>
<p>이 입력탭은 obs studio 와 같은 외부에서 어떤 프로토콜로 보낼건지, 방화벽 설정은 어떻게 할 것인지 등의 설정을 하는 탭이다.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/cbe247cc-78b5-4566-b7ea-2431cc75662a/image.png" alt=""></p>
<p>입력 이름을 obs-studio 와 같은 값으로 입력하고,
입력 유형을 <code>RTMP(푸시)</code> 로 설정하자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2937087a-b61d-4881-ad50-8d303baa77f7/image.png" alt=""></p>
<p>보안그룹은 테스트를 위해서 모든 IP 에서 접근 가능하게 0.0.0.0/0 으로 설정한다.</p>
<p>입력 대상은 STANDARD_INPUT 으로 설정 후 application name 과 instance
이름을 적절하게 설정하자.</p>
<ul>
<li>여기서 설정하는 값들은 해당 input 하는 서버의 rtmp endpoint 가 된다.</li>
<li>입력 서버는 안정성을 위해 기본적으로 두 개를 제공한다.</li>
</ul>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b84ee974-8534-42f0-a369-20056532a9e8/image.png" alt=""></p>
<p>이제 MediaLive 의 <code>채널</code> 탭에 진입해 채널 생성을 해보자.
여기서 생성되는 채널은 MediaPackage 로 송출하는 역할이라고 보면 된다.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/7c560523-6606-4b2b-be9e-1dfa3add241c/image.png" alt=""></p>
<p>우선 <code>채널 및 입력 세부 정보</code> 에서 </p>
<ul>
<li>채널 이름</li>
<li>IAM 역할
여기서는 기존 역할을 사용했으나, 템플릿에서 역할 생성을 통해 추천 제공되는 IAM Role 도 사용 가능하다.</li>
<li>채널 템플릿
HTTP Live Streaming (<strong>MediaPackage</strong>) 를 선택한다.</li>
<li>채널 클래스
STANDARD 를 선택</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/7c73e56d-31e5-4dc0-8643-3cf8e587fb20/image.png" alt=""></p>
<p>이후 해상도, 최대 입력 비트레이트 값은 막대한 과금 방지를 위해 적절한 값을 설정하고, 입력 코덱은 AVC 로 정상 동작이 확인되었다.
출력 전송은 퍼블릭으로 설정하자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/d539a3ef-d77a-437b-8d77-e9fe015d8d3b/image.png" alt=""></p>
<p>이후 좌측 탭의 입력 첨부 -&gt; 입력 첨부로 진입해 방금 MediaLive 에서 만든 입력(input)과 연동한다. 
이름도 적절하게 입력하자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/30b7d947-39c6-4d83-af7f-e8b88481b830/image.png" alt=""></p>
<p>이후 출력그룹에 생성된 MediaPackage 에 대한 출력의 채널탭 설정을 해보자.
HLS 출력 사용 설정, ID 설정, 이름 등을 적절하게 설정한다.</p>
<blockquote>
<p>여기서 주의할 점은 출력 10: 에 이상한 embedded 출력이 껴있었는데, 
이걸 제거하지 않으면 채널 생성이 되지 않는다.
이거 때문에 몇 분을 날린지 모르겠다.. 아직은 AWS Media 서비스 자체가 B2C 가 아닌 B2B 서비스이다 보니 이런 자잘한 설정들이 불친절한 것들이 매우 많은 것 같다.. AWS 분발하자</p>
</blockquote>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ce1fe975-49da-47d4-b484-0b905d398b7f/image.png" alt=""></p>
<p>자, 이제는 정상적으로 idle(유휴) 상태 채널 생성이 완료되었다.
우측 상단의 <code>시작</code> 을 눌러 송출을 시작해보자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1b63630a-61e1-49d8-adea-d97a1f405092/image.png" alt=""></p>
<p>그러면 OBS Studio 에서 방송 영상 전송 대상 서버를 확인하기 위해 다시 <code>입력</code> 탭에 진입한다.</p>
<p>입력 보안 그룹의 허용 목록 규칙이 0.0.0.0/0 인지, 
입력의 유형이 RTMP_PUSH 인지 다시 한 번 확인한 후 엔드포인트를 보자.</p>
<p>형식이 <code>rtmp://ip:1935/$application_name/$application_instance</code> 와 같이 두 개가 설정되어 있다. 
이는 아까 설정했던 endpoint 값이 /$application_name/$application_instance 형태로 붙은 것으로 확인된다.</p>
<p>이 중에 하나를 복사하자. 둘 중에 아무거나 해도 상관없다.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/73cd1b6d-c089-4a5c-9e56-c7ab99910f25/image.png" alt=""></p>
<p>다시 OBS Studio 로 돌아와 설정의 <code>방송</code> 탭에 아래와 같이 입력한다.</p>
<ul>
<li>서버 : rtmp://IP주소:1935/$application_name</li>
<li>스트림 키 : $application_instance</li>
</ul>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/6622f87b-3bc5-45f9-a6b9-8aec549e548e/image.png" alt=""></p>
<p>그리고 <code>방송 시작</code> 을 하고, 
다시 <a href="https://ap-northeast-2.console.aws.amazon.com/medialive/home?region=ap-northeast-2#/channels/">AWS MediaLive Channel</a> 에서 생성한 채널로 진입해 정상적으로 방송 송출이 잘 되는지 확인해보자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/470f8c8e-b4f7-4e25-9972-61038964d8aa/image.png" alt=""></p>
<p>채널 상태가 Running 이고, 정상적으로 라이브 스트리밍이 잘 송출되는 것을 볼 수 있다.
이 화면에서는 Pipeline 1 으로 송출되고 있고, Pipeline 0 에는 OBS Studio 를 연결하지 않았으므로 error alert 이 노출된다.
물론 실제 기업 방송에서는 이중화 송출을 하겠지만 여기서는 송출이 잘 되는지만 확인해도 충분하다.</p>
<p>이제는 hls.js 에서 확인해보자.
<a href="https://ap-northeast-2.console.aws.amazon.com/mediapackage/home?region=ap-northeast-2#/channels">AWS MediaPackage</a> 의 채널에서 생성된 채널을 진입 후, </p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/d9be953e-7b1c-41cd-8c14-c65a9760032b/image.png" alt=""></p>
<p><code>미리보기</code> 를 진입하면 VOD 처럼 hls.js 로 연결된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/6266f38b-2b1e-434f-a648-ba2b0a0c98f7/image.png" alt=""></p>
<p>MediaPackage 의 도메인으로도 잘 송출되는 것을 확인할 수 있다.</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="mediatailor-cloudfront-연동">MediaTailor, CloudFront 연동</h2>
<br>

<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediatailor/home?region=ap-northeast-2#/">AWS MediaTailor</a>
에서 -&gt; 구성 생성에 진입하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/854cd65d-5ef4-4210-8d5c-c2a0dbdb5a8b/image.png" alt=""></p>
<p>이름을 적절히 지정하고, 
콘텐츠 소스는 방금 전 hls.js 의 ?src= 뒤의 https 주소를 입력하자. (파일명 제외)
광고 결정 서버는 이전 포스팅에서 사용한 값을 그대로 사용해보자.</p>
<pre><code>https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_ad_samples&amp;sz=640x480&amp;cust_params=sample_ct%3Dlinear&amp;ciu_szs=300x250%2C728x90&amp;gdfp_req=1&amp;output=vast&amp;unviewed_position_start=1&amp;env=vp&amp;impl=s&amp;correlator=</code></pre><br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/47f55c8c-728b-44ee-b0ef-e69be9f879b7/image.png" alt=""></p>
<p>추가적으로 개인화 세부 정보에서 광고를 커스텀하게 설정할 수도 있으나, 일단 동작 확인이 우선이므로 여기서는 스킵하고 구성을 생성해보자.</p>
<br>

<p>이제는 <a href="https://us-east-1.console.aws.amazon.com/cloudfront/v4/home?region=ap-northeast-2#/distributions">AWS CloudFront</a> 를 진입해 Live Streaming 용 도메인을 생성해보자.</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/762fc7c6-7ff9-4a62-b9ae-50ceffc5a10a/image.png" alt=""></p>
<p>Origin Domain : 방금 생성한 Live Streaming 용 MediaTailor 채널의 도메인
Origin Path : 없음
이름 : 자동 생성된 값을 사용하거나 인식 가능한 값을 삽입
Origin Shield : 조금 더 빠른 캐싱을 위해 서울 리전을 선택</p>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/4ebbc10f-2130-4b26-8f3c-d6950fc1c4d9/image.png" alt=""></p>
<p>캐시 정책은 CachingOptimized 보다는 MediaPackage 정책을 지정하고, 
WAF (방화벽) 은 켜놓기만 해도 몇 달러가 그냥 나가니 우선 비활성화로 두고 이 CloudFront 서비스는 잠깐만 쓰고 비활성화 해두자.</p>
<br>

<p>CloudFront 까지 연동되었으면 이제 다시 hls.js 로 CloudFront 도메인으로 요청해보자.
<code>https://hlsjs.video-dev.org/demo/?src=</code>  + 
<code>https://cloudfront도메인</code> + 
<code>MediaTailor의endpoint</code>  ex) /v1/master/abc/def/AdCampaign2  +
<code>파일명</code>  -&gt; 여기서 파일명은 MediaTailor 의 Origin 이 MediaPackage 이므로 MediaPackage 에서 접근 가능한 파일명으로 지정하면 된다.</p>
<p>최종적으로,
ex) <code>https://hlsjs.video-dev.org/demo/?src=https://qwer.cloudfront.net/v1/master/abc/def/AdCampaign2/index.m3u8</code>
와 같이 요청해보자.</p>
<p>정상적으로 잘 송출될 것이다.</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="광고-삽입-scte-35-적용">광고 삽입, SCTE-35 적용</h2>
<p>TODO..</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="hlsjs-네트워크-분석---cue-outin-동작-확인">hls.js 네트워크 분석 -&gt; CUE-OUT/IN 동작 확인</h2>
<p>TODO..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSAI (Server Side Ads Insert) 적용하기 - 1 (AWS S3, MediaConvert, MediaPackage, MediaTailor, CloudFront)]]></title>
            <link>https://velog.io/@mud_cookie/SSAI-Server-Side-Ads-Insert-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-AWS-MediaTailor-%EC%9E%91%EC%84%B1%EC%A4%91</link>
            <guid>https://velog.io/@mud_cookie/SSAI-Server-Side-Ads-Insert-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-AWS-MediaTailor-%EC%9E%91%EC%84%B1%EC%A4%91</guid>
            <pubDate>Sun, 02 Mar 2025 02:27:17 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">개요</h1>
<p><a href="https://www.youtube.com/watch?v=_CNC0l31Z_s">Youtube - 티빙의 AWS Elemental 서비스 활용기</a></p>
<p>위 영상을 보고 영감을 받아 영상 송출에 서버 사이드 영상을 삽입하는 과정을 직접 실습해보고자 한다.</p>
<p>사용되는 서비스는 아래와 같다.</p>
<ul>
<li>AWS S3 (VOD일 경우, 1번 포스팅에서 진행)</li>
<li>AWS MediaConvert (VOD일 경우, 1번 포스팅에서 진행)</li>
<li>AWS MediaLive (실시간 스트리밍일 경우, 2번 포스팅에서 진행)</li>
<li>AWS MediaPackage</li>
<li>AWS MediaTailor</li>
<li>AWS CloudFront</li>
<li>ADS (자체구축 또는 Google Ad Service, 현 실습에서는 샘플만 사용)</li>
</ul>
<br>
<br>

<hr>
<br>
<br>

<h1 id="사전지식">사전지식</h1>
<br>

<h3 id="ssai">SSAI?</h3>
<p>SSAI (Server-Side Ad Insertion)는 서버 측 광고 삽입 기술을 의미하고,
영상 스트리밍 시 서버에서 광고를 콘텐츠에 직접 삽입하여 재생하는 방식이다.</p>
<p>클라이언트 측에서 광고를 삽입하는 CSAI (Client-Side Ad Insertion)와 대비된다.</p>
<p>광고 차단에 강하며, 사용자 경험을 향상시키는 장점을 가진다.</p>
<br>

<h3 id="영상을-재생하는-방식">영상을 재생하는 방식</h3>
<p>기존의 영상 재생 방식은 클라이언트가 서버로부터 영상 콘텐츠를 다운로드하여 재생하는 방식이다.</p>
<p>SSAI 방식은 서버에서 영상 콘텐츠와 광고를 하나의 스트림으로 결합하여 클라이언트에게 제공한다.</p>
<p>클라이언트는 광고가 삽입된 통합 스트림을 끊김 없이 재생할 수 있다.</p>
<br>


<h3 id="hls">HLS?</h3>
<p>HLS (HTTP Live Streaming)는 Apple에서 개발한 HTTP 기반의 영상 스트리밍 프로토콜이다.</p>
<p>영상을 작은 세그먼트 단위로 분할하여 전송하고, 클라이언트에서 이를 순차적으로 재생하는 방식이다.</p>
<p>다양한 기기 및 플랫폼에서 널리 사용되며, 적응형 비트레이트 스트리밍을 지원한다.</p>
<br>

<h3 id="bitrate">Bitrate?</h3>
<p>비트레이트는 디지털 데이터의 전송 속도를 나타내는 단위이다.
영상 스트리밍에서는 초당 전송되는 데이터의 양 (bits per second, bps) 을 의미한다.</p>
<p>높은 비트레이트는 더 많은 데이터를 전송하므로 고화질 영상을 제공하지만, 네트워크 대역폭을 더 많이 사용한다.</p>
<p>HLS 스트리밍은 적응형 비트레이트 스트리밍을 지원해, 
네트워크 환경에 따라 자동으로 비트레이트를 조절하여 최적의 재생 환경을 제공한다.</p>
<br>

<h3 id="manifest-segment">Manifest, Segment?</h3>
<p><code>매니페스트 (Manifest)</code>:
영상 스트림에 대한 정보를 담고 있는 파일이다.
재생 가능한 세그먼트 목록, 비트레이트 정보, 광고 정보 등을 포함한다.
HLS에서는 <code>.m3u8</code> 형식을 사용한다.</p>
<p><code>세그먼트 (Segment)</code>:
영상을 작은 단위로 분할한 파일이다.
클라이언트에서 순차적으로 다운로드하여 재생하며,
HLS에서는 <code>.ts</code> (Transport Stream) 형식을 사용한다.</p>
<br>

<h3 id="m3u8-응답-예시">.m3u8 응답 예시</h3>
<p>#EXTM3U</p>
<ul>
<li>m3u8 파일의 시작을 알리는 태그</li>
</ul>
<p>#EXT-X-VERSION:4</p>
<ul>
<li>HLS 프로토콜 버전</li>
</ul>
<p>#EXT-X-MEDIA-SEQUENCE:0</p>
<ul>
<li>첫 번째 세그먼트의 순번</li>
</ul>
<p>#EXT-X-TARGETDURATION:10</p>
<ul>
<li>세그먼트의 최대 재생 시간</li>
</ul>
<p>#EXTINF:10.0, </p>
<ul>
<li>세그먼트의 재생 시간</li>
</ul>
<p>segment0.ts</p>
<ul>
<li>세그먼트 파일의 이름</li>
</ul>
<p>#EXT-X-ENDLIST</p>
<ul>
<li>재생목록의 끝, VOD (Video On Demand) 에서 사용</li>
</ul>
<pre><code>#EXTM3U
#EXT-X-VERSION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:10
#EXTINF:10.0,
segment0.ts
#EXTINF:10.0,
segment1.ts
#EXTINF:10.0,
segment2.ts
#EXT-X-ENDLIST</code></pre><br>

<h3 id="ssai-manifest-예시">SSAI Manifest 예시</h3>
<p>SSAI 방식은 서버에서 영상 콘텐츠와 광고를 결합하여 하나의 스트림으로 생성한다.
광고 삽입 시점에 광고 세그먼트를 콘텐츠 세그먼트 사이에 삽입하고, 매니페스트를 수정한다.</p>
<p>#EXTINF:5.0,:</p>
<ul>
<li>ad_segment1.ts 의 재생 시간을 5초</li>
</ul>
<p>ad_segment1.ts:</p>
<ul>
<li>광고 세그먼트 파일의 이름</li>
</ul>
<p>#EXTINF:5.0,:</p>
<ul>
<li>ad_segment2.ts 의 재생 시간을 5초</li>
</ul>
<p>ad_segment2.ts:</p>
<ul>
<li>광고 세그먼트 파일의 이름</li>
</ul>
<p>#EXTINF:10.0,:</p>
<ul>
<li>segment1.ts 의 재생 시간을 10초</li>
</ul>
<p>segment1.ts:</p>
<ul>
<li>실제 영상 데이터를 포함하는 세그먼트 파일의 이름</li>
</ul>
<pre><code>#EXTM3U
#EXT-X-VERSION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:10
#EXTINF:10.0,
segment0.ts
#EXTINF:5.0,
ad_segment1.ts
#EXTINF:5.0,
ad_segment2.ts
#EXTINF:10.0,
segment1.ts
#EXT-X-ENDLIST</code></pre><br>

<h3 id="vast">VAST?</h3>
<p>VAST (Video Ad Serving Template)는 디지털 비디오 광고를 위한 표준 <code>XML</code> 기반 템플릿이다.
광고 서버와 비디오 플레이어 간의 통신을 표준화하여 다양한 플랫폼에서 일관된 광고 재생을 가능하게 한다.
IAB (Interactive Advertising Bureau)에서 개발 및 관리하며, 온라인 비디오 광고 업계의 핵심 기술 표준이다.</p>
<p>VAST 의 주요 기능</p>
<ul>
<li>광고 정보 전달
광고 소재 (비디오 파일, 이미지 등)의 위치, 재생 시간, 추적 URL 등의 정보를 비디오 플레이어에 전달한다.
다양한 광고 유형 (선형, 비선형, 컴패니언 등)을 지원한다.</li>
<li>광고 추적
광고 노출, 클릭, 완료 등 다양한 사용자 상호작용을 추적하기 위한 동작을 제공하고, 광고 효과 측정 및 분석을 가능하게 한다.</li>
<li>광고 재생 제어
비디오 플레이어가 광고를 어떻게 재생해야 하는지에 대한 정보를 제공한다.
광고 스킵 가능 여부, 자동 재생 여부 등을 제어한다.</li>
</ul>
<p>VAST 구성 요소</p>
<ul>
<li>XML 템플릿
광고 정보를 담고 있는 XML 형식의 파일
비디오 플레이어가 해석하여 광고를 재생하는 데 사용된다.</li>
<li>Ad Element
개별 광고를 정의하는 요소
선형 광고, 비선형 광고, 컴패니언 광고 등 다양한 유형의 광고를 포함할 수 있다.</li>
<li>Linear Element
선형 광고 (비디오 콘텐츠 전후 또는 중간에 재생되는 광고)를 정의하는 요소
광고 소재, 재생 시간, 추적 URL 등을 포함한다.</li>
<li>NonLinearAds Element
비선형 광고 (비디오 콘텐츠 위에 오버레이되는 광고)를 정의하는 요소
광고 소재, 크기, 위치 등을 포함한다.</li>
<li>CompanionAds Element
컴패니언 광고 (비디오 플레이어 주변에 표시되는 광고)를 정의하는 요소
광고 소재, 크기, 위치 등을 포함한다.</li>
</ul>
<br>

<h3 id="vast-응답-예시">VAST 응답 예시</h3>
<p><code>&lt;VAST version=&quot;4.2&quot;&gt;</code>: VAST 버전 4.2를 사용함
<code>&lt;Ad id=&quot;12345&quot;&gt;</code>: 광고의 고유 ID
<code>&lt;InLine&gt;</code>: 광고 정보를 직접 포함하는 인라인 광고
<code>&lt;AdSystem&gt;</code>: 광고를 제공하는 광고 시스템
<code>&lt;AdTitle&gt;</code>: 광고 제목
<code>&lt;Impression&gt;</code>: 광고 노출 추적 URL
<code>&lt;Creatives&gt;</code>: 광고 소재를 포함하는 컨테이너
<code>&lt;Creative id=&quot;67890&quot;&gt;</code>: 광고 소재의 고유 ID
<code>&lt;Linear&gt;</code>: 선형 광고
<code>&lt;Duration&gt;</code>: 광고 재생 시간
<code>&lt;MediaFiles&gt;</code>: 광고 소재 파일을 포함하는 컨테이너
<code>&lt;MediaFile&gt;</code>: 광고 소재 파일의 정보</p>
<ul>
<li>delivery: 전송 방식 (progressive)</li>
<li>type: 파일 형식 (video/mp4)</li>
<li>width, height: 영상의 크기</li>
</ul>
<p><code>&lt;VideoClicks&gt;</code>: 비디오 클릭 관련 정보를 포함하는 컨테이너
<code>&lt;ClickThrough&gt;</code>: 클릭 시 이동할 랜딩 페이지 URL
<code>&lt;ClickTracking&gt;</code>: 클릭 추적 URL
<code>&lt;TrackingEvents&gt;</code>: 광고 재생 중 발생하는 이벤트를 추적하는 컨테이너
<code>&lt;Tracking event=&quot;...&quot;&gt;</code>: 특정 이벤트 발생 시 호출될 추적 URL</p>
<ul>
<li>start: 광고 시작 시</li>
<li>firstQuartile: 25% 재생 시</li>
<li>midpoint: 50% 재생 시</li>
<li>thirdQuartile: 75% 재생 시</li>
<li>complete: 광고 완료 시</li>
</ul>
<pre><code>&lt;VAST version=&quot;4.2&quot;&gt;
  &lt;Ad id=&quot;12345&quot;&gt;
    &lt;InLine&gt;
      &lt;AdSystem&gt;Example Ad Server&lt;/AdSystem&gt;
      &lt;AdTitle&gt;Example Linear Ad&lt;/AdTitle&gt;
      &lt;Impression&gt;&lt;![CDATA[https://example-ad-server.com/impression/12345]]&gt;&lt;/Impression&gt;
      &lt;Creatives&gt;
        &lt;Creative id=&quot;67890&quot;&gt;
          &lt;Linear&gt;
            &lt;Duration&gt;00:00:30&lt;/Duration&gt;
            &lt;MediaFiles&gt;
              &lt;MediaFile id=&quot;1&quot; delivery=&quot;progressive&quot; type=&quot;video/mp4&quot; width=&quot;1280&quot; height=&quot;720&quot;&gt;
                &lt;![CDATA[https://example-ad-server.com/video/12345.mp4]]&gt;
              &lt;/MediaFile&gt;
            &lt;/MediaFiles&gt;
            &lt;VideoClicks&gt;
              &lt;ClickThrough&gt;&lt;![CDATA[https://example-advertiser.com/landing?ad=12345]]&gt;&lt;/ClickThrough&gt;
              &lt;ClickTracking&gt;&lt;![CDATA[https://example-ad-server.com/click/12345]]&gt;&lt;/ClickTracking&gt;
            &lt;/VideoClicks&gt;
            &lt;TrackingEvents&gt;
              &lt;Tracking event=&quot;start&quot;&gt;&lt;![CDATA[https://example-ad-server.com/tracking/start/12345]]&gt;&lt;/Tracking&gt;
              &lt;Tracking event=&quot;firstQuartile&quot;&gt;&lt;![CDATA[https://example-ad-server.com/tracking/firstQuartile/12345]]&gt;&lt;/Tracking&gt;
              &lt;Tracking event=&quot;midpoint&quot;&gt;&lt;![CDATA[https://example-ad-server.com/tracking/midpoint/12345]]&gt;&lt;/Tracking&gt;
              &lt;Tracking event=&quot;thirdQuartile&quot;&gt;&lt;![CDATA[https://example-ad-server.com/tracking/thirdQuartile/12345]]&gt;&lt;/Tracking&gt;
              &lt;Tracking event=&quot;complete&quot;&gt;&lt;![CDATA[https://example-ad-server.com/tracking/complete/12345]]&gt;&lt;/Tracking&gt;
            &lt;/TrackingEvents&gt;
          &lt;/Linear&gt;
        &lt;/Creative&gt;
      &lt;/Creatives&gt;
    &lt;/InLine&gt;
  &lt;/Ad&gt;
&lt;/VAST&gt;</code></pre><br>
<br>

<h3 id="aws-s3-simple-storage-service">AWS S3 (Simple Storage Service)</h3>
<p>객체 스토리지 서비스.
다양한 유형의 비정형 데이터를 저장 및 관리한다.
99.999999999%의 높은 내구성 및 무제한 확장성 제공한다.
미디어 파일 저장 및 배포에 최적화된 스토리지 솔루션이고 AWS 의 대표적인 서비스 중 하나이다.</p>
<br>

<h3 id="aws-medialive">AWS MediaLive</h3>
<p>방송 수준의 라이브 비디오 인코딩 서비스.
실시간 방송 스트리밍 생성 및 전송이 주 사용 목적이고,
다양한 입력 소스(SDI, RTP, HLS 등) 및 출력 형식(HLS, DASH 등) 지원한다.
안정적인 라이브 스트리밍 제공 및 방송 품질의 출력 생성한다고 한다.</p>
<br>

<h3 id="aws-mediaconvert">AWS MediaConvert</h3>
<p>방송 품질의 파일 기반 비디오 트랜스코딩 서비스.
다양한 해상도, 비트레이트 및 형식으로 비디오 파일을 변환한다.
고품질 비디오 출력 생성 및 다양한 기기 호환성을 확보하며,
VOD 콘텐츠 제작, 편집 및 배포에 활용한다.</p>
<br>

<h3 id="aws-mediapackage">AWS MediaPackage</h3>
<p>비디오 패키징 및 원본 서버 서비스.
다양한 스트리밍 프로토콜(HLS, DASH, CMAF 등)로 비디오 콘텐츠 패키징.
적응형 비트레이트 스트리밍(ABR) 지원 및 DRM 암호화를 제공해 S3 보다 안정적으로 사용이 가능하다.
안정적인 비디오 스트리밍 및 다양한 기기 호환성을 제공한다.</p>
<br>

<h3 id="aws-mediatailor">AWS MediaTailor</h3>
<p>서버 측 광고 삽입(SSAI, Server Side Ad Insert) 서비스.
비디오 스트림에 개인화된 광고 삽입 및 광고 추적 기능을 제공한다.
ADS 와 연동해 광고 시청률 및 수익 증대, 광고 차단 방지 및 사용자 경험을 향상시킨다.</p>
<br>

<h3 id="aws-cloudfront">AWS CloudFront</h3>
<p>글로벌 콘텐츠 전송 네트워크(CDN) 서비스.
전 세계에 분산된 엣지 로케이션에 콘텐츠 캐싱 및 전송한다.
빠른 콘텐츠 전송 및 지연 시간 감소, 글로벌 사용자에게 안정적인 스트리밍을 제공한다.
또한 DDoS 공격 방어 및 보안 기능을 제공해 무리없이 사용 가능하다.</p>
<br>

<h3 id="ads">ADS</h3>
<p>광고 결정 및 관리 서비스.
사용자 및 콘텐츠에 맞는 광고 선택 및 광고 수익 극대화를 목적으로 사용한다.
일반적으로 다양한 광고 플랫폼과 연동 및 광고 캠페인 관리 기능 제공하고, 광고 시청률 및 수익 분석 기능 제공한다.</p>
<p>대표적인 서비스로는 Google AD Manager 가 있고, 물론 자체 구축도 가능하다.</p>
<br>
<br>

<hr>
<br>
<br>



<h1 id="구성도">구성도</h1>
<br>

<p>티빙이 소개한 인프라 그대로를 따라하려면 실시간 스트리밍 환경을 구축해야 하는데, 
그러면 환경 구축에 힘을 너무 많이 들여야 하니 우선 VOD (저장된 영상)을 기반으로 구성해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5c3d3476-36f3-4ab2-991e-53a7df7468f3/image.png" alt=""></p>
<p>위는 영상을 저장하는 방식이다.
VOD 를 저장하기 위해 AWS S3 를 사용한다.
그리고 .mp4 와 같은 일반 영상 형식이 아닌 HLS 변환을 시킬 것이기 때문에, AWS MediaConvert 를 이용한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b94aecc3-95f9-477b-82ac-93921098e5ea/image.png" alt=""></p>
<p>위는 클라이언트가 영상 및 광고+영상을 요청하는 과정이다.</p>
<p>원본 영상만을 요청할 경우 아래와 같은 과정을 거친다.</p>
<ul>
<li>CloudFront 가 MediaPackage 에게 영상을 요청</li>
<li>MediaPackage 는 S3 에게 HLS segment 를 요청</li>
<li>MediaPackage 가 manifest 를 생성 및 응답받은 HLS segment 과 함께 응답</li>
<li>CloudFront 는 이 응답을 캐싱하며 클라이언트에게 응답한다</li>
</ul>
<p>원본 + 광고 영상을 요청할 경우 아래와 같은 과정을 거친다.</p>
<ul>
<li>CloudFront 는 MediaTailor 에게 영상 및 광고 요청</li>
<li>MediaTailor 는 MediaPackage 에게 manifest 및 segment 를 요청하며, ADS 에게는 광고 VAST 를 요청</li>
<li>MediaTailor 는 응답받은 manifest, segment 와 VAST 를 조합해 원본 + 광고 manifest 를 생성해 응답</li>
<li>CloudFront 는 정책에 따라, 광고 또는 원본 영상의 segment 를 캐싱하며 클라이언트에게 응답한다.</li>
</ul>
<br>
<br>

<hr>
<br>
<br>


<h2 id="테스트용-영상-만들기">테스트용 영상 만들기</h2>
<br>

<p>용량이 큰 영상으로 진행하면 그만큼 요금도 많이 나올테니, 
최대한 작은 영상으로 (1MB 이하) 로 진행해보자.</p>
<p><a href="https://ffmpeg.org/download.html">https://ffmpeg.org/download.html</a></p>
<p>에서 자신의 OS 에 맞는 FFmpeg 설치파일을 다운로드한다.</p>
<ul>
<li>Windows 의 경우엔 <a href="https://www.gyan.dev/ffmpeg/builds/">https://www.gyan.dev/ffmpeg/builds/</a></li>
</ul>
<p>압축을 풀고 <code>C:\Program Files\ffmpeg</code> 의 경로에 디렉토리를 생성하고 </p>
<ul>
<li>bin</li>
<li>doc</li>
<li>presets</li>
</ul>
<p>디렉토리를 해당 디렉토리로 이관 후 
<code>C:\Program Files\ffmpeg\bin</code> 디렉토리를 PATH 환경변수에 등록한다.</p>
<p>이후 powershell 명령어를 출력</p>
<pre><code># PowerShell
# 설치 확인
ffmpeg -version

&gt;&gt; ffmpeg version 7.1-essentials_build-www.gyan.dev Copyright (c) 2000-2024 the FFmpeg developers
built with gcc 14.2.0 (Rev1, Built by MSYS2 project)
configuration: --enable-gpl --enable-version3 --enable-static --disable-w32threads --disable-autodetect --enable-fontconfig --enable-iconv --enable-gnutls --enable-libxml2 --enable-gmp --enable-bzlib --enable-lzma --enable-zlib --enable-libsrt --enable-libssh --enable-libzmq --enable-avisynth --enable-sdl2 --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libaom --enable-libopenjpeg --enable-libvpx --enable-mediafoundation --enable-libass --enable-libfreetype --enable-libfribidi --enable-libharfbuzz --enable-libvidstab --enable-libvmaf --enable-libzimg --enable-amf --enable-cuda-llvm --enable-cuvid --enable-dxva2 --enable-d3d11va --enable-d3d12va --enable-ffnvcodec --enable-libvpl --enable-nvdec --enable-nvenc --enable-vaapi --enable-libgme --enable-libopenmpt --enable-libopencore-amrwb --enable-libmp3lame --enable-libtheora --enable-libvo-amrwbenc --enable-libgsm --enable-libopencore-amrnb --enable-libopus --enable-libspeex --enable-libvorbis --enable-librubberband
libavutil      59. 39.100 / 59. 39.100
libavcodec     61. 19.100 / 61. 19.100
libavformat    61.  7.100 / 61.  7.100
libavdevice    61.  3.100 / 61.  3.100
libavfilter    10.  4.100 / 10.  4.100
libswscale      8.  3.100 /  8.  3.100
libswresample   5.  3.100 /  5.  3.100
libpostproc    58.  3.100 / 58.  3.100</code></pre><p>이제 아무거나 녹화해서 영상을 만든다.
구글에 떠돌아다니는 거 말고 그냥 Window + G 로 녹화가 가능하다.</p>
<p>간단히 스톱워치를 녹화했는데, 8초 짜리 영상에 1.5MB 용량을 차지해, ffmpeg 를 통해 이를 줄여보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b0239d78-81e8-4694-ac28-147d8482d366/image.png" alt=""></p>
<pre><code># PowerShell
# 사전에 해당 영상 파일의 이름을 input.mp4 로 변경한다.
# 아래는 상대경로로 실행하므로 input.mp4 가 위치한 디렉토리에서 실행한다.
ffmpeg -i ./input.mp4 -vf &quot;scale=426:240&quot; -r 15 -b:v 300k -c:v libx264 -preset fast -crf 30 -b:a 64k -c:a aac output.mp4
</code></pre><br>

<p>이제 23KB 으로 줄어든 모습을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a7551774-c476-4c6f-818e-6e48aeb21bb8/image.png" alt=""></p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="aws-s3-버킷-생성-및-mediaconvert-로-hls-변환">AWS S3 버킷 생성 및 MediaConvert 로 HLS 변환</h2>
<p>저장된 영상을 가져와 송출하려면, S3 에 송출할 수 있는 영상 형태로 저장이 되어야 한다.
즉, .mp4 와 같은 형식이 아닌 HLS/DASH 로 변환된 파일로 저장이 되어야 한다는 뜻이다.</p>
<p>S3 에 영상파일을 업로드하고, 이를 AWS MediaConavert 로 변환하는 과정을 거쳐보자.</p>
<br>

<blockquote>
<p>AWS S3 버킷 생성 과정은 <a href="https://celdan.tistory.com/36">https://celdan.tistory.com/36</a>
을 참고. 버킷 정책 JSON 은 위를 참고하지 말고 아래와 같이 따라해야 한다.</p>
</blockquote>
<p>Bucket 정책 JSON 을 위 포스팅과 다르게 해야 하는 이유</p>
<ol>
<li>MediaConvert 의 접근을 위해 <code>GetBucketLocation</code>, <code>GetBucketRequestPayment</code>, <code>ListBucket</code> 정책도 허용해야 함.</li>
<li>AWS Policy Generator 에 오류가 있어 Resources 의 arn 값 뒤에 /* 를 입력해야 한다.</li>
</ol>
<p>결론적으로는 Bucket 정책을 아래와 같이 입력해야 한다.</p>
<pre><code>{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Id&quot;: &quot;Policy1740884171507&quot;,
    &quot;Statement&quot;: [
        {
            &quot;Sid&quot;: &quot;StmtAllowObjectActions&quot;,
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: &quot;*&quot;,
            &quot;Action&quot;: [
                &quot;s3:GetObject&quot;,
                &quot;s3:PutObject&quot;
            ],
            &quot;Resource&quot;: &quot;버킷arn입력/*&quot;
        },
        {
            &quot;Sid&quot;: &quot;StmtAllowBucketActions&quot;,
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: &quot;*&quot;,
            &quot;Action&quot;: [
                &quot;s3:GetBucketLocation&quot;,
                &quot;s3:GetBucketRequestPayment&quot;,
                &quot;s3:ListBucket&quot;
            ],
            &quot;Resource&quot;: &quot;버킷arn입력&quot;
        }
    ]
}</code></pre><p>추가적으로, 보안 상 퍼블릭 액세스 차단도 설정해주는 것이 좋다.
여기선 S3 를 외부에서 직접 접근하는 것이 아닌 MediaConvert, CloudFront 로부터 접근하므로 외부에 공개할 필요가 없다.</p>
<p>S3 버킷이 만들어졌으면 위에서 만든 테스트 영상을 업로드한다.</p>
<br>

<p>이후 AWS MediaConvert 는 <a href="https://ap-northeast-2.console.aws.amazon.com/mediaconvert/home?region=ap-northeast-2#/welcome">AWS MediaConvert</a> 에서 변환한다.</p>
<p>입력 창에 어떤 파일을 변환할지 선택한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/752b8865-304d-4286-b70f-82aa3ec0613c/image.png" alt=""></p>
<p>출력 형식을 지정하기 위해 출력 그룹을 추가한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/bd1379d5-0823-4cb6-aabe-199e7e7d130b/image.png" alt=""></p>
<p>HLS 로 변환 선택 후,</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c29391cf-3dcb-4301-915a-e3277a0bb41a/image.png" alt=""></p>
<p>우선 어디에 저장할 지 선택한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/3635bce5-d773-4efb-8274-01551d576d18/image.png" alt=""></p>
<p>이후엔 출력 형식을 아래와 같이 설정한다.</p>
<ul>
<li>이름 한정자 (Name Modifier, 필수값) : <code>_$dt$</code>
ex)
input_360p_20250302T033716.m3u8</li>
<li>최대 비트레이트 (필수값)
최소 1000 이상을 설정해야 해 1000으로 설정했더니 영상이 다 깨지는 불상사가 발생했다. 어차피 영상 자체가 엄청 크지 않으므로 10000000 과 같이 적당히 크게 잡아보자.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1b42a167-e8a2-47b1-88f5-43a383ddc250/image.png" alt=""></p>
<p>이후 S3 버킷의 출력 디렉토리를 들어가보면 아래와 같이 나온다.
여기서는 해상도를 별도로 지정하지 않았으므로, 해상도에 따른 파일이 분리되지 않았음에 참고하자.</p>
<ul>
<li>output.m3u8  : 마스터 플레이리스트 (다양한 해상도 관리)</li>
<li>output_$해상도_$dt.m3u8 : 개별 해상도의 변형 플레이리스트</li>
<li>output_$해상도_$dt_0000n.ts : 실제 비디오 세그먼트 파일 (설정에서 세그먼트를 10초 단위로 생성했으므로, 10초가 안되는 이 영상은 1개의 세그먼트만 생성되었다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/7914eba5-3b51-4b4e-b3a3-84b153ba1e78/image.png" alt=""></p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="aws-mediapackage-연동">AWS MediaPackage 연동</h2>
<br>

<p>HLS 변환된 파일을 CloudFront 에서 가져가기 위해선 패키징을 해야 한다.
패키징한 것을 저장하는 개념이 아닌, 
HLS/DASH 변환된 세그먼트를 관리하고 manifest 파일과 같이 실시간으로 패키징하는 개념이다.</p>
<p>이를 AWS S3 와 연동해보자.</p>
<br>
<br>

<h3 id="mediapackage-role-설정">MediaPackage Role 설정</h3>
<p>우선 MediaPackage 의 Role 을 설정해야 한다.</p>
<p><a href="https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/roles">IAM Role(역할) 설정</a></p>
<p>역할 생성 -&gt; 사용자 지정 신뢰 정책 (MediaPackage 는 AWS 서비스 탭에서 노출되지 않음..)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/86b1a1a3-e566-4858-9e98-0366b4dadef9/image.png" alt=""></p>
<p>사용자 지정 신뢰 정책에는 아래와 같이 mediapackage.amazonaws.com 에 대한 접근 권한을 설정해주어야 한다.</p>
<pre><code>{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
        {
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: {
                &quot;Service&quot;: &quot;mediapackage.amazonaws.com&quot;
            },
            &quot;Action&quot;: &quot;sts:AssumeRole&quot;
        }
    ]
}</code></pre><br>

<p>이후 <code>AWSElementalMediaPackageV2ReadOnly</code> 로 읽기 권한만을 선택한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/eaeeab5a-6193-45c9-afa6-b253d9689e87/image.png" alt=""></p>
<br>

<p>역할 이름 (Role Name) 만 설정하고 넘어가자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/605f3d57-90b3-45cc-b52f-741bda1880c4/image.png" alt=""></p>
<br>
<br>


<h3 id="s3-bucket-cors-설정">S3 Bucket CORS 설정</h3>
<br>

<p>MediaConvert S3 접근을 위해서는 CORS 설정도 해주어야한다.</p>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/s3/buckets?region=ap-northeast-2&amp;bucketType=general">AWS S3</a> 에 진입해 영상을 업로드한 S3 Bucket 에 진입한다.</p>
<p>권한 탭의 CORS 를 아래와 같이 지정한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b8528dcf-b93a-436d-96af-cbc6afca4fde/image.png" alt=""></p>
<pre><code>[
    {
        &quot;AllowedHeaders&quot;: [
            &quot;*&quot;
        ],
        &quot;AllowedMethods&quot;: [
            &quot;GET&quot;,
            &quot;HEAD&quot;
        ],
        &quot;AllowedOrigins&quot;: [
            &quot;*&quot;
        ],
        &quot;ExposeHeaders&quot;: []
    }
]</code></pre><br>
<br>

<h3 id="mediapackage-생성-및-s3-연동">MediaPackage 생성 및 S3 연동</h3>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediapackage/home?region=ap-northeast-2#/landing">AWS MediaPackage</a></p>
<p>여기서 좌측 탭에서 설정 시 주의할 점이 있다.</p>
<ul>
<li>Live : AWS MediaLive 와 연동</li>
<li>Video on demand : S3 와 같은 미리 HLS 변환된 영상 파일과 연동</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b27f70f7-4f09-4148-97c9-f9c4704225d7/image.png" alt=""></p>
<p>이 과정에서는 S3 와 연동할 것이므로, Video on demand 로 설정한다.</p>
<p>우선 패키징할 그룹 생성을 한다. 
(Packging groups -&gt; Create Group)</p>
<p>간단히 테스트만 할 것이므로 ID 만 지정하고 Create 한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/00e1237d-56bc-4e1c-ad64-444fb0daf710/image.png" alt=""></p>
<p>이제는 실제 S3 의 HLS 변환된 파일과 연동하기 위해 좌측 탭의 
Video on Demand -&gt; Assets -&gt; Ingest assets 에 진입한다.</p>
<ul>
<li>S3 Bucket name : 어느 S3 Bucket 을 지정할 것인지</li>
<li>Use existing role : MediaPackage 에 접근 가능한 Role 을 선택</li>
<li>Filename : S3 Bucket 에서 어떠한 파일을 가져오게 할 것인지 (.m3u8 인 매니페스트 파일 선택)</li>
<li>Packaging group : 바로 위에서 생성한 Package group 명 선택</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c9b1f18c-3a6d-4285-bb9a-1f7647ccd101/image.png" alt=""></p>
<p>이제 정상적으로 잘 가져오는지 확인해보자.
<code>Preview</code> 로 이동하면 HLS 변환된 파일을 재생할 수 있는 테스트 웹뷰를 제공한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/66755321-710c-464a-9433-a38aa524dc80/image.png" alt=""></p>
<p>정상적으로 잘 가져오는 것을 확인 가능하다.</p>
<blockquote>
<p>여기서 아래와 오류가 발생할 수 있다. <br>
    CORS 오류 : S3 의 CORS 설정 누락 <br>
    영상이 깨짐 : MediaConvert 시 비트레이트 설정값이 낮았을 확률이 높음 <br>
    영상을 아예 가져오지 못하는 경우 : Video on demand 가 아닌 Live 로 설정되지 않았는지, Assets 에 유효한 S3 의 영상을 가져오는지 확인</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ad1715f2-1fcb-464d-b3c6-9056bc4b4e2e/image.png" alt=""></p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="aws-cloudfront-연동">AWS CloudFront 연동</h2>
<br>

<p>AWS CloudFront 는 CDN 서비스이다.</p>
<p>CDN은 물리적 거리가 먼 전세계 유저들의 접근 시 대용량의 파일을 일일이 원본 서버에서 전송하면 굉장히 느려질 수 있으므로,
물리적으로 가까운 CDN 서버에서 컨텐츠를 제공할 수 있도록 캐싱하여 응답하게 한다.</p>
<p>이를 MediaConvert 와 연동하고, PC 에서 직접 접근해보자.</p>
<p><a href="https://us-east-1.console.aws.amazon.com/cloudfront/v4/home?region=ap-northeast-2#/distributions">AWS CloudFront</a> 에서 <code>배포 생성</code> 을 진입하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e8e48bbf-a168-4aa8-a7a6-34d346cc5312/image.png" alt=""></p>
<p>Origin domain 선택 시 MediaPackage 가 노출되지 않는다.
CloudFront 에서 직접적으로 제공하는 것은 MediaPackage 의 Live 만 제공하고 on demand 는 제공하지 않는 것으로 보이므로.. 직접 도메인을 입력해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5cdcf071-446b-44fb-8580-f8fe1340de64/image.png" alt=""></p>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediapackagevod/home?region=ap-northeast-2#/packaging_groups">AWS MediaPackage</a> 의 Packaging에 다시 진입해 위에서 만든 패키지의 도메인을 복사 후,
위의 <code>CloudFront 의 Origin domain</code> 에 넣는다.</p>
<p>프로토콜은 HTTPS 로 설정한다.
MediaConvert 는 HTTP 를 지원하지 않으므로 HTTPS 로만 지정해야 됨을 기억하자.</p>
<p>이후 Origin path 에는 <code>/out/v1</code> 을 넣는다.
이게 MediaConvert 에서 자동으로 기본 도메인 뒤에 /out/v1 엔드포인트를 넣는데, 이를 설정하지 않으면 나중에 CloudFront 에 요청할 시 /out/v1 을 수동으로 넣어도 동작하지 않는다..</p>
<p>추가적으로 Origin Sheid 도 설정해주자. 
서울 리전에 캐싱 계층을 두어 영상을 빠르게 가져올 수 있게 해준다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dfdb2d8e-1c22-4689-b744-f988eb3d3934/image.png" alt=""></p>
<p>이후 WAF 만 활성화 하고 마무리하자.
딸깍 한 번 + 적은 비용으로 혹시 모를 공격에 방어를 쉽게 적용해준다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b11520df-4174-43ac-beb7-d7bc1ff0d43c/image.png" alt=""></p>
<p>이제 배포가 활성화 되었는지 확인하자.
요즘은 AWS 도 최적화가 많이 됐는지 서비스가 금방금방 띄워진다.
체감 상 설정 완료 후 5초도 되지 않아 띄워지는 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/d674e5a0-8718-4c95-b857-e7fa1b60ef07/image.png" alt=""></p>
<p>활성화가 확인되었으면 해당 서비스의 도메인을 복사해두자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/270259eb-e088-4486-8444-17463575d348/image.png" alt=""></p>
<p>이제는 <a href="https://CloudFront%EB%8F%84%EB%A9%94%EC%9D%B8/MediaPackage_%ED%8C%8C%EC%9D%BC%EC%9D%98_Endpoint">https://CloudFront도메인/MediaPackage_파일의_Endpoint</a> 로 접근이 가능하다.</p>
<p>MediaPackage_파일의_Endpoint 는 위에서 Preview 할 수 있던 화면에서 확인 가능하다.
<a href="https://ap-northeast-2.console.aws.amazon.com/mediapackagevod/home?region=ap-northeast-2#/assets">AWS MediaPackage</a> 에서 설정한 Assets 에 진입해,
해당 파일에 접근할 수 있는 URL 을 복사하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/4135260e-472b-4554-bf6c-8e668bdf5a80/image.png" alt=""></p>
<p>이를 아래와 같이 조합한다.</p>
<ul>
<li>CloudFront 의 도메인 +</li>
<li>MediaPackage 의 도메인/out/v1 뒤의 내용
ex) <a href="https://dxxxxxxxxxxxxx.cloudfront.net/123/567/abc/index.m3u8">https://dxxxxxxxxxxxxx.cloudfront.net/123/567/abc/index.m3u8</a></li>
</ul>
<p>영상이 제대로 나오는지는 아래에서 확인 가능하다.
<a href="https://hlsjs.video-dev.org/demo/?src=%EC%9C%84%EC%97%90%EC%84%9C%EC%A1%B0%ED%95%A9%ED%95%9CURL">https://hlsjs.video-dev.org/demo/?src=위에서조합한URL</a></p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="aws-mediatailor-aws-cloudfront-연동">AWS MediaTailor, AWS CloudFront 연동</h2>
<br>

<p>AWS MediaTailor 는 기존 원본 영상에서 광고를 삽입해 원본 + 광고 영상 자체를 응답하는 SSAI (Server Side Ads Insert) 방식으로 구현되어 있다.</p>
<p>이 포스팅에서는 아래 구조를 구현하려고 한다.</p>
<ul>
<li>MediaPackage 로부터 원본 영상을 가져오고,</li>
<li>ADS (Ad Decision Server, 샘플용) 으로부터 광고 영상을 가져오고</li>
<li>CloudFront 는 MediaTailor 와 연동해 원본 + 광고 영상을 응답</li>
</ul>
<p>참고할 점은 광고 결정 서버에서 응답해줄 때에는, VAST 규격으로 응답해야 됨을 알고 있어야 한다.</p>
<br>

<h3 id="mediatailor-생성">MediaTailor 생성</h3>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediatailor/home?region=ap-northeast-2#/controlpanel">AWS Elemental MediaTailor</a> 으로 진입해 <code>구성 생성</code> 에 진입한다.</p>
<p>그러면 아래와 같이 3개의 값을 필수 입력해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/0ac55bcd-7b9d-425c-952b-498652b4f41f/image.png" alt=""></p>
<p>하나씩 알아보자.</p>
<p><strong><code>이름</code></strong> : MediaTailor 서비스 식별을 위한 ID
<strong><code>콘텐츠 소스 (Content Source)</code></strong> : 원본 영상을 가져올 위치
예시인 placeholder 에는 domain/out/v1 까지만 작성되어 디렉토리 위치까지는 지정하지 않았지만, 이러면 나중에 요청할 때마다 디렉토리 위치까지 전부 지정해주어야 하는 귀찮음이 발생한다.</p>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediapackagevod/home?region=ap-northeast-2#/assets">AWS MediaPackage Assets</a> 의 원본영상의 URL 을 복사 후 파일명만 제거하자. </p>
<p>ex) <a href="https://abc.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/abc/abc/abc/index.m3u8">https://abc.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/abc/abc/abc/index.m3u8</a>
에서 실제 파일명인 index.m3u8 만 제외해 
<a href="https://abc.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/abc/abc/abc">https://abc.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/abc/abc/abc</a> 까지만 복사해 붙여넣자.</p>
<p>이러면 /abc/abc/abc 디렉토리에 있는 모든 원본 영상들을 가져올 때 endpoint 에 디렉토리명 없이 파일명으로만 호출할 수 있게 된다.</p>
<p><strong><code>광고 결정 서버 (ADS)</code></strong> : 어떠한 광고를 어떻게 넣을건지 응답해주는 서버의 URL 을 입력한다.
우선 테스트용이므로 Google 에서 제공하는 VAST 규격의 서버 URL 을 넣어보자.
<a href="https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/tags">VAST 규격 Google Media Sample</a> 에서 가져올 수 있다.</p>
<p>이 값 중 하나인 &#39;Single Inline Linear&#39; 의 값을 넣어보자.</p>
<p><code>https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_ad_samples&amp;sz=640x480&amp;cust_params=sample_ct%3Dlinear&amp;ciu_szs=300x250%2C728x90&amp;gdfp_req=1&amp;output=vast&amp;unviewed_position_start=1&amp;env=vp&amp;impl=s&amp;correlator=</code></p>
<p>이 URL 로 HTTP API 요청을 날려보면 아래와 같이 응답한다.
<strong>(너무 많이 요청하면 Google 에서 빈 영상을 응답하므로 주의하자)</strong></p>
<pre><code>&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;VAST xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot; xsi:noNamespaceSchemaLocation=&quot;vast.xsd&quot; version=&quot;3.0&quot;&gt;
    &lt;Ad id=&quot;5925573263&quot;&gt;
        &lt;InLine&gt;
            &lt;AdSystem&gt;GDFP&lt;/AdSystem&gt;
            &lt;AdTitle&gt;External - Single Inline Linear&lt;/AdTitle&gt;
            &lt;Description&gt;
                &lt;![CDATA[External - Single Inline Linear ad]]&gt;
            &lt;/Description&gt;
            &lt;Error&gt;
                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=videoplayfailed[ERRORCODE]]]&gt;
            &lt;/Error&gt;
            &lt;Impression&gt;
                &lt;![CDATA[https://securepubads.g.doubleclick.net/pcs/view?xai=AKAOjstBkogPC4a6Cvw-LYvr4_Br0eB49GtbTgPHdOFo9Vh2HDkz4biCJsXEJ2aYuqkFPq4XAIq1S9tTRs0BGeqbzBYhTvTjmaXJF0gZEbY7j2DuzL9JmmCXNVKRTtkIwCu5mhjEIpeiX8iIhhe66-Pyrva1bIovc_QL0XPX5aOlGhpEChlU9H83jykdYUoWl0wNiCVMTfhvChkIP19_27GnI23rwjJ2O44vEr9ujzk4CAGZoo1QV0BJ_xHrNHDFk0mmYxO_NY2LLFLRF9jVIyzVGMCkOkh16vEMnKbK7q39tFLZgyWDUBg0aqIxaHxNDC52EM8rfih4e-WAS0lYPAKcI_rdDPvBJb1Ui7Szumzj5myjyOWxv3Fohpsw2yWYiLCB4uKVQQ&amp;sai=AMfl-YSnCixqbGIazNzHj5p5rwDLsHzAycoSVU_LT_q6cta9glv6KfIrNREwetCTpBZjOaKpd0iqv6f5ygdm&amp;sig=Cg0ArKJSzIa90Sa1CGTzEAE&amp;uach_m=%5BUACH%5D&amp;adurl=]]&gt;
            &lt;/Impression&gt;
            &lt;Creatives&gt;
                &lt;Creative id=&quot;138381721867&quot; AdID=&quot;H0Hrk8zCNZI&quot; sequence=&quot;1&quot;&gt;
                    &lt;CreativeExtensions&gt;
                        &lt;CreativeExtension type=&quot;UniversalAdId&quot;&gt;
                            &lt;UniversalAdId idRegistry=&quot;googlevideo&quot;&gt;H0Hrk8zCNZI&lt;/UniversalAdId&gt;
                        &lt;/CreativeExtension&gt;
                    &lt;/CreativeExtensions&gt;
                    &lt;Linear&gt;
                        &lt;Duration&gt;00:00:10&lt;/Duration&gt;
                        &lt;TrackingEvents&gt;
                            &lt;Tracking event=&quot;start&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=part2viewed&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;firstQuartile&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=videoplaytime25&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;midpoint&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=videoplaytime50&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;thirdQuartile&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=videoplaytime75&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;complete&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=videoplaytime100&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;mute&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=admute&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;unmute&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=adunmute&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;rewind&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=adrewind&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;pause&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=adpause&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;resume&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=adresume&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;creativeView&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=vast_creativeview&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;fullscreen&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=adfullscreen&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;acceptInvitationLinear&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=acceptinvitation&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;closeLinear&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=adclose&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                            &lt;Tracking event=&quot;exitFullscreen&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=vast_exit_fullscreen&amp;ad_mt=%5BAD_MT%5D]]&gt;
                            &lt;/Tracking&gt;
                        &lt;/TrackingEvents&gt;
                        &lt;VideoClicks&gt;
                            &lt;ClickThrough id=&quot;GDFP&quot;&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pcs/click?xai=AKAOjsuvXg8WDEg_3jM5BrCMMZixX6S1SMryJkxVW4hCCGEHygXFdnbvWRt0i-IKY1Efh-z7BYLu66_mWgtbXdS1xUMelT08DmaiqEOp1dVct2nldzeUuEHKVfs0gM1OkzUXxSKWzd1juVflo2sa3H_77M-YaLiS6CfvtlyMVtV93qLRZvmsnIccO0VcbosvfD9KTzoF1cxSww8Dw_UCtZib-h5VDRRi0lAbjbZTdxBgsYPc0rsvp2jmwxWhmlnvB-R6X8o4xwZFq7GxExWl3M6OeVoEq2KPuVhKzmG9jCd7jJyrrIDPt8oDr9alDOP_DDS40drpplUBoIMBDRK5-xpIzdu4tF6gQcFO4FJKJibZuIyKoTtf7hvm&amp;sai=AMfl-YRKowplk9KfrWmQwyIYrMNTnCXsZaZCdLIqbvfWLU7S3i-IpKnn_9wG2AzQIwvhKFyWTe5vJHSLWDJC&amp;sig=Cg0ArKJSzLmhCch-llwR&amp;fbs_aeid=%5Bgw_fbsaeid%5D&amp;adurl=https://googleads.github.io/googleads-ima-html5/vsi/]]&gt;
                            &lt;/ClickThrough&gt;
                        &lt;/VideoClicks&gt;
                        &lt;Icons&gt;
                            &lt;Icon program=&quot;GoogleWhyThisAd&quot; width=&quot;18&quot; height=&quot;18&quot; xPosition=&quot;right&quot; yPosition=&quot;bottom&quot;&gt;
                                &lt;StaticResource creativeType=&quot;image/png&quot;&gt;
                                    &lt;![CDATA[https://imasdk.googleapis.com/formats/wta/help_outline_white_24dp_with_3px_trbl_padding.png?wp=ca-pub-9939518381636264]]&gt;
                                &lt;/StaticResource&gt;
                                &lt;IconClicks&gt;
                                    &lt;IconClickThrough&gt;
                                        &lt;![CDATA[https://adssettings.google.com/whythisad?source=display&amp;reasons=AU2hxZ3KLLLfmYW-RqIDEg2gvvtQnwx5efWb8poBoms0nttpGolOV8OgQl3wxLC-TFcXZCMgTUzulFAQK56wXiN53waGG4dxg_Sk-U3DdvuSOp0WgXRzo5-zBnDv-F-W0DFHKFQszH6X5h_cD9wIOd_0ZeogY_9rS7EK_VdEwRvWGk5TPmIh91pHfq_6TtW08bf2CQAbdwybjuH4lI7fmhv3XuqgnzVj9FWlpA7pV-DJvw-CeKIxJJgTrfWwW0sxhutq4eaBjmMF7-Ax323T1_pa9bRnQKl7BCuRAWqzabLnrQwc_hKw3Fhk50VuEe3nOmcc6SLQu5UH22YXywEeFLSrPmnzluuxBqbUoGvzdtRkjuNaVQ7OE6a5zxzsebFuNH5RfG8ajdrFbgClFtUE___M2fIrIrScbmaW8mvx_5WFa1dTz_vz6GE6KW2f8Ec6TNl5QnEBu8HTgPjTmlfeq59KuWzeehkJlai0kFguOnURXBvXPEx14rsDD4naoCAi0U-efM1IwQtrgk4hS7-ChVpApwGupD8W77D-SSrGWEjBkbTtpKw0DuRx_Ru26MSiifFYraIL2SbOmawbGp6Hgx_t6IBflieYqMyqCNQ0Uvdixh6ENobOJgryF42jbzJ2cVeTCO0vjlWR9joLMMgwR2ziCxOVzOivbOwO7Zzml0j2q17kwU4C5B7BaQ2Ymueg6jkQXKJP7J0HLzTQBWlIufSq8Eh3OUm74kduskOrb41CscLSyDYk-IBo-2V7KhJAwJc-p73AzMZ3sTAqKfZVZ1Q15ooql9Qs5i9N3BigOlGUSA5OJMDOjK_0yu3_OlUjEnLGmoiS1fkFtOCAUfDaElzZdiN3oynQH7D6eWS5gFo_-avg2ics3Wtmi0c6zSGObmZ2GJAMvx2H_OSjS0gfYjT5pjKAL05WC2JJvjZoU9Af&amp;opi=122715837]]&gt;
                                    &lt;/IconClickThrough&gt;
                                &lt;/IconClicks&gt;
                            &lt;/Icon&gt;
                        &lt;/Icons&gt;
                        &lt;MediaFiles&gt;
                            &lt;MediaFile id=&quot;GDFP&quot; delivery=&quot;progressive&quot; width=&quot;640&quot; height=&quot;360&quot; type=&quot;video/mp4&quot; bitrate=&quot;140&quot; scalable=&quot;true&quot; maintainAspectRatio=&quot;true&quot;&gt;
                                &lt;![CDATA[https://redirector.gvt1.com/videoplayback/id/f1be9c477e89fd68/itag/18/source/dclk_video_ads/acao/yes/cpn/mlIQ5E2Gs3QA6tpy/ctier/L/ei/vEfFZ_XUEOjzxtYP6cPR-Ag/ip/0.0.0.0/requiressl/yes/rqh/1/susc/dvc/xpc/Eghovf3BOnoBAQ%3D%3D/expire/1772518204/sparams/expire,ei,ip,itag,requiressl,acao,ctier,source,id,rqh,susc,xpc/sig/AJfQdSswRAIgVZXpNhr-oDO0Hg4FaUnzWdwyG13FHuDrl_jP6OOKiBcCIEjLlNFCld4L7mNf23LW1BW4yarAZjDv82YRbDoL85s2/file/file.mp4]]&gt;
                            &lt;/MediaFile&gt;
                            &lt;MediaFile id=&quot;GDFP&quot; delivery=&quot;progressive&quot; width=&quot;1280&quot; height=&quot;720&quot; type=&quot;video/mp4&quot; bitrate=&quot;237&quot; scalable=&quot;true&quot; maintainAspectRatio=&quot;true&quot;&gt;
                                &lt;![CDATA[https://redirector.gvt1.com/videoplayback/id/f1be9c477e89fd68/itag/22/source/dclk_video_ads/acao/yes/cpn/mlIQ5E2Gs3QA6tpy/ctier/L/ei/vEfFZ_XUEOjzxtYP6cPR-Ag/ip/0.0.0.0/requiressl/yes/rqh/1/susc/dvc/xpc/Eghovf3BOnoBAQ%3D%3D/expire/1772518204/sparams/expire,ei,ip,requiressl,acao,ctier,source,id,itag,rqh,susc,xpc/sig/AJfQdSswRQIgXINod-i43FCu3jLycyuzwcye9S7VcBxW3YIdO2xpEroCIQDiXzVpqsgvFvr03XMSfAQ8Z4yWeoxidPzcU0ZJtRmPPA%3D%3D/file/file.mp4]]&gt;
                            &lt;/MediaFile&gt;
                            &lt;MediaFile id=&quot;GDFP&quot; delivery=&quot;progressive&quot; width=&quot;1280&quot; height=&quot;720&quot; type=&quot;video/mp4&quot; bitrate=&quot;259&quot; scalable=&quot;true&quot; maintainAspectRatio=&quot;true&quot;&gt;
                                &lt;![CDATA[https://redirector.gvt1.com/videoplayback/id/f1be9c477e89fd68/itag/106/source/dclk_video_ads/acao/yes/cpn/mlIQ5E2Gs3QA6tpy/ctier/L/ei/vEfFZ_XUEOjzxtYP6cPR-Ag/ip/0.0.0.0/requiressl/yes/rqh/1/susc/dvc/xpc/Eghovf3BOnoBAQ%3D%3D/expire/1772518204/sparams/expire,ei,ip,requiressl,acao,ctier,source,id,itag,rqh,susc,xpc/sig/AJfQdSswRgIhAKesKpoPdYBMzDKuVOnfyCkETqwf65t8Tv78JHa9pdbEAiEAzXoOoNAZHuMfsqtyI2ncAC7CZpPhvcuLmx5a3gAMAMY%3D/file/file.mp4]]&gt;
                            &lt;/MediaFile&gt;
                            &lt;MediaFile id=&quot;GDFP&quot; delivery=&quot;progressive&quot; width=&quot;854&quot; height=&quot;480&quot; type=&quot;video/mp4&quot; bitrate=&quot;183&quot; scalable=&quot;true&quot; maintainAspectRatio=&quot;true&quot;&gt;
                                &lt;![CDATA[https://redirector.gvt1.com/videoplayback/id/f1be9c477e89fd68/itag/109/source/dclk_video_ads/acao/yes/cpn/mlIQ5E2Gs3QA6tpy/ctier/L/ei/vEfFZ_XUEOjzxtYP6cPR-Ag/ip/0.0.0.0/requiressl/yes/rqh/1/susc/dvc/xpc/Eghovf3BOnoBAQ%3D%3D/expire/1772518204/sparams/expire,ei,ip,requiressl,acao,ctier,source,id,itag,rqh,susc,xpc/sig/AJfQdSswRAIgMamPM2fgblyQTG-X7hTMo4AZCvqC0maaZ3I7zAorfP4CIAj8SYeGatLyA4q8E7YfYRmP4HQ8caOhM8Paj3PBzubd/file/file.mp4]]&gt;
                            &lt;/MediaFile&gt;
                            &lt;MediaFile id=&quot;GDFP&quot; delivery=&quot;streaming&quot; width=&quot;256&quot; height=&quot;144&quot; type=&quot;application/x-mpegURL&quot; minBitrate=&quot;96&quot; maxBitrate=&quot;494&quot; scalable=&quot;true&quot; maintainAspectRatio=&quot;true&quot;&gt;
                                &lt;![CDATA[https://redirector.gvt1.com/api/manifest/hls_variant/id/f1be9c477e89fd68/itag/0/source/dclk_video_ads/acao/yes/cpn/mlIQ5E2Gs3QA6tpy/ctier/L/ei/vEfFZ_XUEOjzxtYP6cPR-Ag/hfr/all/ip/0.0.0.0/keepalive/yes/playlist_type/DVR/requiressl/yes/rqh/5/susc/dvc/xpc/Eghovf3BOnoBAQ%3D%3D/expire/1772518204/sparams/expire,ei,ip,itag,playlist_type,hfr,source,id,requiressl,acao,ctier,rqh,susc,xpc/sig/AJfQdSswRQIgRoPEStFPhNnq4Aj8y6xW0NJgPGXrhF5WPzwLjCOZFV0CIQCIHggtKC_bBprLGrCYyfX8zOND61o-oVklxhriyBQa6w%3D%3D/file/index.m3u8]]&gt;
                            &lt;/MediaFile&gt;
                            &lt;MediaFile id=&quot;GDFP&quot; delivery=&quot;streaming&quot; width=&quot;0&quot; height=&quot;0&quot; type=&quot;application/dash+xml&quot; scalable=&quot;true&quot; maintainAspectRatio=&quot;true&quot;&gt;
                                &lt;![CDATA[https://redirector.gvt1.com/api/manifest/dash/id/f1be9c477e89fd68/itag/0/source/dclk_video_ads/acao/yes/cpn/mlIQ5E2Gs3QA6tpy/ctier/L/ei/vEfFZ_XUEOjzxtYP6cPR-Ag/hfr/all/ip/0.0.0.0/keepalive/yes/playlist_type/DVR/requiressl/yes/rqh/2/susc/dvc/xpc/Eghovf3BOnoBAQ%3D%3D/expire/1772518204/sparams/expire,ei,ip,source,id,requiressl,acao,ctier,itag,playlist_type,hfr,rqh,susc,xpc/sig/AJfQdSswRgIhAMmcYYFxxLXTrM5zNdEwWmm6WA-_0Bfma9nP5cvW4g5IAiEAnEiMYVW1aH7a2FwakxhLZMc1h3xjcylbEVMkrU2Ld6A%3D/file/index.mpd]]&gt;
                            &lt;/MediaFile&gt;
                        &lt;/MediaFiles&gt;
                    &lt;/Linear&gt;
                &lt;/Creative&gt;
                &lt;Creative id=&quot;138381056765&quot; sequence=&quot;1&quot;&gt;
                    &lt;CompanionAds&gt;
                        &lt;Companion id=&quot;138381056765&quot; width=&quot;300&quot; height=&quot;250&quot;&gt;
                            &lt;StaticResource creativeType=&quot;image/png&quot;&gt;
                                &lt;![CDATA[https://pagead2.googlesyndication.com/simgad/4446644594546952943]]&gt;
                            &lt;/StaticResource&gt;
                            &lt;TrackingEvents&gt;
                                &lt;Tracking event=&quot;creativeView&quot;&gt;
                                    &lt;![CDATA[https://securepubads.g.doubleclick.net/pcs/view?xai=AKAOjsuao5ekbjM2zLiSAPRtwIO4r28ebLhK0oHQCeq8PRC6ilykfhoQwjN_mBKKxv3GESoiMAyWuyBMUu9J-n7oKgj0T_PfnUNk9_MpJNQzk8fjbTNjDHpn3W_Z6w_OqTA6SP8aUfLh7xsNQqT-lTftox6-UbtyaHEZAiVR5wjhyIXMJdK-zGYI-rZfxSJXvMSHcIjt_sEbqhKii7bF6bFqcSNkjr6Zw0gIhUlRbmJKHVtGF03Va2065Gq3ycmQGrzHVzVR5TowoEZdSAkVn6yqO3q8q5ij8W5mg-JJ0NhMv57N6p32T5_Nl0O4eEsiaZdetmv5t9q9OfjY-GINI0B_gtgN8Zxta5znzglRppKvuB1i7h9Oh7DpcEQ9hy-dlltmMeFCMQ&amp;sai=AMfl-YRpODJPWdGPrheAk7xTPQej35ghTOVQk5hzOEFuU8ORmfFzFGkC-lbNYn0Kagri0HjoOA0pINntaB7s&amp;sig=Cg0ArKJSzJlhrTbsnoZXEAE&amp;uach_m=%5BUACH%5D&amp;adurl=]]&gt;
                                &lt;/Tracking&gt;
                            &lt;/TrackingEvents&gt;
                            &lt;CompanionClickThrough&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pcs/click?xai=AKAOjsuthEWAe1Smef6QzZ-POU5Aqep9HBow0hTvm3vVOX2VEFDILdMlR5CGySEGDIZ6jsGSWJCSaDEyZ_8xOOB_OEqQ_QKbI0ubzpAeEdlSn8xOnzQ7L99JwmISLtyTjyf3pSBjPXe_vu0-EtAl6wEOMZfb_yDWftiWyMMmuhI_1P4kXzqDn_C5na6KIUMjl6VROjK8Bk8ri00GjYaktRN_8sDUUXh41lQl5M-TwVn-kOK_s2xkLTQoM82RcdIwjNciv_DDTkhWa6eOx4eYCkDHS9gG1XqWiL5Zq2kgNSSwiQjmNn2jMq_-DoCAyxbH5zYJHBAtGdoEermj6YpA9GYDaaUzjzDYmupOE6_X-z4m8IGdXF0I9fLH&amp;sai=AMfl-YQb7ed-zHyN5148ytSYYIAX4D-_1ohPo-9rL9TkbCHt3QZmnHjFkv0AMizN5sRJyVOMTtFtFAjjU6It&amp;sig=Cg0ArKJSzAg-MAtSKXKr&amp;fbs_aeid=%5Bgw_fbsaeid%5D&amp;adurl=https://googleads.github.io/googleads-ima-html5/vsi/]]&gt;
                            &lt;/CompanionClickThrough&gt;
                        &lt;/Companion&gt;
                        &lt;Companion id=&quot;138381188849&quot; width=&quot;728&quot; height=&quot;90&quot;&gt;
                            &lt;StaticResource creativeType=&quot;image/png&quot;&gt;
                                &lt;![CDATA[https://pagead2.googlesyndication.com/simgad/7802555171787573026]]&gt;
                            &lt;/StaticResource&gt;
                            &lt;TrackingEvents&gt;
                                &lt;Tracking event=&quot;creativeView&quot;&gt;
                                    &lt;![CDATA[https://securepubads.g.doubleclick.net/pcs/view?xai=AKAOjstEckACAaplbe5PcG9vWvuyXN3C-8NTLzOmF3j1HUqnt4wDzuLwHYgA42L6hVtGnwUCh39vNRABvKQDGMbcoGhuCzwmZufu9OI9_iBBPfv_NxzXSWa0uuVqK2qw8jAivB9JuNM8FQ8UZfCMU8Zp2HRUc4uVlqljlNVkRykrK4vyY6i9QcsPjDV6vvFSbIklfHtsxTP5hy38U0K-MGWkdK0-DzbSSF13ZmOOGEo9ywiwODbcGhiktZ6QoAOSNRcyIU5PcuEcP7Y1U4d5h8wHons5nCIHGMcSMBfxm-wiTe3O5f0gXBCNkBz5FrPWqFSbmH28pAf5mZwqv22DC0OvzuhWk0xjsBxU8wzRkJ3cXxBvQg1ARl5PgCLmvcAi2T35sUTKrg&amp;sai=AMfl-YTqJhmeL5ZtNuYq_zKrpWARMyaQUMK_ySpT7_df3ujvUP0RCml7iJT3rnuu0xLY4lHfKLypGjKj0aSJ&amp;sig=Cg0ArKJSzNSDocVDG5c0EAE&amp;uach_m=%5BUACH%5D&amp;adurl=]]&gt;
                                &lt;/Tracking&gt;
                            &lt;/TrackingEvents&gt;
                            &lt;CompanionClickThrough&gt;
                                &lt;![CDATA[https://pubads.g.doubleclick.net/pcs/click?xai=AKAOjssj2sihfOu9Y6h7w_AHywQZkWpKokDBJYd_BstVSFq_RF4ENyB7PeOz0VLsNAhIWgsDgkk-4koyB52jujPR0ENVfBEPTc1QJfmXOI8ajR_QO7wy8w52DUPf9gIbKXQ9qgJpv59J6HFDcYsqmF_ETRs2KlxgVmnLfpsCtGl2mD-3bU6aTcGhPVg3NemVQuD-NVEEDDprN9xdStnI9OG3SoNhp5we9rfuj-eUqZpat1jvO9_AZWt-QzIN5mt8uT80PL5PiarfOLXmOeSbi8UrZm3vx0hlQGE6ETWixssS7NdBb5aMn5CSj-L0InhUCaCfojE8uq0KXSnL-VEmmbc0VN4XQcd-rHGIXyGRGXbEhLhLgKSjQHz1&amp;sai=AMfl-YR67u2tNJ5uKV9l2j7FFPo3h6mS5JAmfAAGIstUGkhVlNzL8czuN20OgKJBefCzQGS-YtXfIjSvuizx&amp;sig=Cg0ArKJSzKBxstETBEhd&amp;fbs_aeid=%5Bgw_fbsaeid%5D&amp;adurl=https://googleads.github.io/googleads-ima-html5/vsi/]]&gt;
                            &lt;/CompanionClickThrough&gt;
                        &lt;/Companion&gt;
                    &lt;/CompanionAds&gt;
                &lt;/Creative&gt;
            &lt;/Creatives&gt;
            &lt;Extensions&gt;
                &lt;Extension type=&quot;waterfall&quot; fallback_index=&quot;0&quot;/&gt;
                &lt;Extension type=&quot;geo&quot;&gt;
                    &lt;Country&gt;US&lt;/Country&gt;
                    &lt;Bandwidth&gt;0&lt;/Bandwidth&gt;
                &lt;/Extension&gt;
                &lt;Extension type=&quot;metrics&quot;&gt;
                    &lt;FeEventId&gt;vEfFZ5OaD82TqMwPksOHiAI&lt;/FeEventId&gt;
                    &lt;AdEventId&gt;CIqy4Y6g7YsDFfkzigMdSTAVjw&lt;/AdEventId&gt;
                &lt;/Extension&gt;
                &lt;Extension type=&quot;ShowAdTracking&quot;&gt;
                    &lt;CustomTracking&gt;
                        &lt;Tracking event=&quot;show_ad&quot;&gt;
                            &lt;![CDATA[https://securepubads.g.doubleclick.net/pcs/view?xai=AKAOjssAiSzjze4MIXJv4LFIsYoKSZWRavWIPBGH8wn2se_JCg7hu--K2fkUWGhl4vRlbWJ8c9CPrju_HbtKSwvg2foGwkXYxlxTcud2ZgpUDD6McdzqB7lfQNsgiPQl6mhjmw0rs270kGAEvBQ7RyVhm75QzNqS9Vk5gSPyp-VRyycyKXAbOtW55hStsWWuVLvP3kOz_d_O1T5DBRUDpn73h2CQktZN3yu1Iao6vssr-YNQECE1Jjw2t-DChn606gOEwJ_6T9KkhUyiqhCaK0fioKZtwDUxmAMsqqS-gWf1IdpVCKS4oLCYCsHb58KM1kW7Mf0YXWi8Km9IBup17fyp_GtVi6dU4MPT2S0TGPRJ6JkNW1sHL_NGSxF9iJF4DAqnsQxNKTRy&amp;sai=AMfl-YSvhgIqRD3UPHZtxKiIQpeQsGsqNORrDrJ8jHCxOgXa66fXnfldNc4eqEy1iitA2Q99b9VXos86ylnz&amp;sig=Cg0ArKJSzDb8aOfyJ3hzEAE&amp;uach_m=%5BUACH%5D&amp;adurl=]]&gt;
                        &lt;/Tracking&gt;
                    &lt;/CustomTracking&gt;
                &lt;/Extension&gt;
                &lt;Extension type=&quot;video_ad_loaded&quot;&gt;
                    &lt;CustomTracking&gt;
                        &lt;Tracking event=&quot;loaded&quot;&gt;
                            &lt;![CDATA[https://pubads.g.doubleclick.net/pagead/interaction/?ai=Bb1xrvEfFZ4rkD_nnqMwPyeDU-AiD6qWVRgAAABABII64hW84AViLgsbBgwRgya6ZjeykgBC6AQo3Mjh4OTBfeG1syAEFwAIC4AIA6gInLzIxNzc1NzQ0OTIzL2V4dGVybmFsL3NpbmdsZV9hZF9zYW1wbGVz-ALw0R6AAwGQA6QDmAOkA6gDAeAEAdIFBhCPpcSJFpAGAaAGI6gHuL6xAqgHmgaoB5fFsQKoB_PRG6gHltgbqAeqm7ECqAfgvbECqAf_nrECqAffn7ECqAf4wrECqAf7wrEC2AcB4AcB0ggnCIBhEAEYnQEyAooCOguAQIDAgICAgKiAAki9_cE6WLKK4Y6g7YsD2AgCgAoFmAsBqg0CVVPqDRMI49nhjqDtiwMV-TOKAx1JMBWP0BUB-BYBgBcB&amp;sigh=lglNGT79knY&amp;label=video_ad_loaded]]&gt;
                        &lt;/Tracking&gt;
                    &lt;/CustomTracking&gt;
                &lt;/Extension&gt;
                &lt;Extension type=&quot;esp&quot;&gt;
                    &lt;EspLibrary LibraryName=&quot;uidapi.com&quot; LibraryUrl=&quot;&quot;/&gt;
                    &lt;EspLibrary LibraryName=&quot;euid.eu&quot; LibraryUrl=&quot;&quot;/&gt;
                    &lt;EspLibrary LibraryName=&quot;liveramp.com&quot; LibraryUrl=&quot;&quot;/&gt;
                    &lt;EspLibrary LibraryName=&quot;esp.criteo.com&quot; LibraryUrl=&quot;&quot;/&gt;
                    &lt;EspLibrary LibraryName=&quot;liveintent.com&quot; LibraryUrl=&quot;&quot;/&gt;
                    &lt;EspLibrary LibraryName=&quot;liveintent.triplelift.com&quot; LibraryUrl=&quot;&quot;/&gt;
                &lt;/Extension&gt;
                &lt;Extension type=&quot;IconClickFallbackImages&quot;&gt;
                    &lt;IconClickFallbackImages program=&quot;GoogleWhyThisAd&quot;&gt;
                        &lt;IconClickFallbackImage width=&quot;640&quot; height=&quot;226&quot;&gt;
                            &lt;AltText&gt;Why this ad? This ad is based on: * General factors like the app you&amp;#39;re using, the time of day, or your approximate location. You can update your options for ads in this device&amp;#39;s settings.&lt;/AltText&gt;
                            &lt;StaticResource creativeType=&quot;image/png&quot;&gt;
                                &lt;![CDATA[https://imasdk.googleapis.com/formats/wta/contextual_bks.png?wp=ca-pub-9939518381636264]]&gt;
                            &lt;/StaticResource&gt;
                        &lt;/IconClickFallbackImage&gt;
                    &lt;/IconClickFallbackImages&gt;
                &lt;/Extension&gt;
                &lt;Extension type=&quot;companion_about_this_ad&quot;&gt;
                    &lt;Icon program=&quot;GoogleWhyThisAd&quot; width=&quot;18&quot; height=&quot;18&quot; xPosition=&quot;right&quot; yPosition=&quot;bottom&quot;&gt;
                        &lt;StaticResource creativeType=&quot;image/png&quot;&gt;
                            &lt;![CDATA[https://imasdk.googleapis.com/formats/wta/help_outline_white_24dp_with_3px_trbl_padding.png?wp=ca-pub-9939518381636264]]&gt;
                        &lt;/StaticResource&gt;
                        &lt;IconClicks&gt;
                            &lt;IconClickThrough&gt;
                                &lt;![CDATA[https://adssettings.google.com/whythisad?source=display&amp;reasons=AU2hxZ1eaRIVJcuysAZTqzQCx99ssRbh6h1XZwHFS6AWxyEXS-IvQerLka-DOWAc02QeJbTASw-tMNnijtvfuRE4Vr_-GG_K-t1XbdWn7tgjdPoskoPbGEiWClDXdxuZAGMEAmVxCHGDEim692Kd9Lc1-zXKB6ie0D7wbJsBMLOmMS-9hF18kwSAcb3D_6FesNI3cceqrP7sQI75qAL7vuPmzLfbsMRKQNGyq-jVY72QrIotgydJ_tjmKwSJhSU9rCbRQJgTDOBhK-SKPEcgFuJUil03SXlQPhiqWB73qpA13iBPrLvHPhK82C7rD_Z99CQrpMo4Rj9e9RdZG7tzqIOUMxjLoNpNe_4IUClOnGqpwa68g88FWAVMiT66NQ2iqJO0QaHBxDEzPgGY9OeyvAHMu0SZqbgiIISW1hjKQPp2etvvyh4bf5rZZC95F2OtFG7hQm1XQ7JFdb68wdz0cQrnK9_uNjFT836Q848TUUsE6-77bGjDuxvznftLDMrkw7fCyqhZwmKvWs2913cAaaSe_z8ulRHi_Oc-5jov68qYRo472B42Wf4AN8jE6WjkC5696pXg8PGqPTh0v4dIazcwJ5Ib1yCSpsrKx0UXZoSNwGEHGNBs-zVAnFn93BrAe2PfzzRT7HJTVmNbzZ6RHACn-Pde4vOsA5rXG00WV2PwjnKZVJ80kCJuMvazSHNa9-kalng8H-IQjhmJSjAx0n9BxFieKV4S4Tq2vbHdnomAxICwgaumq77xIFMO-TWQ-J-ZT-HWefia2MrIwWYeFCuGUemo6ispgPn_gSKD9WffKMash0ZV1f1aTqkv3G_C6NXJdMJ0mic9V9Rj-QU4eGS5cpyupWzCQOTY8x5znizJCN1ISOYsCVzx61KEtMS3eaytZeZgxKRQvpr0B27Zl9R14lGO7cP8EHiXKB29Y7HO&amp;opi=122715837]]&gt;
                            &lt;/IconClickThrough&gt;
                            &lt;IconClickFallbackImages&gt;
                                &lt;IconClickFallbackImage width=&quot;640&quot; height=&quot;226&quot;&gt;
                                    &lt;AltText&gt;Why this ad? This ad is based on: * General factors like the app you&#39;re using, the time of day, or your approximate location. You can update your options for ads in this device&#39;s settings.&lt;/AltText&gt;
                                    &lt;StaticResource creativeType=&quot;image/png&quot;&gt;
                                        &lt;![CDATA[https://imasdk.googleapis.com/formats/wta/contextual_bks.png?wp=ca-pub-9939518381636264]]&gt;
                                    &lt;/StaticResource&gt;
                                &lt;/IconClickFallbackImage&gt;
                            &lt;/IconClickFallbackImages&gt;
                        &lt;/IconClicks&gt;
                    &lt;/Icon&gt;
                &lt;/Extension&gt;
            &lt;/Extensions&gt;
        &lt;/InLine&gt;
    &lt;/Ad&gt;
&lt;/VAST&gt;</code></pre><br>

<h3 id="원본-영상--광고-응답되는지-확인">원본 영상 + 광고 응답되는지 확인</h3>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediatailor/home?region=ap-northeast-2#/config/">AWS MediaTailor</a> 에서 방금 생성한 구성에 진입해보자.</p>
<p>아래에서 HLS 재생 접두사를 복사하고,</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/09365b45-2027-4bda-adaf-31c57e416a36/image.png" alt=""></p>
<p>MediaPackage 를 테스트한 곳인 
<a href="https://hlsjs.video-dev.org/demo/?src=">https://hlsjs.video-dev.org/demo/?src=</a>
에서 뒤에 복사한 URL 과 파일명을 넣어서 웹으로 진입해보자.</p>
<p>ex)
<a href="https://hlsjs.video-dev.org/demo/?src=https://abc.mediatailor.ap-northeast-2.amazonaws.com/v1/master/abc/AdCampaign1/index.m3u8">https://hlsjs.video-dev.org/demo/?src=https://abc.mediatailor.ap-northeast-2.amazonaws.com/v1/master/abc/AdCampaign1/index.m3u8</a></p>
<p>내 원본 영상은 8초짜리였는데, 구글 광고 결정 서버에서 준 10초짜리 Preroll 광고가 붙어 18초짜리 영상이 됐음을 확인 가능하다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e424e6b0-355c-45c3-8380-183c2bfdcac7/image.png" alt=""></p>
<br>


<h3 id="mediatailor--cloudfront-연동">MediaTailor + CloudFront 연동</h3>
<p>위에서 원본 영상 응답용 CloudFront 를 재활용해서
원본 + 광고 영상도 응답할 수 있게 해보자.</p>
<p>과정을 먼저 요약하자면 아래와 같다.</p>
<ul>
<li>원본 영상은 MediaPackage 에서 가져오도록 한다.</li>
<li>원본 + 광고 영상은 MediaTailor 에서 가져오도록 한다.</li>
<li>두 요청의 API endpoint 패턴이 달라, 해당 패턴에 따라 다른 영상 도메인으로 요청</li>
</ul>
<p><a href="https://us-east-1.console.aws.amazon.com/cloudfront/v4/home?region=ap-northeast-2#/distributions">AWS CloudFront</a> -&gt; 위에서 만든 CloudFront 서비스 진입 -&gt; Origin(원본) 탭 -&gt; 원본 생성 진입</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/792ed270-9570-4d52-97d7-feb2787cf745/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dcc56a8a-7fe7-40f7-a88e-95b0e506152c/image.png" alt=""></p>
<p><strong><code>Origin Domain</code></strong> : 
원본 영상 응답용 CloudFront 에서는 Orgin Domain 을 MediaPackage 의 domain 을 넣었지만, 
여기서는 MediaTailor 의 도메인을 넣어야 한다.</p>
<p><a href="https://ap-northeast-2.console.aws.amazon.com/mediatailor/home?region=ap-northeast-2#/config/">AWS MediaTailor</a> 로 돌아가서 만든 구성의 도메인을 가져오자. <code>https://abc.mediatailor.ap-northeast-2.amazonaws.com</code> 과 같은 값을 가져와서 넣으면 된다.</p>
<p><strong><code>이름</code></strong> : 광고용 CloudFront 라는 것을 식별할 수 있게 적절하게 설정</p>
<br>

<p>Origin(원본)이 추가되었으면, API endpoint 패턴에 따른 Origin 분기를 쳐야한다.
<code>동작(Behavior)</code> 탭에 진입해 동작 생성을 해보자. </p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8e132d1d-2cd6-44b8-8885-907f4a313582/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/fa56a688-1b94-4327-a8a0-fa345a1449a2/image.png" alt=""></p>
<p><strong><code>경로 패턴</code></strong> : /v1/*
MediaTailor 는 /v1/master/... 와 같은 endpoint 를 가지는 것을 위에서 확인했다. 그래서 도메인 바로 뒤에오는 /v1 을 인식하게 한다.
<strong><code>원본 및 원본 그룹</code></strong> : 위에서 생성한 MediaTailor 선택
MediaPackage 가 아님에 주의하자.
<strong><code>캐시 정책</code></strong> : CachingOptimized
나중에 고도화해 개인에 따라 광고를 다르게 하려면 캐싱 정책을 다르게 할 수 있지만, 여기서는 우선 광고 1개만 요청할 것이기 때문에 기본 캐싱 정책을 사용한다.</p>
<p>동작(Behavior)이 만들어졌으면, 일반 탭에 진입해 도메인을 복사한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ed02f326-a0ed-493a-8e95-fe635da8c300/image.png" alt=""></p>
<p>도메인만 가지고는 뭘 할 수 없으므로, 아래 세 가지를 조합해서 호출하면 된다.</p>
<ul>
<li>광고용 CloudFront 도메인</li>
<li>MediaTailor Endpoint</li>
<li>파일명</li>
</ul>
<p>MediaTailor 의 Endpoint 는 방금 위에서 HLS 재생 접두사의 도메인만 제거한 부분을 넣으면 되고, 
파일명은 MediaPackage 의 가장 마지막 파일명을 참고하자. </p>
<p>ex) 최종적으로 광고 + 원본 영상을 요청할 URL :
<a href="https://abc.cloudfront.net/v1/master/abc/AdCampaign1/index.m3u8">https://abc.cloudfront.net/v1/master/abc/AdCampaign1/index.m3u8</a></p>
<p>이 역시 정상 송출 확인을 위해 <a href="https://hlsjs.video-dev.org/demo/?src=">https://hlsjs.video-dev.org/demo/?src=</a>  뒤에 위 URL 을 넣어 테스트해보자.</p>
<p>이제 MediaTailor 에게 직접 요청할 필요 없이 컨텐츠가 캐싱되는 CloudFront 으로부터도 영상을 송출할 수 있게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/121da23c-4788-4831-bbe3-bbc33560aa8c/image.png" alt=""></p>
<br>
<br>

<hr>
<br>
<br>



<h2 id="network-및-manifest-분석">Network 및 Manifest 분석</h2>
<br>

<p>이 과정을 거쳤지만 실제 hls.js 클라이언트에서 어떻게 요청을 보내고,
응답받는지 분석해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/cfaeef8b-4e9d-435f-95bc-d9a9c2dc763c/image.png" alt=""></p>
<h3 id="1-최초-cloudfront-manifest-요청">1. 최초 CloudFront manifest 요청</h3>
<p><a href="https://000.cloudfront.net/v1/master/111/AdCampaign1/index.m3u8">https://000.cloudfront.net/v1/master/111/AdCampaign1/index.m3u8</a> 으로 요청해 최초 CloudFront 가 index.m3u8 manifest 파일을 응답한다.
manifest 내부 구조는 아래와 같다.</p>
<p>영상의 메타데이터 정보와 
./../../manifest/111/AdCampaign1/222/0.m3u8 이라는 상대경로의 manifest 파일 위치를 가리킨다.</p>
<pre><code>#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:CODECS=&quot;avc1.640032,mp4a.40.2&quot;,AVERAGE-BANDWIDTH=197170,RESOLUTION=426x240,FRAME-RATE=15.0,BANDWIDTH=105684480
../../../manifest/111/AdCampaign1/222/0.m3u8
</code></pre><br>

<h3 id="2-1번-응답의-상대-경로의-manifest-재요청">2. 1번 응답의 상대 경로의 manifest 재요청</h3>
<p><a href="https://000.cloudfront.net/v1/manifest/111/AdCampaign1/222/0.m3u8">https://000.cloudfront.net/v1/manifest/111/AdCampaign1/222/0.m3u8</a>
으로 클라이언트는 manifest 파일을 다시 요청한다.</p>
<p>이번 응답은 아래와 같다.
위와는 달리 여러 정보들이 포함되어있다.</p>
<ul>
<li><code>#EXT-X-PLAYLIST-TYPE:VOD</code> -&gt; VOD 라고 명시</li>
<li><code>#EXT-X-TARGETDURATION:10</code> -&gt; 각 segment 의 최대 재생시간이 10초</li>
<li><code>#EXT-X-MEDIA-SEQUENCE:0</code> -&gt; 첫 번째 segment의 순번</li>
<li><code>#EXT-X-DISCONTINUITY-SEQUENCE:0</code> -&gt; 불연속성 시퀀스. 
일반적으로 해상도 또는 프레임 변경과 같은 스트림 속성의 변경을 나타냄.
여기서는 광고 삽입 전후에 불연속성을 나타냄.</li>
<li><code>#EXTINF:2.0,</code> -&gt; 다음 세그먼트의 재생 시간을 2초 단위로 지정</li>
<li><code>../../../../segment/111/AdCampaign1/222/0/0</code> -&gt; segment 파일의 상대 경로. .m3u8 파일의 위치를 기준으로 해석.</li>
<li>반복..</li>
<li><code>#EXT-X-DISCONTINUITY</code> -&gt; 다음 segment에 불연속성이 있음을 나타냄. 여기선 광고 이후 콘텐츠 재생으로 전환될 때 불연속성을 나타냄.</li>
<li><code>#EXTINF:8.533,</code> -&gt; 다음 segment의 재생 시간이 8.533초</li>
<li><code>https://333.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/444/555/666/777/index_1_0.ts</code> -&gt; segment 파일의 절대경로</li>
</ul>
<pre><code>#EXTM3U
#EXT-X-VERSION:3
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DISCONTINUITY
#EXTINF:2.0,
../../../../segment/111/AdCampaign1/222/0/0
#EXTINF:2.0,
../../../../segment/111/AdCampaign1/222/0/1
#EXTINF:2.0,
../../../../segment/111/AdCampaign1/222/0/2
#EXTINF:2.0,
../../../../segment/111/AdCampaign1/222/0/3
#EXTINF:2.0,
../../../../segment/111/AdCampaign1/222/0/4
#EXT-X-DISCONTINUITY
#EXTINF:8.533,
https://333.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/444/555/666/777/index_1_0.ts
#EXT-X-ENDLIST
</code></pre><p>정리하자면 이 manifest 는 광고 먼저 송출 후 콘텐츠가 송출되며,
광고는 5개의 2초 단위 segment 로 나뉘어지고,
콘텐츠는 1개의 8.533초 단위 segment 가 있다는 것을 보여준다.</p>
<br>

<h3 id="3-광고-segment-요청-5회-반복">3. 광고 segment 요청 (5회 반복)</h3>
<p><a href="https://000.cloudfront.net/v1/segment/111/AdCampaign1/222/0/0">https://000.cloudfront.net/v1/segment/111/AdCampaign1/222/0/0</a> 
으로 요청을 보냈으나 301 응답을 받아, 아래로 redirect 된다.</p>
<p><a href="https://segments.mediatailor.ap-northeast-2.amazonaws.com/tm/111/888/asset_240_105_0_00001.ts">https://segments.mediatailor.ap-northeast-2.amazonaws.com/tm/111/888/asset_240_105_0_00001.ts</a></p>
<p>브라우저에 segment 가 load 되어 이제 광고를 송출할 수 있게 된다.</p>
<br>

<h3 id="4-콘텐츠-segment-요청">4. 콘텐츠 segment 요청</h3>
<p><a href="https://333.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/444/555/666/777/index_1_0.ts">https://333.egress.mediapackage-vod.ap-northeast-2.amazonaws.com/out/v1/444/555/666/777/index_1_0.ts</a>
으로 요청해 원본 영상의 segment 가 load 되어 콘텐츠를 송출할 수 있게 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[미니PC 홈서버 구축하기 (2) - 네트워크 설정]]></title>
            <link>https://velog.io/@mud_cookie/%EB%AF%B8%EB%8B%88PC-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-2-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@mud_cookie/%EB%AF%B8%EB%8B%88PC-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-2-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Mon, 27 Jan 2025 07:05:08 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@mud_cookie/%EB%AF%B8%EB%8B%88PC-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-SER8-Ubuntu-24">미니PC 홈서버 구축하기 (1) -(SER8, Ubuntu 24 세팅)</a> 에 이어서 작성한다.</p>
<h2 id="0-아주-간단하게-접속만-되는지-확인">0. 아주 간단하게 접속만 되는지 확인</h2>
<br>

<p>** 현재 내 PC 와 네트워크 구성은 아래와 같다.
나중에 구조를 약간 변경할 예정이지만, 일단 접속만 되는지 확인하고 개념을 정리한 후에 다른 구조로 적용해보자. **</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8eb49804-a05c-4eca-bda9-dd5310ba3305/image.png" alt=""></p>
<p>일단 내 미니PC 에서 띄운 간단한 Springboot 서버를 메인 PC 에서 접속해보자.
그러기 위해선 모뎀에서 빠져나와 미니PC 에 연결된 네트워크의 public(공인) IP 주소를 알아야 한다.
private (사설) IP 로는 접근하지 못한다. 왜냐?
지금 미니PC 와 메인PC 는 같은 공유기로부터 나오지 않았기 때문에 서로 다른 네트워크라고 봐도 무방하므로 내부적으로만 접근 가능한 private IP 로는 접근이 되지 않는다.</p>
<p>미니PC 는 리눅스 환경이므로, 아래 명령어로 public IP 주소를 알 수 있다.</p>
<pre><code class="language-bash">curl ifconfig.me</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e6e91bff-c195-4c46-a990-9d96980d2487/image.png" alt=""></p>
<p>출력된 public IP 를 123.123.123.123 이라고 가정해보자.
그러면 외부에서 해당 IP 에 별다른 보안 설정 없이도 바로 접속이 가능하다.
왜냐? 모뎀은 단순히 LAN 포트를 매핑시켜주는 역할만 하기 때문이다.</p>
<p>그러면 메인 PC 에서 curl 명령어로 미니PC 의 공인IP:포트/endpoint 로 접속이 되는지 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2e0bfa73-4443-43ff-b2bd-7a167dc338bd/image.png" alt=""></p>
<p>접속이 잘 되는 것을 볼 수 있다.</p>
<p>다만 이는 보안은 둘째치고, <strong>한 가지 함정이 숨어있다.</strong>
바로 IP 가 계속 바뀐다는 점인데, 이를 명령어로 확인해보자.</p>
<pre><code class="language-bash">ip a

&gt;&gt;
...
2: enp1s0: ...
    inet 123.123.123.123/24 brd 123.123.123.255 scope global dynamic noprefixroute enp1s0
...</code></pre>
<br>

<p>ip a 명령어의 응답에서 공인 IP 설정이 어떻게 되어있는 지 보면, <code>dynamic</code> 이라는 키워드가 보인다.
이는 내 IP 가 동적으로 계속 변한다는 것을 의미하는데, 이는 내 가정용 인터넷 회선이 자체가 동적으로 할당시켜서 보내준다는 것이다.</p>
<p>이는 DHCP 라고 칭하고, 이를 어떻게 해결할 것인가와 보안적인 부분도 같이 개념을 바로잡고 넘어가보려고 한다.</p>
<p>짧게 요약하자면, 아래 내용을 <code>1. 사전지식</code> 에서 설명하고자 한다.</p>
<ul>
<li>동적 IP 할당은 DDNS 로 해결한다.</li>
<li>DDNS 편의성과 보안을 조금 더 강화하기 위해 모뎀에 직접 연결하는 것이 아닌 공유기에 물리고 포트포워딩을 적용한다.</li>
<li>외부에서 접속 시 프록시 서버 또는 SSH 터널로만 접속할 수 있게 한다.</li>
<li>방화벽을 설정한다.</li>
<li>필요에 따라 특정 클라이언트에서만 접속해야되는 경우 VPN 을 적용할 수 있다.</li>
</ul>
<br>
<br>

<hr>
<br>
<br>


<h2 id="1-사전지식">1. 사전지식</h2>
<h3 id="1-1-dhcp--동적고정-ip">1-1. DHCP / 동적,고정 IP</h3>
<p>내 인터넷 회선은 일반 가정용이다.
속도는 100Mbps 일 뿐더러, 고정 IP 와 같은 서비스는 지원하지 않는다.</p>
<ul>
<li>포스팅과 무관하지만 참고용 인터넷 속도 확인 링크 : <a href="https://fast.com/ko/#">https://fast.com/ko/#</a></li>
</ul>
<p>그러면 동적,고정 IP 는 무엇이면서 DHCP 는 무엇이냐?</p>
<p>DHCP(Dynamic Host Configuration Protocol)는 네트워크 내 장치가 자동으로 IP 주소를 할당받도록 하는 프로토콜이다. 
가정용 인터넷 회선에서는 ISP(인터넷 서비스 제공업체)가 동적 IP를 제공하며, 이는 일정 시간이 지나면 변경된다.</p>
<p>동적 IP(Dynamic IP): ISP가 사용 가능한 IP를 자동으로 할당하며 일정 시간이 지나면 변경됨. 
일반 가정용 인터넷에서 기본적으로 제공됨.</p>
<p>고정 IP(Static IP): 변하지 않는 IP 주소로, 서버 운영 등에 필요하지만 일반적으로 유료 서비스로 제공됨.</p>
<ul>
<li>참고 : 가장 저렴한 Static IP 할당 요금도 월 3만원이 넘어간다.
가격 참고 링크 : <a href="http://kt-center.co.kr/new2/sp_internet/sp_04.php">http://kt-center.co.kr/new2/sp_internet/sp_04.php</a></li>
</ul>
<p>동적 IP 문제를 해결하기 위해 <strong>DDNS(Dynamic DNS)</strong> 를 사용하면 변경된 IP를 자동으로 도메인과 연결하여 접속할 수 있다.</p>
<br>

<h3 id="1-2-dns--ddns--도메인">1-2. DNS / DDNS / 도메인</h3>
<ul>
<li>DNS(Domain Name System): 사람이 이해하기 쉬운 도메인 이름(예: google.com)을 IP 주소(예: 142.250.74.14)로 변환하는 시스템.</li>
<li>DDNS(Dynamic DNS): 동적으로 변경되는 IP를 특정 도메인에 자동으로 매핑하는 서비스. 유/무료 서비스가 있고, 가정용 서버 운영 시 유용함.</li>
<li>도메인(Domain): 특정 IP 주소에 대한 별칭. 예를 들어 myhome.ddns.net을 설정하면, 동적 IP 변경에도 같은 도메인으로 접속 가능.</li>
<li>네임서버(Nameserver) : DNS/DDNS 를 제공하는 서버. 일반적으로는 큰 규모를 가진 플랫폼에서 제공한다. 
해당 네임서버는 주기적으로 도메인과 공인IP를 매핑하는 작업을 진행하고 라우팅한다.</li>
</ul>
<br>

<h3 id="1-3-fiber-모뎀--공유기-역할">1-3. Fiber 모뎀 / 공유기 역할</h3>
<ul>
<li>Fiber 모뎀: ISP에서 제공하는 광(광섬유) 인터넷 신호를 변환하여 사용자의 네트워크로 전달하는 장치. 
대부분의 모뎀은 단순히 ISP와 사용자를 연결하는 역할만 함.</li>
<li>공유기: 여러 기기가 인터넷을 공유할 수 있도록 하는 네트워크 장치.
내부 네트워크에서 장치 간 통신을 가능하게 하고, 방화벽 및 포트포워딩 등의 기능을 제공함.</li>
</ul>
<br>

<h3 id="1-4-포트포워딩--게이트웨이">1-4. 포트포워딩 / 게이트웨이</h3>
<ul>
<li>포트포워딩: 외부에서 특정 포트로 접속하면 내부 네트워크의 특정 장치로 트래픽을 전달하는 기능. 가정에서 서버를 운영할 때 필수적으로 설정해야 함.
예: 공유기에서 123.123.123.123:8080으로 요청이 들어오면 내부 네트워크 192.168.1.100:8080으로 전달하도록 설정.</li>
<li>게이트웨이: 네트워크 간 트래픽을 중계하는 장치. 일반적으로 공유기가 게이트웨이 역할을 하며, 외부 네트워크(인터넷)와 내부 네트워크를 연결하는 역할을 수행함.</li>
</ul>
<br>

<h3 id="1-5-프록시-서버">1-5. 프록시 서버</h3>
<p>프록시 서버는 클라이언트와 인터넷 사이에 위치하여 요청을 대신 처리하는 서버이다.</p>
<ul>
<li>리버스 프록시: 외부 클라이언트가 내부 서버에 직접 접근하지 못하도록 하면서 요청을 중계하는 역할. 보안 강화 및 로드 밸런싱 등에 사용됨.</li>
<li>포워드 프록시: 내부 클라이언트가 특정 웹사이트에 접근할 때 중계 역할을 수행함.</li>
</ul>
<p>홈 서버 운영 시, 일반적으로 Nginx 를 띄워 가장 앞단에서 SSL 인증 및 로드밸런싱을 통해 리버스 프록시의 역할을 한다.</p>
<p>또한 내 서버에서 직접적으로 리버스 프록시를 운영하고 싶지 않다면, 
Cloudflare 와 같은 외부의 프록시 서버를 사용하는 것도 방법이다.
Cloudflare 자체적으로 DDos, 해킹 방어를 지원하므로 내가 운용할 PC에 웹 서버를 운영한다면 좋은 선택이지만, 해당 PC에 웹 서버를 띄우지 않는다면 굳이 사용할 필요가 없다.
만약 적용한다면 Cloudflare 프록시 서버 외의 다른 IP 로부터의 접근은 막아둘 필요가 있다.</p>
<br>

<blockquote>
<p>다만 나의 경우엔 미니PC 에 웹 서버를 띄우지 않고 MySQL, Kafka, Redis, Elasticsearch 등 DB 성격을 지닌 것들만 운영할 것이기 때문에  Cloudflare 와 같은 외부 프록시 서버는 사용하지 않을 예정이다.
위 툴들은 외부 서버에서 접근이 가능해야 되는 상황이라, 접근하는 것은 SSH 터널링으로만 접속할 수 있게 구성하려한다.
<br>
웹 서버는 외부 Oracle Cloud 서버에 띄우고, 그 안에는 Nginx 를 통한 SSL 인증과 로드밸런싱 및 Cloudflare 프록시도 적용하는 것을 나중에 작성할 예정이다.</p>
</blockquote>
<br>

<p>아래는 내가 앞으로 구성할 환경을 간단하게 요약한 그림이다.
미니PC 는 DB 나 형상관리 용도로만 사용할 것이지만, 
다른 개발자가 접근할 것을 생각해 VPN 은 제외하고 포트는 열어두는 대신 SSH 접속만 허용할 예정이다.</p>
<p>화살표 이외 다른 방식의 접근은 방화벽으로 인해 접근이 실패될 것이다.
(해상도가 깨지니 <code>새 탭에서 이미지 열기</code> 로 확인하자.)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f3b5e4ec-f307-4873-8619-d02ab6d3592a/image.png" alt=""></p>
<br>

<h3 id="1-6-ssh-터널링">1-6. SSH 터널링</h3>
<p>SSH 터널링은 보안이 취약한 네트워크에서 암호화된 터널을 통해 데이터를 전송하는 기술이다. 원격 서버에 안전하게 접속하거나 프록시 역할을 수행할 때 유용하다.</p>
<p>예제: 로컬에서 원격 서버의 8080 포트에 접근하는 SSH 터널 설정</p>
<pre><code class="language-bash"># 원격 서버의 SSH 포트가 2222일 때, 
ssh -L 8080:localhost:8080 -p 2222 user@remote-server</code></pre>
<p>위 명령어를 실행하면, 로컬 PC에서 localhost:8080으로 접근할 때 원격 서버의 8080 포트로 연결된다.</p>
<p>앞서 설명한 것과 같이, 외부에서 미니PC 에 접근할 때 SSH 로만 접근하도록 설정할 예정이다.</p>
<br>

<h3 id="1-7-vpn">1-7. VPN</h3>
<p>VPN(Virtual Private Network)은 공용 네트워크에서 안전하게 내부 네트워크에 접근할 수 있도록 하는 기술이다.</p>
<p>WireGuard, OpenVPN 등을 사용하면 외부에서 내부 네트워크로 안전하게 접속할 수 있다.</p>
<p>VPN을 사용하면 공인 IP 없이도 내부 네트워크의 장치에 접속 가능하고, 네트워크 보안을 강화할 수 있다.</p>
<p>다만 크리티컬한 단점은 VPN 이라는 벽을 거쳐오므로 통신속도가 느려지고, 뿐만 아니라 클라이언트도 VPN 을 설정해야 진입할 수 있다.</p>
<p><strong>그러므로 내부적으로 특정 클라이언트에만 통신을 허용하고 싶을 때 적용하는 것이 일반적이다.</strong></p>
<br>

<h3 id="1-8-방화벽">1-8. 방화벽</h3>
<p>방화벽(Firewall)은 네트워크 보안을 위해 특정 트래픽을 차단하거나 허용하는 역할을 한다.</p>
<p>리눅스에서 ufw(Uncomplicated Firewall) 설정 예제
ufw 는 iptables 를 편하게 사용하기 위한 것으로 Ubuntu, Debian 기반에서 동작하니 참고하자.</p>
<pre><code>sudo ufw default deny incoming  # 모든 인입 차단 (기본값)
sudo ufw allow 22/tcp  # 22포트(기본은 SSH) 허용
sudo ufw allow 80/tcp  # HTTP 허용
sudo ufw allow 443/tcp # HTTPS 허용
sudo ufw allow from 192.168.1.100 to any port 8080 # 192.168.1.100 에서만 8080포트 허용
sudo ufw allow from 192.168.1.200 to any port 8080
192.168.1.200 에서도 8080포트 허용
sudo ufw allow from 192.168.1.0/24 to any port 80  # 192.168.1.0~255 범위에서만 80포트(HTTP) 허용


sudo ufw enable         # 방화벽 활성화
sudo ufw status         # 방화벽 설정 확인


# 만약 방화벽 설정을 삭제하고 싶다면
# 방화벽 설정을 넘버링으로 확인 
sudo ufw status numbered

# 출력 예시
Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 8080/tcp                   ALLOW IN    Anywhere                  
[ 2] 8080/tcp (v6)              ALLOW IN    Anywhere (v6)     


# [ 2] 삭제
sudo ufw delete 2</code></pre><p>방화벽을 설정하여 불필요한 포트와 IP는 차단하고, 필수적인 것만 개방해야 보안이 강화된다.</p>
<br>
<br>

<hr>
<br>
<br>

<h2 id="2-네트워크-설정">2. 네트워크 설정</h2>
<br>

<p>앞서 보여준 전체 도식에서 Cloudflare, Oracle Cloud 관련 설정은 제외하고 미니PC 설정에만 집중한다.</p>
<p>우선 모뎀에 직접 연결하는 것 말고, 미니PC 를 공유기에 연결해보자.</p>
<h3 id="2-0-미니pc-lan-을-모뎀이-아닌-공유기에-연결">2-0. 미니PC LAN 을 모뎀이 아닌 공유기에 연결</h3>
<p>변경한 LAN 구성은 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a05e9bd4-88a8-49d7-807b-76d67c5f5a50/image.png" alt=""></p>
<p>이제는 메인PC, 미니PC, Wi-fi 모두 공유기에서 관리하므로 
미니PC 에서 연 포트를 메인PC, Wi-fi 에서 접속이 가능해야 한다.</p>
<p>미니PC 에서 private IP 정보를 확인해보자.</p>
<pre><code>...
2: enp1s0: ...
    inet 172.30.1.26/24 brd 172.30.1.255 scope global dynamic noprefixroute enp1s0
...</code></pre><p>172.30.1.26 이라는 내부 IP 정보가 확인되었다.
아래와 같이 미니PC 에서 8080 포트에 swagger endpoint 를 띄워 로컬에서 접속이 가능한 것을 보인다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/66103d88-b5a4-484f-8a34-7d37642331dd/image.png" alt=""></p>
<p>해당 private IP 와 8080 포트를 메인PC, Wi-fi 로 연결한 핸드폰에서도 확인해보자.</p>
<p>동일 공유기에서 나온 메인PC 에서 private IP 에 접속</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/cc5d50e1-878a-4be4-86d0-b16fb43f76bc/image.png" alt=""></p>
<p>모바일에서 공유기의 Wi-fi 를 사용해 private IP 에 접속</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/319b5fb3-3319-40c5-9fe5-78d94518ebfe/image.png" alt=""></p>
<p>같은 공유기 내에서는 private IP 로 통신됨을 알았으니, 
이제는 외부에서도 접속할 수 있게 DDNS 와 포트포워딩을 설정해보자.</p>
<br>

<h3 id="2-1-포트포워딩-설정">2-1. 포트포워딩 설정</h3>
<p>KT 공유기를 기준으로 설명한다.
일반적으로 KT 공유기의 내부 접속 주소는 172.30.1.254 이다.</p>
<p>접속하면 아래와 같은 화면이 노출되는데, 여기서 기본값은</p>
<ul>
<li>ktuser / homehub 
으로 접속하면 된다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/efd4ce7f-ed0b-4e43-8b42-1e0c1e3fbd19/image.png" alt=""></p>
<p>로그인하면 아래와 같이 비밀번호를 변경해야만 앞으로도 계속 사용할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/005f05db-6203-4c91-9b28-018d6be31b01/image.png" alt=""></p>
<p>변경이 완료되면 아래와 같은 공유기 기본 정보가 나타난다.
여기서 IP 할당방식이 DHCP 이고, 임대시간이 3600초 라고 표시되는 것을 보아 1시간마다 동적 IP 로 할당됨을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ec3673b8-e910-49d7-8388-df8791792c86/image.png" alt=""></p>
<p>장치설정 &gt; 트래픽 관리에서 포트포워딩을 테스트해보자.</p>
<blockquote>
<p>포트포워딩 설정을 하지 않은 상태에서는 동적 public IP 로 접속해도 모든 포트를 막고 있으므로 접속이 되지 않는다.</p>
</blockquote>
<p>아래 캡처에 설명한 예시는 외부에서 8080 포트로 접속할 때, 
172.30.1.26 이라는 private IP 주소를 가진 네트워크에 8080 포트로 매핑하겠다는 뜻이다. </p>
<blockquote>
<p>보안 상 외부 포트를 내부 포트와 다르게 설정하는 것이 안전하다.
특히 SSH 와 같은 기본 22포트를 그대로 사용하면 외부에서 접속이 그만큼 쉬워진다.
여기서는 테스트용이므로 일단 진행해본다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/165ef71d-93c5-4df3-a707-508304d1094b/image.png" alt=""></p>
<p>그러면 이제 외부에서는 동적 public IP 로 <code>private IP:8080</code> 에 접속할 수 있게 되었다.</p>
<pre><code># 접속 대상 PC 에서 동적 public IP 확인
curl ifconfig.me</code></pre><p>위에서 얻은 동적 public IP 를 가지고 모바일에서 wi-fi 없이 접속이 가능한지 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/cfd042c0-32dc-4b30-8281-a00e75dc9a6d/image.png" alt=""></p>
<p>접속이 잘 되는 것을 볼 수 있다.
그러면 특정 포트를 열어야지만 접속할 수 있는 것을 알았으니, 
1시간마다 동적으로 IP 가 계속 변하는 불편함을 DDNS 로 해결해보자.</p>
<br>

<h3 id="2-2-ddns-설정">2-2. DDNS 설정</h3>
<blockquote>
<p>KT 공유기에서 지원하는 DDNS no-ip, dyndns 네임서버만 지원한다.
현재 no-ip 계정이 핸드폰이 바뀌어 2차인증이 막혔고, dyndns 는 무료 서비스가 중단되었다.
그래서 duckdns 를 사용해 dns 를 매핑하고, 일정 주기마다 특정 스크립트를 실행해 내 공인 ip 를 duckdns 에 매핑하는 과정을 거치고자 한다.</p>
</blockquote>
<h4 id="duckdns-도메인-생성-및-연결하기">duckdns 도메인 생성 및 연결하기</h4>
<p>아래 duckdns 에 진입하고 로그인한다.
<a href="https://www.duckdns.org/">https://www.duckdns.org/</a></p>
<p>이후 recapcha 를 클릭하면 아래와 같은 화면이 출력된다.
sub domain 란에 만들고 싶은 서브도메인을 작성한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c4f8abaf-b8e8-4c92-ae19-185e6cf3a30f/image.png" alt=""></p>
<p>그러면 아래와 같이 도메인이 특정 public ip 와 매핑됐음을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/bcc4b50b-3092-46d9-824c-0740c272ed9a/image.png" alt=""></p>
<p>그러면 해당 도메인으로 접속해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/4d14e7ec-0eba-488d-b878-c108ca627bc9/image.png" alt=""></p>
<p>접속은 잘 됐지만, 내 public IP 가 언제 바뀔지 모른다.
이를 해결하기 위해 일정 주기마다 특정 스크립트를 실행해보자.</p>
<h4 id="일정-주기마다-duckdns-에-ip-갱신하기">일정 주기마다 duckdns 에 IP 갱신하기</h4>
<p><a href="https://www.duckdns.org/install.jsp">https://www.duckdns.org/install.jsp</a>
에서 linux cron 을 선택하고 설명을 따라가면 된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/38d51129-3d1f-498c-9f9c-74279d50853a/image.png" alt=""></p>
<p>잠깐 설명하자면 아래와 같다.</p>
<ol>
<li>linux 에서 cron 과 curl 이 사용할 수 있는 환경이어야 한다.</li>
<li>아래명령어로 duck.sh 파일을 생성한다.<pre><code>mkdir ~/duckdns
cd ~/duckdns
vi duck.sh</code></pre></li>
<li>아래 명령어에 아까 확인한 토큰값과 서브도메인을 넣는다.
여기서 ip 에 값을 넣지 않아도, 자동으로 duckdns 서버에서 감지한다고 설명이 나와있다.<pre><code>echo url=&quot;https://www.duckdns.org/update?domains=${도메인}&amp;token=${토큰}&amp;ip=&quot; | curl -k -o ~/duckdns/duck.log -K -</code></pre></li>
<li>duck.sh 에 실행권한을 준다.<pre><code>chmod 700 duck.sh</code></pre></li>
<li>crontab 을 등록한다. (crontab = 일정 주기마다 스크립트를 실행하는 도구)<pre><code># crontab 진입
crontab -e
</code></pre></li>
</ol>
<h1 id="아래-내용은-1분마다-ducksh-를-수행하겠다는-의미">아래 내용은 1분마다 duck.sh 를 수행하겠다는 의미</h1>
<p>*/1 * * * * ~/duckdns/duck.sh &gt;/dev/null 2&gt;&amp;1</p>
<pre><code>
주기는 각자 알아서 설정하면 되고, 나의 경우엔 1분마다 설정했다.
log 파일은 ~/duckdns/duck.log 에 저장되므로 여기서 확인 가능하다.

&lt;br&gt;
&lt;br&gt;


### 2-3. 방화벽 설정

기본적으로 외부로부터의 접속을 공유기가 전부 차단하고, 포트포워딩으로 특정 포트만 열면서 매핑하도록 해준다.

그럼에도 방화벽을 설정해주어야 하는 이유가 뭘까?

`Zero Trust` 라는 용어를 살펴보자.
기본적으로 보안이라는 것은 외부로부터의 무분별한 접근을 막는 것을 기본 원칙으로 한다.
하지만 다음과 같은 상황을 상정해보자.
- 내부 네트워크의 PC2가 공격을 받아 PC1 에 접근하려고 할 경우
- 내부 네트워크에 타인이 물리적으로 접속하는 경우

이런 경우에는 내부 네트워크라고 해도 안전한 상황이 아니다.
그래서 `내부도 신뢰하지 않는다.` 라는 의미에서 Zero Trust 라는 보안 용어가 나오게 된 이유이다.

또한 포트포워딩은 특정 IP 에 대해서만 허용하는 옵션이 없다.
방화벽은 Port 뿐 아니라 IP 도 제한할 수 있으므로, 방화벽 설정까지 하는 것은 옳은 선택이라고 할 수 있다.

그러면 방화벽 설정을 해보자.
우선 Docker 를 사용하는 환경이라면, 아래 옵션을 적용해 방화벽 설정이 올바르게 적용되도록 해야 한다.
</code></pre><h1 id="docker-daemon-설정-진입">docker daemon 설정 진입</h1>
<p>sudo vi /etc/docker/daemon.json</p>
<h1 id="아래-내용-삽입">아래 내용 삽입</h1>
<p>{
  &quot;iptables&quot;: false
}</p>
<h1 id="docker-재기동">docker 재기동</h1>
<p>sudo systemctl restart docker</p>
<pre><code>
`1-8. 방화벽` 에서 간략하게 설명했지만 명령어를 다시 확인해보자.
방화벽 설정이 변경될때마다 sudo ufw enable 또는 sudo ufw reload 를 적용해야 한다.
</code></pre><p>sudo ufw default deny incoming  # 모든 인입 차단 (기본값)
sudo ufw allow 22/tcp  # 22포트(기본은 SSH) 허용
sudo ufw allow 80/tcp  # HTTP 허용
sudo ufw allow 443/tcp # HTTPS 허용
sudo ufw allow from 192.168.1.100 to any port 8080 # 192.168.1.100 에서만 8080포트 허용
sudo ufw allow from 192.168.1.200 to any port 8080
192.168.1.200 에서도 8080포트 허용
sudo ufw allow from 192.168.1.0/24 to any port 80  # 192.168.1.0~255 범위에서만 80포트(HTTP) 허용</p>
<p>sudo ufw enable         # 방화벽 활성화
sudo ufw status         # 방화벽 설정 확인</p>
<h1 id="만약-방화벽-설정을-삭제하고-싶다면">만약 방화벽 설정을 삭제하고 싶다면</h1>
<h1 id="방화벽-설정을-넘버링으로-확인">방화벽 설정을 넘버링으로 확인</h1>
<p>sudo ufw status numbered</p>
<h1 id="출력-예시">출력 예시</h1>
<p>Status: active</p>
<pre><code> To                         Action      From
 --                         ------      ----</code></pre><p>[ 1] 8080/tcp                   ALLOW IN    Anywhere<br>[ 2] 8080/tcp (v6)              ALLOW IN    Anywhere (v6)     </p>
<h1 id="-2-삭제">[ 2] 삭제</h1>
<p>sudo ufw delete 2</p>
<pre><code>
&lt;br&gt;
&lt;br&gt;


### 2-4. SSH 접속

SSH 는 기본적으로 22포트를 사용한다.
외부에서 접근하려면 22번 포트를 사용할 수 있게 열어주어야 하며, 접속을 당하는 PC에서는 SSH 접속 툴을 설치해주어야 한다.
</code></pre><h1 id="ssh-서버-설치-여부-확인">SSH 서버 설치 여부 확인</h1>
<p>systemctl status ssh</p>
<h1 id="openssh-설치-ubuntu-기준">openssh 설치 (Ubuntu 기준)</h1>
<p>sudo apt update &amp;&amp; sudo apt install openssh-server -y</p>
<h1 id="openssh-시작-및-활성화">openssh 시작 및 활성화</h1>
<p>sudo systemctl enable ssh
sudo systemctl start ssh</p>
<h1 id="실행-확인">실행 확인</h1>
<p>systemctl status ssh</p>
<pre><code>
&gt; 내 과정을 따라왔다면 이후 포트포워딩으로 외부 특정 포트 접근을 22포트로 매핑 및 open 하고, 방화벽도 open 해야 한다.
여기서 주의할점은 외부에서 SSH 접속 시 22포트를 그대로 사용하면 취약하므로 외부 접근 포트를 22말고 다른 포트로 매핑하자.

그러면 MobaXterm, Putty 등으로 SSH 접속이 가능해진다.

여기서 끝나도 되지만, 조금만 더 강화해보자.
외부에서 SSH 접속 시 아래 정보가 필요하다.
- IP (도메인)
- SSH 외부 접속 포트
- Linux 계정
- 위 계정의 비밀번호

여기서 타인에게 위 정보들을 알려주었는데, 그 타인에게 접속을 그만하게 하고 싶다면 비밀번호를 변경하는 방법이 있다. 
그런데 비밀번호를 매번 변경하는 것은 서버를 관리하는 입장에서 힘들 뿐 아니라, 보통 비밀번호는 다른 권한과 연계되어 있는 경우가 많으므로 외부에 노출되는 것은 보안 관점에서 좋은 방법은 아니다.

그래서, 비밀번호 대신 key 기반으로 접속하게 해보자.
나는 비밀번호를 유지하고, 나를 포함해 타인 모두 key 로만 SSH 접속 가능해야 한다.

기본적으로 로컬PC 에서 공개/비밀키를 생성한 후에,
로컬에는 비밀키를 사용해 접속하고 / 원격PC 에는 공개키를 등록해 해당 비밀키로 매칭되는 공개키로 인증되는 구조이다.

![](https://velog.velcdn.com/images/mud_cookie/post/8fb20758-77be-4ef1-89b1-133ec4bc8349/image.png)

</code></pre><h1 id="로컬pc-에서-key-생성-windows-cmd-linux-terminal-모두-가능">로컬PC 에서 key 생성 (Windows cmd, Linux Terminal 모두 가능)</h1>
<p>ssh-keygen -t rsa -b 4096 -C &quot;<a href="mailto:your_email@example.com">your_email@example.com</a>&quot;</p>
<h1 id="생성된-id_rsapub-이라는-공개키를-원격-서버의-sshauthorized_keys-에-추가">생성된 id_rsa.pub 이라는 공개키를 원격 서버의 ~/.ssh/authorized_keys 에 추가</h1>
<h1 id="이-작업은-원격-서버-관리자가-수행한다">이 작업은 원격 서버 관리자가 수행한다.</h1>
<h1 id="인증-권한-수정">인증 권한 수정</h1>
<p>chmod 600 ~/.ssh/authorized_keys</p>
<pre><code>
이후 아래 사항이 적용되지 않았다면 설정한다.
(2, 3번째 옵션은 선택이지만 하는 것을 권장한다.)
</code></pre><p>sudo vi /etc/ssh/sshd_config</p>
<p>PubkeyAuthentication yes   # 공개 키 인증 활성화
PasswordAuthentication no  # 비밀번호 로그인 비활성화 (보안 강화)
PermitRootLogin no         # root 계정으로 SSH 접속 방지</p>
<h1 id="수정-후-ssh-서버-재시작">수정 후 ssh 서버 재시작</h1>
<p>sudo systemctl restart sshd</p>
<pre><code>
&gt; 참고 : 
ssh-keygen -t rsa -b 4096 -C &quot;your_email@example.com&quot;
명령어에서 &quot;&quot; 안의 부분은 공개키의 끝부분에 누구의 공개키인지 식별하기 위함이다. 
PC 마다 모두 다른 비밀키로 인증해야되고, 그에 매칭되는 공개키는 서버에서 관리되어야 한다.
그래서 관리자는 ~/.ssh/authorized_keys 안의 식별자를 통해 접근 권한을 관리할 수 있게 되는 것이다.

&lt;br&gt;
&lt;br&gt;

### 2-5. duckdns 응용하기 - 서브의 서브도메인 적용 (Nginx 리버스 프록시, SSL 인증 적용)

일반적인 URI 의 형태는 아래와 같다.
`서브도메인.2차도메인.최상위도메인/endpoint`
여기서 /endpoint 앞부분까지를 host 라고 칭한다.

일반적인 네임서버에서 도메인을 임대할 때에는
`2차도메인.최상위도메인` 을 임대하고, 그 앞에 서브도메인은 내가 마음대로 지정할 수 있는 `와일드카드` 형태이다.

그런데 이번에 duckdns 에 DNS 를 요청할 때에는 구조가 조금 특이했다.
서브도메인만 내가 지정해 임대할 수 있고, 2차도메인과 최상위도메인은 duckdns.org 로 고정되어 있었다.

여기서 예상되는 문제점이 무엇이 있을까?
일반적인 host를 `second.com` 으로 IP1 에 매핑하고,
duckdns 에 요청한 DNS 가 `sub.duckdns.org` 으로 IP2 에 매핑했다고 가정하자.

일반적인 host 는 
- `sub1.second.com` 을 IP1:8081
- `sub2.second.com` 을 IP1:8082 
으로 계속해 Nginx 와 같은 리버스 프록시에서 포트에 따른 프록시를 거쳐 매핑할 수 있다.
`와일드카드 도메인` 에서는 second.com 앞에 어떠한 서브도메인이 있든 IP1 으로 매핑하는 형태이기 때문이다.

다만 duckdns 는 이미 `sub` 라고 고정되어 있어 내 서버에 특정 포트를 추가로 등록하고 싶으면 어떻게 해야되지 라는 생각이 들 수 있다.

**이 역시 duckdns 도 `sub.duckdns.org` 앞에 어떠한 `서브의 서브도메인`이 붙었더라도 IP2 로 매핑하게 된다.**
- sub-sub1.sub.duckdns.org 를 IP2:8081
- sub-sub2.sub.duckdns.org 를 IP2:8082 
으로 계속해 늘릴 수 있다는 얘기이다. 물론 이는 Nginx 와 같은 리버스 프록시를 사용할 때의 얘기이다.

그러면 지금까지는 `서브도메인명.duckdns.org:8080/$endpoint`
이라는 URI 로 접속한 것 대신 
`서브-서브도메인명.duckdns.org:8080/$endpoint` 으로 접속할 수 있게 Nginx 리버스 프록시를 활용해보자.


#### 2-5-1. Nginx 설치

웹 서버를 띄우지 않을거라면 SSL 인증을 굳이 할 필요는 없다.
이 경우엔 Nginx 를 서브-서브도메인의 매핑 용도로만 사용하면 되니, SSL 인증을 하지 않을 사람은 2-4-1 만 하고 넘어가자.

리눅스 서버에 Nginx 를 설치해보자.
[Oracle Cloud 프리티어 A1 인스턴스 생성 + 고정 public IP 생성](https://velog.io/@mud_cookie/Oracle-Cloud-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%83%9D%EC%84%B1)
하고
[도메인 구매, DNS 적용, SSL 인증, 신규 도메인 추가(가비아, Oracle Cloud)](https://velog.io/@mud_cookie/%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B5%AC%EB%A7%A4-DNS-SSL-%EC%9D%B8%EC%A6%9D-%EA%B0%80%EB%B9%84%EC%95%84-Oracle-Cloud)
에서의 nginx 설정을 다시 한 번 복습해보자.

&gt; 80 포트를 예시로 들 예저잉니 포트포워딩, 방화벽에서 열어둔 상태여야 한다.
</code></pre><p>sudo apt install nginx -y</p>
<p>sudo systemctl start nginx
sudo systemctl enable nginx  # 부팅 시 자동 시작 설정</p>
<p>sudo systemctl status nginx  # Nginx 상태 확인
sudo lsof -i :80             # 80 포트에서 수신 대기 중인 프로세스 확인</p>
<p>curl -I <a href="http://localhost">http://localhost</a>     # 로컬 접속 확인</p>
<h1 id="알아보기-쉽게-도메인명conf-로-작성하자">알아보기 쉽게 도메인명.conf 로 작성하자.</h1>
<p>sudo vi /etc/nginx/sites-available/test.conf</p>
<h1 id="testconf-에-아래-내용-삽입">test.conf 에 아래 내용 삽입</h1>
<p>server {
    # HTTP 요청을 HTTPS로 리디렉션
    listen 80;
    server_name test.서브도메인명.duckdns.org;</p>
<pre><code>location / {
    # test.서브도메인명.duckdns.org 를 localhost:8080 으로 매핑
    proxy_pass http://localhost:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}</code></pre><p>}</p>
<h1 id="심볼릭링크를-걸어야-적용된다">심볼릭링크를 걸어야 적용된다.</h1>
<p>sudo ln -s /etc/nginx/sites-available/test.conf /etc/nginx/sites-enabled/</p>
<h1 id="nginx-설정-확인">nginx 설정 확인</h1>
<p>sudo nginx -t</p>
<h1 id="nginx-재기동">nginx 재기동</h1>
<p>sudo systemctl restart nginx</p>
<pre><code>
이제 브라우저로 접속해보자.

`서브도메인명.duckdns.org` 로 접속한 모습

![](https://velog.velcdn.com/images/mud_cookie/post/b1b93498-b4fa-40d6-9a9a-472a74b011b1/image.png)

`서브도메인명.duckdns.org:8080/$endpoint` 로 접속한 모습

![](https://velog.velcdn.com/images/mud_cookie/post/08c827b8-c2ac-49f1-b94c-b095fd1a973c/image.png)

`test.서브도메인명.duckdns.org/$endpoint` 로 접속한 모습

![](https://velog.velcdn.com/images/mud_cookie/post/cb2eddfb-6920-4b64-842f-679afe0719c3/image.png)


이 때 Nginx 를 왜 적용했는지 이해해야 한다.
그냥 `서브도메인명.duckdns.org:8080/$endpoint` 으로 접속하면 되지 않냐? 라고 할 수 있다.

하지만 아래와 같은 이유로 리버스 프록시인 Nginx 를 적용했다.
- 8080 과 같은 포트번호를 외부에 직접 노출하는 것은 보안 취약점이다.
- 서비스가 하나 추가/삭제될 때마다 포트포워딩, 방화벽 등을 일일이 수정하는 것은 관리 포인트가 늘어나는 부분이다.
- 당연하지만 서브-서브도메인명을 적용하면 내가 띄울 서버의 별칭을 지정 가능하다.

그러면 Nginx 는 브라우저의 http(80), https(443) 요청만 처리하냐? -&gt; 아니다. DB, 메시지 브로커, 기타 네트워크 서비스에 대한 로드 밸런싱이 가능하다.

&lt;br&gt;

#### 2-4-2. Let&#39;s Encrypt SSL 인증 (선택)

SSL 인증은 
[도메인 구매, DNS 적용, SSL 인증, 신규 도메인 추가(가비아, Oracle Cloud)](https://velog.io/@mud_cookie/%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B5%AC%EB%A7%A4-DNS-SSL-%EC%9D%B8%EC%A6%9D-%EA%B0%80%EB%B9%84%EC%95%84-Oracle-Cloud)
에서 이미 다뤄봤지만 나의 경우엔 관리해야될 서버가 여러개이므로 다시 진행해보자.

Let&#39;s Encrypt 는 무료이면서 간편하게 설정이 가능하다.
다만 90일마다 갱신이 필요하므로 이는 아래에서 확인해보자.
</code></pre><h1 id="lets-encrypt-를-적용하기-위한-certbot-설치">Let&#39;s Encrypt 를 적용하기 위한 certbot 설치</h1>
<h1 id="nginx-에-적용할-것이므로-apache-버전을-설치하지-말-것">nginx 에 적용할 것이므로 apache 버전을 설치하지 말 것.</h1>
<p>sudo apt update
sudo apt install certbot python3-certbot-nginx -y</p>
<h1 id="certbot-에-도메인-반영">certbot 에 도메인 반영</h1>
<h1 id="nginx를-잠시-중지-기존에-동작하던-ssl-인증이-있다면-잠시-멈추고-하자">Nginx를 잠시 중지 (기존에 동작하던 SSL 인증이 있다면 잠시 멈추고 하자)</h1>
<p>sudo systemctl stop nginx</p>
<h1 id="standalone-모드로-인증서-발급-이메일-입력과-동의-과정이-있다">standalone 모드로 인증서 발급, 이메일 입력과 동의 과정이 있다.</h1>
<p>sudo certbot certonly --standalone -d 도메인명나열</p>
<h1 id="nginx-다시-시작">Nginx 다시 시작</h1>
<p>sudo systemctl start nginx</p>
<h1 id="nginx-설정-파일-업데이트">nginx 설정 파일 업데이트</h1>
<p>sudo vi /etc/nginx/sites-available/도메인명.conf</p>
<h1 id="도메인명conf-에-아래-내용-입력">도메인명.conf 에 아래 내용 입력</h1>
<h1 id="이미-nginx-에서-자동으로-업데이트했지만-다시-작성한다">이미 nginx 에서 자동으로 업데이트했지만 다시 작성한다.</h1>
<h1 id="etcnginxsites-available도메인명conf">/etc/nginx/sites-available/도메인명.conf</h1>
<p>server {
    # HTTP 요청을 HTTPS로 리디렉션
    listen 80;
    server_name 도메인명;
    return 301 https://$host$request_uri;
}</p>
<p>server {
    # HTTPS 설정
    listen 443 ssl;
    server_name 도메인명나열;</p>
<pre><code># SSL 인증서 파일 경로
ssl_certificate /etc/letsencrypt/live/도메인명/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/도메인명/privkey.pem;

# SSL 설정 추가
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers HIGH:!aNULL:!MD5;

# 루트 디렉토리와 인덱스 파일 설정
root /var/www/html;
index index.html index.htm;

# 8080(예시) 포트로 프록시 설정
location / {
    proxy_pass http://localhost:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}</code></pre><p>}</p>
<pre><code>
설정이 완료되었으면 nginx 를 재기동하자.
심볼릭을 위에서 설정했다면 굳이 다시 할 필요는 없다.
&lt;br&gt;
</code></pre><h1 id="심볼릭-링크로-설정-파일을-활성화">심볼릭 링크로 설정 파일을 활성화</h1>
<p>sudo ln -s /etc/nginx/sites-available/도메인명.conf /etc/nginx/sites-enabled/</p>
<h1 id="nginx-설정-테스트">nginx 설정 테스트</h1>
<p>sudo nginx -t</p>
<h1 id="nginx-재시작">nginx 재시작</h1>
<p>sudo systemctl restart nginx\</p>
<pre><code>
이제 `http://test.서브도메인명.duckdns.org/endpoint`  으로 입력했을 때,
https://... 으로 redirect 되면서 더 이상 `안전하지 않음` 표시가 뜨지 않는 것이 확인된다.

![](https://velog.velcdn.com/images/mud_cookie/post/6698f060-f254-46fd-b1f0-467ec008261d/image.png)

&lt;br&gt;

한 가지 설정이 더 남았다.
아까 Let&#39;s Encrypt 는 90일마다 갱신을 해줘야 한다고 얘기했는데, 이를 매번 확인할 수 없으므로 crontab 을 사용해 자동화해보자.
</code></pre><h1 id="crontab-설정-진입">crontab 설정 진입</h1>
<p>crontab -e</p>
<h1 id="아래-명령어를-적용한다">아래 명령어를 적용한다.</h1>
<p>0 3 * * * /usr/bin/certbot renew --quiet &amp;&amp; /bin/systemctl reload nginx</p>
<h1 id="crontab-적용-확인">crontab 적용 확인</h1>
<p>crontab -l</p>
<p>```</p>
<p>이 과정을 이해했다면,
도메인을 더 추가하고 SSL 인증을 받는 과정을 간단하게 처리할 수 있을 것이다.</p>
<br>
<br>

<hr>
<br>
<br>


<h1 id="주의사항">주의사항</h1>
<p>위에서 우리는 단계적으로 차근차근 접근하다보니 8080 포트를 공유기 포트포워딩, 방화벽 설정에서 열어두었다.</p>
<p>하지만 마지막 단계에서 우리는 브라우저에서 8080 포트로 직접 접속하는 것이 아닌, 
도메인명으로 접속한 뒤에 Nginx 리버스 프록시에서 8080 포트로 매핑하는 방식으로 변경했다.</p>
<p><strong>그래서, 초반에 열어두었던 8080 포트를 공유기 포트포워딩, 방화벽에서 다시 닫아야한다.</strong></p>
<p>물론 리버스 프록시 서버가 따로 있다면 말이 달라지지만, 우리는 그럴 돈과 자원이 없지 않은가..</p>
<p>필요한 부분만 설명했다고는 하지만 내용이 다소 길어 두서가 없을 수 있다.
설명이 부족했다면 얼마든 피드백을 주기 바란다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[미니PC 홈서버 구축하기 (1) -(SER8, Ubuntu 24 세팅)]]></title>
            <link>https://velog.io/@mud_cookie/%EB%AF%B8%EB%8B%88PC-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-SER8-Ubuntu-24</link>
            <guid>https://velog.io/@mud_cookie/%EB%AF%B8%EB%8B%88PC-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-SER8-Ubuntu-24</guid>
            <pubDate>Sat, 25 Jan 2025 02:18:30 GMT</pubDate>
            <description><![CDATA[<h2 id="1-홈서버를-구축하게-된-이유">1. 홈서버를 구축하게 된 이유</h2>
<br>

<p>최근 퍼블릭 클라우드의 비용이 기하급수적으로 증가하면서, <br> 
개인 개발자들은 직접 서버를 운영하는 것이 경제적인 대안이 되고 있다. <br>
특히, 개발자라면 개인적인 프로젝트를 실험하고 운영할 수 있는 환경이 필요하다.  <br></p>
<p>요즘은 미니PC 가 대중화되면서 적은 비용, 전력과 공간만으로도 사무용 PC 혹은 홈서버를 구축할 수 있게 되었다. <br>
그래서 미니PC 에 리눅스를 설치하고, 24시간 구동되는 홈서버로 사용하는 과정을 설명하고자 한다. <br></p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="2-준비물">2. 준비물</h2>
<p>미니 PC는 N100 과 같은 너무 낮은 사양 말고,  <br>
적어도 Kafka, Elasticsearch, MySQL 등을 안정적으로 구동할 수 있는 서버를 구동하고자 <br>
Ryzen 8745Hs CPU 베어본 + DDR5 5600 32GB * 2 + NVME SSD PCIE 4.0 1TB 스펙으로 구성하였다. <br>
예산은 약 70만원 중반 정도가 소요됐다.</p>
<p>** 왜 8745HS 를 구매했는지 **</p>
<ul>
<li>2025.01 기준 현존하는 미니PC 중 저전력이면서 가장 높은 CPU는 8845HS 라고 할 수 있다.</li>
<li>그 이상은 내 홈서버에 오버스펙일 뿐더러, 특히 나는 LPDDR5 은 무슨 DDR5 만으로도 충분하다.
그리고 8코어를 넘어가는 CPU 를 24시간 구동하는데 전력 소모와 발열을 과연 미니PC 가 잘 잡을 수 있을지도 의문이다.</li>
<li>그래서 8코어 이하의 CPU 중 8845HS 는 8745HS 에 비해 성능 차이가 거의 나지 않고, NPU 기능이 탑재되어있다.</li>
<li>나는 현재의 NPU 기능은 거의 쓸모 없다고 판단해, 조금 더 저렴한 8745HS 로 구매했다.</li>
<li>8745HS vs 8845HS 비교 : <a href="https://www.cpubenchmark.net/compare/6353vs6086/AMD-Ryzen-7-8745HS-vs-AMD-Ryzen-7-PRO-8845HS">https://www.cpubenchmark.net/compare/6353vs6086/AMD-Ryzen-7-8745HS-vs-AMD-Ryzen-7-PRO-8845HS</a></li>
</ul>
<br>
<br>

<p>** 준비물 및 가격 **</p>
<ul>
<li><strong>미니PC (Beelink SER8)</strong>: Beelink AMD Ryzen 8745HS Barebone
TDP : 45W
Clock : 3.8GHz
Cache : L1 (512KB), L2 (8MB), L3 (16MB)
가격 : 베어본 (SSD, RAM 없는) 기준 40만원에 해외직구
구매 링크 : <a href="https://m.youchen.co.kr/goods/view?no=1008&amp;NaPm=ct%3Dm6d8hyjk%7Cci%3Dcheckout%7Ctr%3Dppc%7Ctrx%3Dnull%7Chk%3D760afd7f55c580e6dd9d7c12fac24896b19267f0">https://m.youchen.co.kr/goods/view?no=1008&amp;NaPm=ct%3Dm6d8hyjk%7Cci%3Dcheckout%7Ctr%3Dppc%7Ctrx%3Dnull%7Chk%3D760afd7f55c580e6dd9d7c12fac24896b19267f0</a>
SSD, RAM 을 끼워파는 경우 내가 원하는 1TB / 32GB * 2 스펙이 없을 뿐더러, 가격도 높고 제조사도 불분명하므로 따로 구매했다.</li>
<li><strong>SSD</strong>: SK Hyniz P41 M.2 NVME 1TB (PCIE 4.0 규격)
읽기 속도 : 7000MB /s
쓰기 속도 : 6500MB /s
가격 : 11만원 해외직구
PCIE 3.0 과는 두 배이상의 읽기/쓰기 속도를 보여주는 대신 CPU, 메인보드 호환성 확인 필수.
구매 링크 : <a href="https://smartstore.naver.com/youchen2019/products/10524997433?NaPm=ct%3Dm6d8hcot%7Cci%3Dcheckout%7Ctr%3Dppc%7Ctrx%3Dnull%7Chk%3D495fa0f519be3d38f24953d322bf23e107d8fc59">https://smartstore.naver.com/youchen2019/products/10524997433?NaPm=ct%3Dm6d8hcot%7Cci%3Dcheckout%7Ctr%3Dppc%7Ctrx%3Dnull%7Chk%3D495fa0f519be3d38f24953d322bf23e107d8fc59</a></li>
<li><strong>RAM</strong>: SAMSUNG DDR5 5600MHz 32GB * 2 (SODIMM, 노트북 규격)
가격 : 개당 10만원 (당근)
RAM 은 잔고장이 잘 나지 않으므로 중고로 구매해도 무방하다. 현재 정가는 개당 15만원 정도 한다.
24시간 구동되는 미니PC 이므로 오버클럭은 하지 않는다.  </li>
<li><strong>Power 어댑터</strong> : Delta ADP-120RHBB 19V 6.32A 120W (외경 5.5mm)
내가 구매한 베어본 제품은 중국 내수용 제품으로 110V 용 어댑터가 동봉되어있었다.
돼지코를 이용할 수 있지만, 아무래도 24시간 돌아가므로 안정성과 호환성을 생각해 어댑터를 따로 구매했다.
일반적으로는 ASUS 어댑터를 구매하지만, ASUS 는 가품이 워낙 많이 풀려 Delta 제품으로 구매했다.
어댑터 구매를 고민한다면 제품과 호환되는 전압, 전류, 외경을 확인하고 구매하자.
가격 : 3만원
구매링크 : <a href="https://smartstore.naver.com/newsmartmall/products/10449924347?NaPm=ct%3Dm6d8j05x%7Cci%3Dcheckout%7Ctr%3Dppc%7Ctrx%3Dnull%7Chk%3D9b4a37e765733e805f64823c355506c35cac46db">https://smartstore.naver.com/newsmartmall/products/10449924347?NaPm=ct%3Dm6d8j05x%7Cci%3Dcheckout%7Ctr%3Dppc%7Ctrx%3Dnull%7Chk%3D9b4a37e765733e805f64823c355506c35cac46db</a></li>
<li><strong>리눅스 설치용 USB 64GB</strong>
Beelink 미니PC 는 기본적으로 해당 기기에서만 사용할 수 있는 Windows 11 설치용 USB 가 동봉되어 온다.
물론 나는 리눅스를 설치할 것이기도하고, 
중국 소프트웨어는 믿지 못하므로 따로 USB 를 구매해 리눅스 ISO 파일을 넣어 설치했다.
가격 : 5천원 (쿠팡)
Ubuntu 24.04 LTS 를 설치하는 데 용량은 8GB 면 충분하다.</li>
</ul>
<br>
<br>

<h3 id="소프트웨어-및-기타">소프트웨어 및 기타</h3>
<ul>
<li><strong>Ubuntu 24.04 LTS ISO 파일</strong>: 최신 LTS 버전 사용 추천</li>
<li><strong>Rufus</strong>: 부팅 가능한 USB 제작을 위한 툴</li>
</ul>
<br>
<br>

<hr>
<br>
<br>

<h2 id="3-미니-pc-조립-및-ubuntu-24041-lts-설치">3. 미니 PC 조립 및 Ubuntu 24.04.1 LTS 설치</h2>
<h3 id="3-1-ssd-ram-장착">3-1. SSD, RAM 장착</h3>
<p>SER8 에 SSD, RAM 을 장착해보자.
RAM 은 아래 사진에는 없지만, SER8 은 노트북용 SODIMM 메모리 규격을 사용해야되는 것을 잊지 말자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a10c347a-a205-4130-8cbd-93720a4b017f/image.png" alt=""></p>
<p>아래 사진은 SER8 의 하단을 분해해 SSD 와 RAM 을 부착한 모습이다.
참고로 SER8 모델은 SSD 부착하는 곳에 자동으로 방열판이 달려있다.
추가적으로 NVME SSD 슬롯 두 개와, SODIMM RAM 슬롯 두개임을 참고하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/0d374e25-dd2d-48da-9249-70d9dbaba0ee/image.png" alt=""></p>
<br>

<h3 id="3-2-ubuntu-부팅디스크-만들기">3-2. Ubuntu 부팅디스크 만들기</h3>
<p>이제는 리눅스를 설치해보자.
먼저 리눅스를 설치할 USB 부팅디스크(8GB 이상)를 준비하고,
<a href="https://ubuntu.com/download/desktop">https://ubuntu.com/download/desktop</a> 서 LTS 버전을 다운받는다.</p>
<p>2025.01 기준 24.04.1 이 최신 LTS 버전이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ff2c1937-d18e-4606-8d08-85581e2af0d6/image.png" alt=""></p>
<p>이제는 해당 파일을 Rufus 를 통해 부팅디스크로 만들어보자.
<a href="https://rufus.ie/ko/#google_vignette">https://rufus.ie/ko/#google_vignette</a> 에서
ISO 파일로 변환 작업을 하는 PC 의 시스템에 따라 적절한 것을 다운받는다.
일반적으로는 rufus-4.6.exe 를 받으면 된다. (x64)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c9d208b1-aaef-4ba3-92b9-00662e2e6fb4/image.png" alt=""></p>
<p>rufus 를 실행하고 나서, </p>
<ol>
<li>장치에는 연결한 USB 를 선택한다.</li>
<li>부팅 선택에는 다운받은 ISO 파일을 선택한다.</li>
<li>이후 [시작] 버튼으로 진행한다.
참고로 해당 USB 는 자동 포맷 후 부팅디스크로 변환되니 참고하자.</li>
</ol>
<br>

<h3 id="3-3-ser8-에-우분투-설치">3-3. SER8 에 우분투 설치</h3>
<p>이제 SER8 에 부팅디스크로 만든 USB 를 연결하고 전원을 켜보자.
Beelink 로고가 보이는 순간 [Del] 키를 연타해 바이오스로 진입하자.</p>
<p>그러면 키보드로 상단 바를 좌우로 넘기면서 [Boot] 탭에 진입하면 아래와 같은 화면이 출력된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c0c46ee4-8e43-4cd8-9772-86075180d281/image.png" alt=""></p>
<p>여기서 Boot Option #1 (부팅 옵션 1순위)에 부팅디스크 USB 를 선택하고,</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/919d0bf2-8df9-46fe-b510-f8012b9e0f2c/image.png" alt=""></p>
<p>다시 상단 메뉴바의 [SAVE &amp; Exit] 탭으로 넘어가 [Save Changes and Exit] 으로 설정을 저장 후 나간다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/664a3f84-04b7-458d-9d25-c3dc9d801ed0/image.png" alt=""></p>
<p>그러면 재부팅이 될텐데, 이때 아래와 같은 화면이 출력된다.
여기서 Ubuntu 를 설치한다는 첫번째 옵션으로 선택후 Enter 를 입력하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/11a3e8b4-8ea8-46f2-b4e7-cc7d7ef16767/image.png" alt=""></p>
<p>그러면 몇 분의 로딩 후 Ubuntu가 설치된다. 
부팅디스크 USB 는 제거하지말고, 설치가 완료되고 나서 부팅디스크를 제거하라는 문구가 나올때까지 기다렸다가 제거하면 된다.</p>
<p>완전히 Ubuntu 가 설치된 후 부팅이 완료되면 Ubuntu 첫 세팅 가이드가 나오는데, 이것까지 스크린샷을 찍어두었지만 실수로 백업을 하지 않는 바람에 삭제됐다.. </p>
<p>별 내용은 없지만 Ubuntu 첫 부팅 시 나오는 세팅 가이드는 다른 글에서 참고하자.</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="4-ubuntu-기본-사용법">4. Ubuntu 기본 사용법</h2>
<p>나의 경우엔 Linux 를 SSH 로만 사용해봤지 실제 OS 를 구축해 GUI 상에서 구동시켜 본 적이 없어 기본적인 것은 짚고 넘어간다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c1a423f3-dba7-46f3-b4f5-47b36de60ef4/image.png" alt=""></p>
<p>Window 키를 누르면 기본적으로 메뉴 진입창에 들어온다.</p>
<ul>
<li>Terminal, Setting 등 키워드로 메뉴 진입에 유용하다.</li>
<li>참고로 Terminal 단축키는 Ctrl + Alt + T </li>
<li>어차피 리눅스를 쓰는 사람들은 웬만한 작업을 모두 Terminal 에서 진행하니 터미널 진입 단축키만 알아도 무방하다.</li>
</ul>
<p>참고로 Windows 의 파일 탐색기와 같은 Terminal 명령어는 아래와 같다.</p>
<pre><code># 뒤에는 디렉토리 경로 및 파일 경로를 적는다.
xdg-open ./</code></pre><p>&lt;Window 키 진입 시&gt;
<img src="https://velog.velcdn.com/images/mud_cookie/post/16e9c66b-a241-458e-bd98-dfb61aa620ec/image.png" alt=""></p>
<p>&lt;Terminal 과 같은 메뉴 진입시 키워드 입력으로 가능&gt;
<img src="https://velog.velcdn.com/images/mud_cookie/post/84a86862-ce6b-440e-a24a-a592714f911a/image.png" alt=""></p>
<p>기본적인 스크린샷은 printscreen 단축키로 가능하다.</p>
<ul>
<li>printscreen : 스크린샷 도구 진입</li>
<li>alt + printscreen : 현재 활성 창 캡처 + 클립보드 복사</li>
<li>shift + printscreen : 전체화면 캡처 + 클립보드 복사</li>
</ul>
<p>** 우분투 다크모드 적용법 **</p>
<p>Settings &gt; Apperance 에서 Dark 로 적용.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f590f965-b466-4617-a868-d39e3cd2ea34/image.png" alt=""></p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="5-스펙성능-확인">5. 스펙/성능 확인</h2>
<h3 id="5-1-cpu-스펙-확인">5-1. CPU 스펙 확인</h3>
<pre><code class="language-bash">lscpu</code></pre>
<p>현재 1개의 CPU 소켓이 있고,
그 1개의 소켓에는 8개의 물리 CPU 코어가 있고,
하이퍼쓰레딩으로 인해 8 * 2 = 16 개의 논리 CPU가 존재함을 볼 수 있다.
그래서 CPU : 16 으로 출력된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/903cdf90-d6e9-4774-ab2f-1aa167ea9bda/image.png" alt=""></p>
<br>

<h3 id="5-2-메모리-확인">5-2. 메모리 확인</h3>
<pre><code>free -h</code></pre><p>total, used, free 를 확인한다.
여기에는 부팅을 위해 하드웨어가 기본적으로 사용하는 메모리는 제외되므로, 64GB 전체가 표시되지 않을 수 있음에 참고하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/adda8861-8161-41e7-bbdb-9ccc5cfc5a3f/image.png" alt=""></p>
<br>

<h3 id="5-3-디스크-확인">5-3. 디스크 확인</h3>
<pre><code>sudo fdisk -l</code></pre><p>여러 파티션들이 나오는데, 제일 용량이 높은 것이 1TB 임을 확인한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1b31925c-d536-463f-a66d-ddbaa280afde/image.png" alt=""></p>
<br>

<h3 id="5-4-geekbench-성능-확인">5-4. Geekbench 성능 확인</h3>
<p>가장 대중성과 신뢰성이 높은 Geekbench 로 테스트해보자.
CPU 에 다양한 백그라운드 프로세스로 부하를 주어
Single / Multi Core 점수를 매기는 툴이다.</p>
<p>아래처럼 설치 후 실행해보자.
약 5분정도가 소요되니 손톱이나 깎으면서 기다리자.</p>
<blockquote>
<p>다른 프로세스는 모두 종료하고 터미널만 열린 상태에서 수행하자.</p>
</blockquote>
<pre><code>wget https://cdn.geekbench.com/Geekbench-6.2.1-Linux.tar.gz
tar -xvf Geekbench-6.2.1-Linux.tar.gz
cd Geekbench-6.2.1-Linux
./geekbench6</code></pre><p>아래 결과를 보면 <a href="https://browser.geekbench.com/v6/cpu/10155002">https://browser.geekbench.com/v6/cpu/10155002</a> 에 내 점수가 기록되었다고 출력된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/395273e9-a483-4ac3-b1ae-b1e859d51a36/image.png" alt=""></p>
<p>Single 코어 점수 : 2550
Multi 코어 점수 : 13489
점이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8241eb28-7b17-4744-bba6-0d97ce1c913d/image.png" alt=""></p>
<p>내가 정말 괜찮은 제품을 뽑았는지는 절대적인 점수만 봐서는 모르니까,
다른 사람들과의 점수를 비교해보자.
Google 에 <code>CPU 명 + Geekbench</code> 를 검색하자.
나의 경우엔 8745HS 여서 아래에서 다른 사람들의 점수를 확인 가능한데,
<a href="https://browser.geekbench.com/search?q=8745HS">https://browser.geekbench.com/search?q=8745HS</a></p>
<p>다른 사람들에 비해 나쁘지 않은 점수가 나온 것을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2429a560-f759-495b-a996-1c150bb53779/image.png" alt=""></p>
<p>멀티코어 점수가 10% ~ 20% 가량 더 높은 것이 보이는데,
일반적으로 SER8 에는 32GB 램이 부착되어 나오지만 나는 64GB 으로 세팅해 조금 더 여유있는 환경이라 그러지 않았을까 추측한다.</p>
<p>혹시 몰라서 다시 한 번 수행해보았다.
<a href="https://browser.geekbench.com/v6/cpu/10155127">https://browser.geekbench.com/v6/cpu/10155127</a></p>
<p>Single 코어 점수 : 2626
Multi 코어 점수 : 13583
으로 이전보다 살짝 더 높게 나왔다. 
평균치보다 더 높게 나오니, 특히 멀티 코어 점수가 8845HS 에 비빌만한 점수가 나와 기분이 살짝 좋긴 하네</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/4d24ef14-73ec-43ae-8943-45b590aeb935/image.png" alt=""></p>
<br>
<br>

<hr>
<br>
<br>

<h2 id="6-linux-세팅">6. Linux 세팅</h2>
<br>

<h3 id="6-1-root-계정-설정">6-1. root 계정 설정</h3>
<p>Linux 최초 설정 시 root 계정부터 세팅해야 한다.</p>
<pre><code>sudo passwd root
... 암호 설정

# root 권한 잘 수행되는지 확인 및 최신 패키지 설치
su
exit
sudo apt update</code></pre><h3 id="6-2-pinta-스크린샷-도구-설치-및-자동화-선택">6-2. Pinta 스크린샷 도구 설치 및 자동화 (선택)</h3>
<p>블로그 포스팅을 위해 스크린샷을 편하게 찍고, 여러가지 편집할 도구가 필요하다.
Linux 에서도 동작하는 pinta 오픈소스를 설치해보자.</p>
<p>ubuntu 24 에서는 apt respoitory 안에 pinta 가 없어 snap 으로 설치한다.</p>
<pre><code>sudo snap install pinta

# pinta 버전 확인
pinta --version</code></pre><p>이제 명령어로 스크린샷을 찍을 수 있게 해보자.
기본 단축키인 prtsrc 조합으로 가능하지만, 그때마다 일일이 pinta 를 열기도 귀찮으므로 우선 명령어로 조금이나마 자동화를 해보자.</p>
<pre><code># 단축키 입력 시 스크린샷을 찍게 해주는 명령어를 위한 gnome 설치
sudo apt install gnome-screenshot

# 스크린샷 테스트
# -a : 선택한 영억
# -w : 활성창
# 위 옵션이 없으면 기본적으로 전체화면 캡처
# -f : 해당 위치에 해당 이름으로 저장
gnome-screenshot -a -f ~/Pictures/Screenshots/test.png

# 명령어 수행 확인 (스크린샷이 저장된 디렉토리 또는 스크린샷 파일 열기)
xdg-open ~/Pictures/Screenshots/
xdg-open ~/Pictures/Screenshots/test.png</code></pre><p>gnome-screenshot 명령어가 잘 수행된다면, 캡처 후 pinta 를 자동으로 여는 스크립트를 만들어보자.</p>
<pre><code># script 전용 디렉토리 생성
mkdir ~/scripts
cd ~/scripts
vi screenshot_pinta.sh

# 이제 vi 편집창에서 아래 스크립트 입력 후 저장
# gnome-screenshot 앞서 설명한 -a -w -f 옵션 중 본인이 원하는 옵션으로 설정한다.
#!/bin/bash
SCREENSHOT_PATH=&quot;$HOME/Pictures/Screenshots/screenshot_$(date +%Y%m%d%H%M%S).png&quot;
gnome-screenshot -w -f &quot;$SCREENSHOT_PATH&quot;
pinta &quot;$SCREENSHOT_PATH&quot; &amp;

# 스크립트 수행 확인
./screenshot_pinta.sh</code></pre><p>아래와 같이 정상적으로 열리는 모습을 보인다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e7aca9a2-7a7e-4f4e-b1a6-71e89d5d8145/image.png" alt=""></p>
<p>이제 Ctrl + Shift + S 키보드 조합을 캡처 후 pinta 편집 창을 열게 해보자.</p>
<p>우선 Settings &gt; Keyboard &gt; View and Customize Shortcuts 으로 키보드 단축키 설정에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b6cbdffc-27ef-4b5b-a46e-7a5f17bc16c5/image.png" alt=""></p>
<p>이후 Custom Shortcuts 에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e6f2908a-e4e9-49fa-9a0f-8863c5b3d049/image.png" alt=""></p>
<p>Add Shortcut 을 진입해 새로운 단축키를 만든다.
나는 pinta Screenshot 이라는 이름의 단축키에 
/home/${username}/scripts/screenshot_gimp.sh 라는 명령어를 수행하도록 했다.
단축키는 Shift + Ctrl + S 으로 지정했다.</p>
<blockquote>
<p>~/ 와 같은 유저별 상대경로는 안되는 것으로 확인되어 절대경로로 지정해야 한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/cec09b5d-c9c8-41ce-a6c5-fe0174122a46/image.png" alt=""></p>
<p>간단한 사용법은 아래와 같다.</p>
<ul>
<li>특정 영역 제거
S -&gt; 마우스 좌클릭 드래그 -&gt; Delete</li>
<li>사각형 그리기
O 두번 -&gt; 마우스 좌클릭 드래그 -&gt; 최하단 색상 선택</li>
<li>텍스트 생성
텍스트 한 번 생성 후 수정 안됨.. 말이되나
다른 이미지 편집 툴을 사용할까도 해봤는데 Gimp 처럼 너무 기능이 많아 불편하거나 기능이 없는 경우도 있어 그냥 pinta 를 택함.</li>
</ul>
<br>


<h3 id="6-3-한글-입력-설정">6-3. 한글 입력 설정</h3>
<p><a href="https://andrewpage.tistory.com/390">https://andrewpage.tistory.com/390</a></p>
<blockquote>
<p>Ubuntu 24 에 들어오면서 한글 입력을 위해선 Korean &gt; Korean 이 아닌, 
Korean &gt; Hangul 로 적용해야 된다. </p>
</blockquote>
<p>Settings &gt; Region &amp; Language 탭에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/be834d5d-5b97-44af-8055-7e283853b377/image.png" alt=""></p>
<p>이후 Manage Installed Languages 에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b5fe494c-eda3-4cd7-88c4-e2f7ef917fa2/image.png" alt=""></p>
<p>Install/Remove Languages 에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e6424a67-f7f2-44ae-a77a-aa4b7c48ed8b/image.png" alt=""></p>
<p>Korean 을 선택한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/4764e66a-35f5-44d4-a2c2-c0b98f00da3b/image.png" alt=""></p>
<p>Download 가 완료되면 [한국어] 가 추가되었는지 후 Close 한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/664186e4-34d5-4472-9b98-59d48f9d1ddf/image.png" alt=""></p>
<p>이후 우분투 OS 자체를 Reboot 해주어야 한다.
GUI 상으로 우측 상단에 있는 것으로 reboot 가능하지만, 
터미널에 익숙해져보자.</p>
<pre><code>sudo reboot</code></pre><p>reboot 이 완료되었다면 ibus-setup 에 진입하자.</p>
<pre><code>ibus-setup</code></pre><p>[Input Method] 탭에 Add 에 진입하자. 나는 이미 Hangul 이 적용되어 있지만, 없다고 가정하고 진행해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/903e35ad-ad94-4da8-9782-92f5a7906d99/image.png" alt=""></p>
<p>Korean 을 입력해 진입하면,</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/49d575c6-64a8-469d-88e5-7e8ea6bffef1/image.png" alt=""></p>
<p>아래와 같이 Hangul 이 나온다. </p>
<blockquote>
<p>Hangul 이 나오지 않으면 reboot 을 하지 않았거나 Korean 이 제대로 설치되지 않은 것이므로 위 설치 단계로 다시 넘어가자.
그리고 Ubuntu 24 부터는 Hangul 로 해야만 적용되므로 참고하자.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/efb740a6-c534-4bb8-a3c9-e12ba03bbaf5/image.png" alt=""></p>
<br>

<p>아직 작업을 추가로 해주어야 한다.</p>
<p>Settings &gt; keyboard &gt; Add Input Source 에 진입한다.
나는 이미 Korean (Hangul) 로 적용되어 있지만, 세팅이 안된 사람이라면 English (US) 으로 써있을 것이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/d009d50b-e9c3-4925-aa5b-4321dc5816e9/image.png" alt=""></p>
<p>그러면 Korean 을 진입해</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/7362019d-8b41-4e7e-b25a-9983d042ad1d/image.png" alt=""></p>
<p>Hangul 을 추가해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/63132b9e-490a-45d8-a9a7-647234e37cc7/image.png" alt=""></p>
<blockquote>
<p>그 이후에는 기존에 있던 English (US) 를 삭제해야 한다.</p>
</blockquote>
<p>그러면 우측 상단에 [한] 클릭 시 Korean(Hangul) 이 나오면 된 것이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/9021af8a-a419-4e52-8c41-4c19413f4f97/image.png" alt=""></p>
<br>

<p><strong>이제는 한/영키로 변환할 수 있게 해보자.</strong></p>
<p>기본 설정은 Shift + Space 으로 변환되게 설정되어있다. 
이를 수정하기 위해 이전의 화면에서 메뉴버튼의 Preferences 에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/49954b43-ce84-4316-9411-2c7001ca0c40/image.png" alt=""></p>
<p>Add 버튼을 누르고, </p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/efdb5ec5-8d1d-495d-a886-6a518a543bd8/image.png" alt=""></p>
<p>일반적인 키보드의 한/영키를 입력하면 아래와 같이 Alt_R 로 인식되고, 
OK 를 누르면 이제 한/영키 전환이 잘 될것이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2846a893-281d-4bf6-973d-910100ccdd99/image.png" alt=""></p>
<br>

<p><strong>선택) 한/영키 + space 누를 시 window menu 나오는 현상 수정</strong></p>
<p>Ubuntu 는 기본적으로 ALT + Space 를 입력하면 Window Menu 가 나오는게 매우 불편하므로.. 변경해보자.
특히 나는 블로그를 쓰면서 한/영 전환을 매우 자주하므로 불편하다.</p>
<p>Settings &gt; Keyboard &gt; View and Customize Shortcuts 에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1d3e219b-d148-455d-8ed1-bc6b9db808ca/image.png" alt=""></p>
<p>Windows 에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/db19fb04-8fc5-4d18-b56d-08fe450f004c/image.png" alt=""></p>
<p>Activate the window menu 를 Alt + Space 가 아닌 다른 단축키로 설정한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1c393a3e-7e9b-4913-b606-bd18033928f8/image.png" alt=""></p>
<br>


<h3 id="6-4-vivim-설정-curl--git--터미널-테마--tree-설치">6-4. vi/vim 설정, curl / git / 터미널 테마 / tree 설치</h3>
<p>vi 는 리눅스에서 가장 많이 쓰이는 에디터이지만, 기본적으로 방향키 등도 B, C, D 와 같은 문자열로 입력되는 현상을 수정해보자.</p>
<pre><code class="language-bash">cd ~
# vi로 .exrc 파일 생성
vi .exrc

# 아래 내용 입력 후 저장
set bs=2
set nocp

# 위 설정을 저장
source .exrc</code></pre>
<blockquote>
<p>참고 : 터미널에서 복사/붙여넣기 단축키는 Shift + Ctrl + C, Shift + Ctrl + V 이다.</p>
</blockquote>
<p><strong>vim 설치 (선택)</strong>
vim 은 vi 에서 조금 더 진화된 에디터이다.
특정 키워드의 색상을 다르게 하거나, 화살표로 커서를 이동하는 것들을 기본적으로 지원한다.</p>
<pre><code>sudo apt install vim

# 검색어 강조 설정
echo &quot;set hlsearch&quot; &gt;&gt; ~/.vimrc
echo &quot;set incsearch&quot; &gt;&gt; ~/.vimrc</code></pre><p><strong>curl 설치</strong></p>
<p>curl(Client URL) 이란 의미로 Client에서 URL을 사용해 서버와 데이터를 송수신하는 명령어 툴이다.</p>
<p>특히 Linux 환경에서 HTTP, HTTPS, SMTP, TELNET, FTP, LDAP 등 다양한 프로토콜을 지원하여 자주 쓰이는 명령어라 설치해두자.</p>
<pre><code class="language-bash">sudo apt install curl -y</code></pre>
<p><strong>git 설치</strong></p>
<p>git은 소스코드 관리나 여러 repository 들을 직접 불러올 때 쓸모가 많으므로 무조건 설치하자.</p>
<pre><code class="language-bash"># git 설치
sudo apt install git -y
# 설치 확인
git --version

# git 사용자 정보 적용 (선택)
git config --global user.name &quot;Your Name&quot;
git config --global user.email &quot;your.email@example.com&quot;

# config 정보 확인
git config --list</code></pre>
<p><strong>프롬프트 테마 설치</strong></p>
<p>대중적으로 사용되는 터미널 입력창의 테마가 있다.
Powerline 을 설치해보자.</p>
<pre><code># powerline 설치
sudo apt install powerline fonts-powerline

# powerline 적용
echo &#39;if [ -f /usr/share/powerline/bindings/bash/powerline.sh ]; then&#39; &gt;&gt; ~/.bashrc
echo &#39;    source /usr/share/powerline/bindings/bash/powerline.sh&#39; &gt;&gt; ~/.bashrc
echo &#39;fi&#39; &gt;&gt; ~/.bashrc
source ~/.bashrc</code></pre><p>적용이 잘 된 모습</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/416e6a9d-ec96-4a55-a977-bafe2f109cf0/image.png" alt=""></p>
<p><strong>만약 powerline 폰트가 깨지는 경우</strong></p>
<p>터미널 우측 상단의 메뉴 &gt; Preferences </p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/08029fb7-43ef-447b-8b83-8b93f009106a/image.png" alt=""></p>
<p>좌측의 Unnamed &gt; Text &gt; Custom font 체크 후 폰트 선택</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/bffad149-ff49-47f2-9bcf-163b85b8265f/image.png" alt=""></p>
<p>Monospace 선택</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/d83d4c54-11d6-45f9-a6e3-935b87a39488/image.png" alt=""></p>
<p>폰트 캐시 삭제</p>
<pre><code>sudo fc-cache -f -v</code></pre><p>이후 터미널 재실행</p>
<p><strong>tree 설치</strong></p>
<p>리눅스에서 현재 디렉토리와 하위 디렉토리 구조를 표현할 때 tree 구조보다 가시성이 좋은 것은 없다.
누군가에게 설명할 일이 있거나 파일 구조를 터미널에서 쉽게 보고자 한다면 설치해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/4d194357-d9c3-4c1e-a40a-7c393f777d01/image.png" alt=""></p>
<pre><code>sudo apt install tree</code></pre><br>

<h3 id="6-5-docker-설치">6-5. docker 설치</h3>
<blockquote>
<p>Ubuntu 24.04에서는 apt-key 명령어가 더 이상 권장되지 않으며, 대신 GPG 키를 /etc/apt/keyrings/ 디렉토리에 저장하는 방식으로 변경해야 한다.</p>
</blockquote>
<pre><code class="language-bash"># 저장소 추가
echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null

# 패키지 목록 업데이트
sudo apt update

# Docker 패키지 설치
sudo apt install docker-ce docker-ce-cli containerd.io

# Docker 설치 확인
sudo docker --version
sudo docker run hello-world

# 현재 유저에 docker 명령어 실행 권한 주기
sudo usermod -aG docker $USER
newgrp docker

# docker-compose 설치
sudo apt-get install docker.io docker-compose</code></pre>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[K6 부하테스트 스크립트 작성법]]></title>
            <link>https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95</link>
            <guid>https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95</guid>
            <pubDate>Sun, 01 Dec 2024 03:34:03 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-Grafana-influx-DB-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-Prometheus-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-trn6mnca">K6 부하테스트, Grafana (+ influx DB) 모니터링, Prometheus 인스턴스 메트릭 수집</a></p>
<p>위 링크에서 K6 부하테스트 및 Grafana / Prometheus / InfluxDB 모니터링 환경 구축을 언급했다. </p>
<p>이에 이어서 K6 스크립트 작성법에 알아보고자 한다.</p>
<h1 id="k6">K6?</h1>
<p>먼저 K6 란 Grafana 에 소속된 부하테스트 도구로써 Grafana 와의 호환성이 뛰어나다. 
Grafana 사용자 수가 많은 만큼, 오픈소스도 잘 되어있고 K6 또한 많은 기능들을 지원한다.</p>
<p>또한 Go 언어의 코루틴 기반으로 작성되어 있어, 
가상 사용자 (vUser) 수를 대폭 늘릴 수 있다는 것이 가장 큰 장점이다.
JMeter 나 Ngrinder 에서 Heap 메모리 제한으로 인해 가상 사용자 수 (VUs) 최대 값은 수천을 을 넘기기 힘들고, OOM 도 자주 발생한다.
가상 사용자 하나 당 쓰레드 생성으로 인한 메모리 한계 때문인데, 이게 과연 부하 테스트가 맞는지는 의문이다.
K6 는 최대 30,000 ~ 40,000 개의 가상 사용자 수를 지정해도 충분해, 진정한 의미로 <code>부하</code> 테스트가 가능하다.</p>
<p>JMeter vs K6 를 비교한 Grafana 블로그에서 비교 분석글을 확인할 수 있다.</p>
<ul>
<li><a href="https://grafana.com/blog/2021/01/27/k6-vs-jmeter-comparison/">K6 vs JMeter</a></li>
</ul>
<p>코루틴에 대한 자세한 내용은 아래 포스팅에서 확인 가능하다.</p>
<ul>
<li><a href="https://velog.io/@mud_cookie/%EC%BD%94%EB%A3%A8%ED%8B%B4%EC%9D%B4%EB%9E%80">코루틴이란</a></li>
</ul>
<br>
<br>

<hr>
<br>
<br>


<h1 id="스크립트-작성법">스크립트 작성법</h1>
<p>K6 의 테스트 스크립트는 Javascript 로 작성한다. 
런타임에는 Javascript Interpreter 를 사용해 Go 엔진으로 실행된다고 보면 된다.
개발자라면 한 번쯤은 다뤄보았을 언어이기도 하고, K6 스크립트 메서드가 직관적이라 어려울 것은 없다.</p>
<p>그래도 어떠한 기능들을 지원하는지는 알아야 하니 자세하게 살펴보자.</p>
<br>

<h2 id="1-k6-스크립트-lifecycle">1. K6 스크립트 LifeCycle</h2>
<p>기본적인 구조는 아래와 같다.</p>
<pre><code>import http from &#39;k6/http&#39;;
import { sleep } from &#39;k6&#39;;

export let options = {
  vus: 10, // 가상 사용자 수
  duration: &#39;30s&#39;, // 테스트 실행 시간
};

export function setup() {
  // setup code
  return {
    initData: &#39;initial setup data&#39;
  }
}

export default function (data) {
  let res = http.get(&#39;https://test-api.com&#39;);
  console.log(`Response time: ${res.timings.duration}ms`);
  sleep(1);
}

export function teardown(data) {
  // teardown code
}</code></pre><p>기본적으로는 테스트 스크립트에 대한 options 를 지정하고, 아래와 같은 lifeCycle 을 가져간다.</p>
<ul>
<li>function setup() 으로 테스트 실행 전 데이터를 정의하고,</li>
<li>default function (data) 에서는 실제 테스트할 스크립트를 지정한다.
data 는 setup 에서 정의한 data 를 의미한다.</li>
<li>teardown 에서는 테스트 종료 후 정리 작업을 진행한다.
마찬가지로 data 는 setup 에서 return 한 객체를 의미한다.
일반적으로 테스트 중에 저장된 데이터를 삭제하는 로직이 들어간다.</li>
</ul>
<p>추가적으로,</p>
<ul>
<li>options 객체에서는 가상 사용자 수(vus)와 테스트 시간, Tag 등 다양한 설정을 할 수 있다.</li>
<li>sleep(1)은 1명의 가상 사용자가 요청을 마치고 1초간 쉬는 걸 의미한다. 
이렇게 하면 부하를 연속해서 주지 않고 약간의 간격을 줄 수 있다.
Java 와는 다르게 ms 단위가 아닌 s 단위임에 주의하자.</li>
</ul>
<br>

<h2 id="2-옵션-설정">2. 옵션 설정</h2>
<p>options 설정을 통해 부하 테스트의 스케줄을 세부적으로 지정할 수 있다.</p>
<h3 id="2-1-stages--단계별-부하를-설정">2-1. Stages : 단계별 부하를 설정</h3>
<p>stages 는 테스트 부하가 주입되는 단계를 설정할 수 있다.
일반적으로 테스트는 RampUp --&gt; Load --&gt; RampBackDown 의 순서로 수행이 된다.</p>
<pre><code>export let options = {
  stages: [
    { duration: &#39;10s&#39;, target: 20 }, // 10초 동안 가상 사용자를 20명까지 증가
    { duration: &#39;20s&#39;, target: 50 },  // 1분 동안 가상 사용자를 50명으로 유지
    { duration: &#39;10s&#39;, target: 0 },  // 30초 동안 가상 사용자를 0명으로 감소
  ],
};</code></pre><p>위 예제에서는 RampUp으로 20유저를 10초간 생성한다. 
그리고 Load는 50 유저를 20초간 수행하고, 
마지막으로 RampBackDown으로 사용자를 10초동안 0으로 만든다.</p>
<h3 id="2-2-tags--스크립트에-특정-태그를-붙이기">2-2. Tags : 스크립트에 특정 태그를 붙이기</h3>
<p>스크립트 자체에 Tag 를 붙여 해당 테스트 결과는 해당 Tag 를 붙여서 출력하도록 설정할 수 있다.</p>
<pre><code>export let options = {
  tags: { test_name: &quot;test-script-1&quot; }, // 태그 추가
};</code></pre><h3 id="2-3-thresholds-테스트-중-특정-성능-목표를-설정">2-3. Thresholds: 테스트 중 특정 성능 목표를 설정</h3>
<p>thresholds 옵션을 사용하면 테스트 완료 후, 설정된 성능 목표가 충족되었는지 확인할 수 있다.
이를 통해 부하 테스트의 성공 여부를 자동으로 판단할 수 있다.</p>
<pre><code>export let options = {
  thresholds: {
    http_req_duration: [&#39;p(95)&lt;500&#39;], // 95%의 요청이 500ms 이하이어야 함
  },
};</code></pre><h3 id="2-4-summary-trend-stats-테스트가-종료된-후-요약-보고서에-포함할-통계의-종류를-설정">2-4. Summary Trend Stats: 테스트가 종료된 후, 요약 보고서에 포함할 통계의 종류를 설정</h3>
<p><code>summaryTrendStats</code> 옵션을 사용하면 요약 보고서에서 원하는 통계 정보만을 선택적으로 확인할 수 있다.</p>
<pre><code>export let options = {
  summaryTrendStats: [&#39;avg&#39;, &#39;p(95)&#39;, &#39;max&#39;],
};</code></pre><br>

<h2 id="3-http-요청-메서드">3. HTTP 요청 메서드</h2>
<pre><code># GET 요청 예시
export default function () {
  let url = &#39;https://test-api.com/resource&#39;;
  let res = http.get(url);
  console.log(`Status code: ${res.status}`);
  console.log(`Response body: ${res.body}`);
}</code></pre><pre><code># POST 요청 예시
export default function () {
  let url = &#39;https://test-api.com/resource&#39;;
  let payload = JSON.stringify({ name: &#39;John Doe&#39;, age: 30 });
  let params = {
    headers: {
      &#39;Content-Type&#39;: &#39;application/json&#39;,
    },
  };

  let res = http.post(url, payload, params);
  console.log(`Status code: ${res.status}`);
}</code></pre><h2 id="4-check--metrics">4. Check &amp; Metrics</h2>
<p>부하 테스트를 하다 보면 단순히 요청을 보내는 것 외에도, 요청이 성공했는지 여부를 확인하고 싶은 경우가 많다. 
이럴 때 check 메서드를 사용해 볼 수 있다.</p>
<pre><code>import { check } from &#39;k6&#39;;

export default function () {
  let res = http.get(&#39;https://test-api.com&#39;);
  check(res, {
    &#39;status is 200&#39;: (r) =&gt; r.status === 200,
    &#39;response time &lt; 500ms&#39;: (r) =&gt; r.timings.duration &lt; 500,
  });
}</code></pre><p>위 스크립트에서 check 메서드는 응답 상태 코드가 200인지, 그리고 응답 시간이 500ms 이하인지 확인한다. 
이렇게 조건을 걸어두면, 부하 테스트 후에 얼마나 많은 요청이 성공했는지 쉽게 알 수 있다.</p>
<h2 id="5-group-지정">5. Group 지정</h2>
<p>하나의 테스트 스크립트 안에서도 특정 API 마다, 혹은 특정 로직 별로 구분하고 싶을 수가 있다.
이럴 때 사용되는 것이 Group 이다.</p>
<p>자세한 옵션은 아래에서 확인 가능하다.
<a href="https://grafana.com/docs/k6/latest/using-k6/tags-and-groups/">grafana k6 - Tags and Groups document</a></p>
<pre><code>export default function (data) {
  group(&#39;POST api/books&#39;, function () {
    // API 테스트 또는 로직 테스트 작성...
  }
  group(&#39;Review Create And Update&#39;, function () {
    // API 테스트 또는 로직 테스트 작성...
  }
}</code></pre><p>이렇게 하면 HTTP 결과가 <code>POST api/books</code>, <code>Review Create And Update</code> 라눈 두개의 Group 으로 나뉘게 된다. 
Console 의 결과창에는 자세한 정보가 나오지 않아, 저장한 DB 에서 조회하거나 DB 에 연동된 시각화 도구에서 확인이 가능하다.
나의 경우엔 InfluxDB 에 저장 후 Grafana DashBoard 와 연동했다.</p>
<h2 id="6-외부에서-변수-지정">6. 외부에서 변수 지정</h2>
<p>k6 를 수행하는 명령어는 <code>k6 run your-path/script.js</code> 와 같은 형태이다.
다만 수행할때마다 script 를 일일이 바꾸기 힘드므로 특정 값을 변수로 지정해 수행할 때 외부에서 지정한 값 또는 기본값으로 수행하게 할 수 있다.</p>
<p>외부에서 변수를 주입해 수행하는 예시)</p>
<pre><code class="language-bash">STAGE1_DURATION=5s
STAGE1_TARGET=5000
STAGE2_DURATION=20s
STAGE2_TARGET=10000
STAGE3_DURATION=5s
STAGE3_TARGET=0
k6 run --env STAGE1_DURATION=$STAGE1_DURATION --env STAGE1_TARGET=$STAGE1_TARGET --env STAGE2_DURATION=$STAGE2_DURATION --env STAGE2_TARGET=$STAGE2_TARGET --env STAGE3_DURATION=$STAGE3_DURATION --env STAGE3_TARGET=$STAGE3_TARGET</code></pre>
<br>

<p>k6 script 예시)</p>
<pre><code class="language-bash">// 외부 환경변수로부터 stages 값 주입
const stage1_duration = __ENV.STAGE1_DURATION || &#39;10s&#39;;
const stage1_target = Number(__ENV.STAGE1_TARGET || 1);
const stage2_duration = __ENV.STAGE2_DURATION || &#39;110s&#39;;
const stage2_target = Number(__ENV.STAGE2_TARGET || 1);
const stage3_duration = __ENV.STAGE3_DURATION || &#39;10s&#39;;
const stage3_target = Number(__ENV.STAGE3_TARGET || 0);

// 테스트 설정
export let options = {
  stages: [
    { duration: stage1_duration, target: stage1_target }, 
    { duration: stage2_duration, target: stage2_target }, 
    { duration: stage3_duration, target: stage3_target },  
  ],
  ...
};</code></pre>
<br>
<br>

<hr>
<br>
<br>


<h1 id="복합-예시">복합 예시</h1>
<p>위 설명한 내용을 기반으로 복합적인 스크립트 작성 예시를 들어본다.</p>
<pre><code>import http from &#39;k6/http&#39;;
import { sleep, check, group } from &#39;k6&#39;;

// 테스트 설정
import http from &#39;k6/http&#39;;
import { sleep, check, group } from &#39;k6&#39;;

// 테스트 설정
export let options = {
  stages: [
    { duration: &#39;10s&#39;, target: 20 }, // 10초 동안 가상 사용자를 20명까지 증가
    { duration: &#39;20s&#39;, target: 50 }, // 20초 동안 가상 사용자를 50명으로 유지
    { duration: &#39;10s&#39;, target: 0 },  // 10초 동안 가상 사용자를 0명으로 감소
  ],
  tags: {                            // 태그 추가
    team : &#39;server3&#39;,
    test_name: &#39;test-script-2&#39; 
  }, 
  thresholds: {
    http_req_duration: [&#39;p(95)&lt;100&#39;], // 95%의 요청이 100ms 이하이어야 함
  },
};

// setup 함수 - 테스트 실행 전 초기화 작업
export function setup() {
  console.log(&#39;Setup: Initializing test setup...&#39;);

  // 공통으로 사용할 헤더 초기화
  let headers = {
    &#39;accept&#39;: &#39;*/*&#39;,
    &#39;Content-Type&#39;: &#39;application/json&#39;,
  };

  // 필요한 데이터나 환경 초기화 등 설정
  return {
    initData: &#39;initial setup data&#39;, // 필요 시 데이터를 반환하여 main 함수에 전달
    commonHeaders: headers          // 헤더를 반환하여 main 함수에서 사용
  };
}

// main 함수 - 실제 테스트가 수행되는 부분
export default function (data) {
  let url = &#39;http://host.docker.internal:8080/api/books&#39;;
  let bookId;

  group(&#39;POST api/books&#39;, function () {
    // __VU: 현재 가상 사용자 ID, __ITER: 해당 VU의 반복 횟수
    let payload = JSON.stringify({
        name: `The Lord of the Rings VU${__VU} ITER${__ITER + 1}`, // VU ID와 반복 횟수를 조합하여 고유한 값으로 변경
        category: &#39;Fantasy&#39;,
        author: {
        name: &#39;JinUk Ye&#39;,
        biography: &#39;English writer and philologist&#39;
        }
    });

    // POST 요청을 보낸다.
    let res = http.post(url, payload, { headers: data.commonHeaders });

    // POST 요청 응답 검증
    check(res, {
        &#39;is POST status 200 or 201&#39;: (r) =&gt; r.status === 200 || r.status === 201,   // 상태 코드가 200 또는 201인지 확인
    });

    console.log(`POST Status code: ${res.status}`);

    // POST 응답에서 생성된 ID를 추출한다.
    bookId = res.json().id;

  });

  sleep(0.1);       // POST 저장 후 100ms 후에 GET 조회

  // GET 요청 그룹
  group(&#39;GET /api/books&#39;, function () {
    // 책 ID로 GET 요청을 보낸다.
    if (bookId) {
      let getUrl = `${url}/${bookId}`;
      let getRes = http.get(getUrl, { headers: data.commonHeaders });

      // GET 요청 응답 검증
      check(getRes, {
        &#39;is GET status 200&#39;: (r) =&gt; r.status === 200, // 상태 코드가 200인지 확인
      });

      console.log(`GET ${getUrl} Status code: ${getRes.status}`);
    } else {
      console.error(&#39;No book ID returned from POST request.&#39;);
    }
  });

  sleep(0.1);
}

// teardown 함수 - 테스트 종료 후 정리 작업
export function teardown(data) {
  console.log(&#39;Teardown: Cleaning up after test...&#39;);
  // 테스트가 끝난 후 필요한 정리 작업 수행
}
</code></pre><br>

<p>host.docker.internal 은 내가 임의로 환경 테스트 중인 WSL 에서 docker 외부의 localhost 에 요청을 보내기 위해 작성한 것이니 무시해도 된다.</p>
<p>위와 같이 script 를 작성하고, 
<a href="https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-Grafana-influx-DB-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-Prometheus-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-trn6mnca">K6 부하테스트, Grafana (+ influx DB) 모니터링, Prometheus 인스턴스 메트릭 수집</a> 
에서 설정한 것을 기반으로 아래와 같이 k6 스크립트를 실행해보았다.</p>
<p>위와 같이 통합 모니터링 환경을 설정한 것이 아니라면, 그냥 로컬에서 <code>k6 run test-script.js</code> 와 같이 실행해도 무방하다.</p>
<br>

<pre><code>docker run --rm --network monitoring_network \
  -v ${docker 외부에서 마운트할 디렉토리}/load-test/${팀명}:/scripts grafana/k6:0.55.0 run \
  --out influxdb=http://influxdb:8086/metrics \
  /scripts/test-script.js</code></pre><p>아래와 같은 결과가 콘솔에 출력된다. 
50 명의 가상유저(VUs) 로 실행되었고, 
모든 유저들이 40초 동안 테스트들이 5016 번을 테스트했음을 알린다.
checks 에는 5016 * 2 = 10034 번의 검증이 통과했고, (각 테스트마다 check 가 2개 있으므로)
이외 나머지 http 관련 값들이 출력된다.</p>
<pre><code>
         /\      Grafana   /‾‾/  
    /\  /  \     |\  __   /  /   
   /  \/    \    | |/ /  /   ‾‾\ 
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/ 

     execution: local
        script: /scripts/test-script.js
        output: InfluxDBv1 (http://influxdb:8086)

     scenarios: (100.00%) 1 scenario, 50 max VUs, 1m10s max duration (incl. graceful stop):
              * default: Up to 50 looping VUs for 40s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)


# 테스트 진행..
.
.
.

time=&quot;2024-12-01T07:11:36Z&quot; level=info msg=&quot;Teardown: Cleaning up after test...&quot; source=console

     ✓ is POST status 200 or 201
     ✓ is GET status 200

     checks.........................: 100.00% 10034 out of 10034
     data_received..................: 3.2 MB  79 kB/s
     data_sent......................: 2.3 MB  57 kB/s
     http_req_blocked...............: avg=40.02µs  min=1.7µs    med=4.66µs   max=16.68ms  p(90)=8.02µs   p(95)=10.81µs 
     http_req_connecting............: avg=32.39µs  min=0s       med=0s       max=16.57ms  p(90)=0s       p(95)=0s      
   ✓ http_req_duration..............: avg=3.76ms   min=1.25ms   med=2.83ms   max=33.92ms  p(90)=7.14ms   p(95)=9.45ms  
       { expected_response:true }...: avg=3.76ms   min=1.25ms   med=2.83ms   max=33.92ms  p(90)=7.14ms   p(95)=9.45ms  
     http_req_failed................: 0.00%   0 out of 10034
     http_req_receiving.............: avg=47.23µs  min=12.33µs  med=36.47µs  max=1.72ms   p(90)=74.5µs   p(95)=95.79µs 
     http_req_sending...............: avg=21.8µs   min=4.2µs    med=13.99µs  max=3.3ms    p(90)=37.17µs  p(95)=51.51µs 
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=3.69ms   min=1.22ms   med=2.78ms   max=33.7ms   p(90)=7.05ms   p(95)=9.35ms  
     http_reqs......................: 10034   249.732516/s
     iteration_duration.............: avg=209.16ms min=203.57ms med=207.23ms max=243.99ms p(90)=216.19ms p(95)=220.55ms
     iterations.....................: 5017    124.866258/s
     vus............................: 1       min=1              max=49
     vus_max........................: 50      min=50             max=50</code></pre><p>이전에 내가 포스팅했던 모니터링 환경을 구축했다면, Grafana 에서 같이 모니터링해보자.</p>
<p>아래는 위 부하테스트를 두 건 (정상 1 건, 에러 발생 1 건) 결과를 Grafana DashBoard 로 모니터링한 결과를 캡처한 예시이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a7f19427-b3b9-4770-a1ab-e3e22fc56b34/image.png" alt=""></p>
<br>

<p>내친 김에 VUs (가상 사용자 수) 를 대폭 늘려보자.
JMeter 나 Ngrinder 에서는 상상도 못했던 10,000 으로 화끈하게 테스트해보자.</p>
<pre><code>  stages: [
    { duration: &#39;10s&#39;, target: 4000 }, // 10초 동안 가상 사용자를 4,000명까지 증가
    { duration: &#39;20s&#39;, target: 10000 }, // 20초 동안 가상 사용자를 1,000명으로 유지
    { duration: &#39;10s&#39;, target: 0 },  // 10초 동안 가상 사용자를 0명으로 감소
  ]</code></pre><p>결론적으로 40초동안 12만 건 이상의 HTTP 요청을 보냈는데, 
아래를 보면 이제 슬슬 지연되는 것이 확인된다.
내 로컬PC 에서 Spring Boot 인스턴스를 띄우고 k6 를 구동해 실제 서버 스펙보다는 떨어진다는 것을 참고하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/be780ec5-e9a1-4684-ad80-c6116e1fefb7/image.png" alt=""></p>
<br>
<br>

<hr>
<br>
<br>



<h1 id="httphttps-외-다른-프토토콜-지원">HTTP/HTTPS 외 다른 프토토콜 지원</h1>
<p>K6 는 기본적으로 HTTP/HTTPS 기반이기 때문에, Kafka / RabbitMQ 와의 직접적인 부하는 지원하지 않는다.
다만 k6-plugin-kafka 또는 k6-plugin-amqp 등의 플러그인을 사용하면 사용이 가능하다.</p>
<blockquote>
<p>관련해 추가로 작성한 포스트</p>
</blockquote>
<ul>
<li><a href="https://velog.io/@mud_cookie/%EC%BD%94%EB%A3%A8%ED%8B%B4%EC%9D%B4%EB%9E%80">코루틴이란</a></li>
<li><a href="https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-Grafana-influx-DB-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-Prometheus-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-trn6mnca">K6 부하테스트, Grafana (+ influx DB) 모니터링, Prometheus 인스턴스 메트릭 수집</a> </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[K6 부하테스트, Grafana (+ influx DB) 모니터링, Prometheus 인스턴스 메트릭 수집]]></title>
            <link>https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-Grafana-influx-DB-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-Prometheus-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-trn6mnca</link>
            <guid>https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-Grafana-influx-DB-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-Prometheus-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-trn6mnca</guid>
            <pubDate>Sat, 30 Nov 2024 11:45:10 GMT</pubDate>
            <description><![CDATA[<p>위 기술에 대해 검색해보면 개인 서버에 실행하는 것이 대부분이고, 이유와 과정에 대해 자세한 설명이 없어 따로 블로그를 작성한다.
조직에서, 특히 폐쇄망에서 사내 구성원들이 하나의 환경에서 사용할 수 있게 하고, 커스텀한 시각화 DashBoard 구축을 목표로 진행한다.
또한 이 문서를 보는 사람들이 시행착오를 줄이고 각자 자신만의 metric 시각화를 구축할 수 있게 설명하고자 한다.</p>
<blockquote>
<p>구축한 환경의 기본 소스들은 아래에 넣어두었으니 참고하자.
<a href="https://github.com/isckd/integration-monitoring">https://github.com/isckd/integration-monitoring</a></p>
</blockquote>
<br>
<br>

<hr>
<br>
<br>

<h2 id="도입-이유">도입 이유</h2>
<p>사내 Spring Boot 기반의 MSA 아키텍처로 구성된 개발 환경에서는, 그동안 부하 테스트 및 모니터링에 적합한 도구를 제대로 사용하고 있는지는 의문이었다.
운영계에는 Jeniffer 와 MaxGuage 솔루션을 도입해 실시간으로 장애상황 모니터링이 가능했지만, 개발계에선 여러가지 부하테스트를 수행하고 병목지점을 발견하기 위한 모니터링은 전무한 상황이었다.</p>
<p>그래서 개발자들은 필요 시 각자 로컬 환경에서 JMeter를 사용하여 부하 테스트를 진행했지만, JMeter는 OS 단에서 관리하는 스레드를 사용하기 때문에 스레드 하나 당 메모리 1MB 이상을 소비하며 컨텍스트 스위칭 비용도 무시할 수 없기 때문에 과연 많은 양의 부하를 줄 수 있었는가에 대해서는 개인적으로 의구심이 있었다.</p>
<p>결론적으로 부하 테스트 환경의 확장성과 효율성에 제한이 있었고, 내가 개별적으로 구축한 Ngrinder 역시 비슷한 문제를 가지고 있었다.
각 인스턴스들의 모니터링은 당연히 적용되지 않은 상태였다.</p>
<p>이에 따라 보다 중앙화해 관리할 수 있고, 정형화되고 효율적인 부하 테스트 및 모니터링 도구를 도입하기로 결정했다.</p>
<br>

<p>여기서 개발계와 운영계는 인프라 구조부터가 다른데, 이렇게까지 할 필요가 없다는 의견이 있을 수 있다.
물론 Spring Boot 인스턴스 수와, 각 툴들의 클러스터링 구조 및 서버 사양도 모두 다른 상황인 것은 맞다. 
하지만 나는 개발계에서도 부하테스트와 모니터링이 필수적이라 생각하는 이유는 아래와 같다.</p>
<ul>
<li>꼭 성능만을 측정하고자 하는 것이 아니다.</li>
<li>트래픽 양에 따른 스토리지 및 메모리 소비량을 확인할 수 있다.</li>
<li>부하 임계점을 찾고 운영환경과 비례해 간략하게나마 비교할 수 있다.</li>
<li>부하를 가정해 예상치 못한 이슈 발견 및 재현을 할 수 있다. 이로 인해 빠르게 부하를 재현하고 수정이 가능하다.</li>
<li>어느 작업이 전체 작업 중 리소스를 몇% 나 차지하는지, 어느 부분에서 병목현상이 발생하는지 확인이 가능하다.</li>
</ul>
<br>
<br>

<hr>
<h2 id="사용한-부하테스트--모니터링-툴">사용한 부하테스트 / 모니터링 툴</h2>
<p>모니터링이라고 함은 기본적으로 <code>시계열 데이터</code> 를 기반으로 시각화 하는 것을 기반으로 한다.
시계열 데이터라고 함은 어려울 것 없이 특정 시간대별로 데이터의 양상을 나타낸다고 이해하면 된다.</p>
<p>모니터링을 위한 시계열 데이터는 인스턴스의 정보들을 나타내는 Metric 들이어야 하며, 이 진영에서는 Prometheus 가 오픈소스로 꽉 잡고있다.</p>
<p>Prometheus 동작 방식은 외부 인스턴스에서 제공하는 /prometheus API Endpoint 를 Polling 하여 저장하는 방식이고, 웬만한 오픈소스 툴, 프레임워크들은 해당 API Endpoint 를 제공한다.
즉, 데이터 수집 주체는 Prometheus 이고 Grafana 에서는 Prometheus 에 특정 시간대의 특정 인스턴스의 데이터를 요청하는 구조이다.</p>
<p>그래서, 부하를 받는 Spring Boot 인스턴스 및 여러가지 툴, 프레임워크들의 성능을 모니터링하기 위해 Prometheus를 적용해 Grafana 로 시각화했다.
K6 부하테스트의 실시간 진행상황 및 결과는 InfluxDB 에 저장하고, Grafana와 연동하여 시각화했다.</p>
<p>InfluxDB 는 Prometheus 와 달리 API Endpoint 로 제공하지 못하는 정보들을 외부에서 직접 저장시키는 것이 가능해, K6 부하테스트의 진행상황 및 결과 수집이 가능하다.
즉, 부하테스트에 대한 데이터 수집 주체는 외부 인스턴스(K6) 이고 InfluxDB 는 데이터를 저장하고 Grafana 에 데이터를 제공하는 것이다.</p>
<p>결과적으로, K6, Grafana, Prometheus, InfluxDB를 조합하여 부하 테스트와 모니터링의 통합된 환경을 구축했다.</p>
<p>이외 Oracle, MySQL, Redis, RabbitMQ, Elasticsearch, Kafka 등을 모니터링 할 수 있게 exporter 를 사용해 Prometheus 로 metric 을 수집하고 Grafana 에서 시각화 할 수 있는 과정도 진행했다. </p>
<p>설치는 최대한 Docker 를 사용해 일관적인 관리와 유지보수성을 높였다.</p>
<p>이 과정을 밟아보며 Spring Boot 기반의 MSA 아키텍처에 대한 신뢰성을 높이고, 성능 최적화를 위한 기반을 마련해보자.</p>
<br>

<h3 id="prometheus">Prometheus</h3>
<p>Prometheus 는 시계열DB 를 제공하며, 모니터링 및 경고 알림 시스템에 특화되어있다.
여기서는 운영이 아닌 개발환경이라 알림 기능은 제외했지만, 기본적인 Metric 을 수집하는 기능으로서 다른 툴들과의 호환성이 매우 뛰어나다.</p>
<p>특징은 아래와 같다.</p>
<ul>
<li>데이터 장기 저장보다는 현재 시점으로부터 특정 기간 전까지의 시계열 데이터를 수집하는 것에 특화되어있다. 기본값으로는 일주일동안 저장한다.</li>
<li>Metric 들을 키-값 형태의 Label 으로 정의해 시계열 데이터로 저장한다.</li>
<li>PromQL 언어를 사용한다.</li>
<li><code>Pull</code> 방식을 사용해 외부에서 Prometheus 에 Metric 정보들을 보내는 형식이 아닌, Prometheus 자체적으로 특정 인스턴스에 API 를 Polling 형식으로 호출해 가져오는 방식이다.</li>
<li>워낙 오래되고 활성 사용자가 많은 만큼, Grafana Dashboard 들을 보면 대부분이 Prometheus 에서 metric 을 가져와 시각화 하는 방식이 많다.</li>
<li>Prometheus 에서 외부 인스턴스의 데이터를 가져올 때, 해당 인스턴스는 아래와 같은 데이터 형식으로 API 응답을 주어야 한다. <pre><code># HELP cpu_usage CPU usage in percentage
# TYPE cpu_usage gauge
cpu_usage{job=&quot;app-server&quot;, instance=&quot;10.0.0.1&quot;} 85.7
cpu_usage{job=&quot;app-server&quot;, instance=&quot;10.0.0.2&quot;} 65.3</code></pre></li>
</ul>
<p>외부 인스턴스에서 위와 같이 API 응답을 위 형태 그대로 응답해야 Prometheus 에서 정상적으로 Polling 할 수 있다.
Json 방식이 아님에도 불구하고, Prometheus 는 워낙 활성화된 오픈소스라 다양한 도구들에서 Prometheus 전용 API Endpoint 들을 제공해, 이것이 가장 장점이라고 판단했다.</p>
<p>그래서 K6 부하테스트 모니터링을 제외하고는 전부 Prometheus 로 metric 을 수집했다.</p>
<br>

<h3 id="influxdb">InfluxDB</h3>
<p>InfluxDB 도 시계열DB 로서의 역할을 제공하지만, Prometheus 와는 다르게 DB 로서의 역할에 치중해있다.</p>
<p>특징은 아래와 같다.</p>
<ul>
<li>시계열 데이터 분석 및 장기 저장에 특화되어있다.</li>
<li><code>Pull 방식 뿐 아니라 Push 방식도 지원</code> 해 외부 인스턴스에서 시계열 데이터를 삽입하는 Push 도 가능하다.</li>
<li>1.x 버전에서는 InfluxQL 언어를 사용했으나, 2.x 부터는 WEB UI 지원 및 Flux 언어를 사용한다.</li>
<li>자체적인 알림 기능이 존재하지 않는다.</li>
</ul>
<br>

<h3 id="k6">K6</h3>
<p>Go 언어의 코루틴(고루틴) 기반으로 동작하여 메모리 효율이 뛰어난 (Java 의 일반 Thread 에 비해 10배 가까이 메모리 효율이 좋은) K6를 부하 테스트 도구로 적용하였다. <br>
특히 일반적인 쓰레드는 OS 에 종속되며 메모리 사용량이 크며, 컨텍스트 스위칭이 발생할 때마다 OS Level 에서 System Call 이 발생해 많은 양의 리소스가 소비된다.
코루틴은 경량화된 쓰레드 개념으로, OS 에 의해 직접 관리되지 않고 일반 쓰레드와 M:N 매핑해 사용된다.
컨텍스트 스위칭 비용이 적고, 낮은 메모리 사용량 덕분에 JMeter 와 같은 일반 쓰레드로 동작하는 도구에 비해 K6 는 훨씬 많은 VUser (가상 사용자 수) 를 사용 가능하다.
K6는 이러한 경량화된 구조 덕분에 높은 부하를 생성하면서도 시스템 자원 사용을 최소화할 수 있었다.
K6 vs JMeter 부하테스트 도구 비교 : </p>
<ul>
<li><a href="https://grafana.com/blog/2021/01/27/k6-vs-jmeter-comparison/">K6 vs JMeter (Grafana Blog)</a></li>
</ul>
<p>또한 K6 는 부하를 주기 위해 일회성으로 동작하므로, metric 수집을 위해 정기적으로 API 를 Polling 하는 (Pull 방식) Prometheus 와는 방향성이 맞지 않는다.</p>
<p>그래서 Push 방식의 시계열 데이터를 지원하는 InfluxDB 와 연동하였다.</p>
<br>

<h3 id="grafana">Grafana</h3>
<p>여러 도구를 사용하는 환경에서도 중앙 집중식 대시보드를 제공하며, 활성화된 오픈소스 커뮤니티로 웬만한 툴들의 metric 들을 시각화하는 Dashboard 들이 많이 존재한다.
특히 Grafana 재단에서 K6 를 만든만큼 K6 모니터링 호환성이 뛰어나다.
또한 Prometheus 재단과는 독립되었지만, 서로 활성화된 오픈소스인 만큼 상호 보완이 잘 되어 호환성이 뛰어나다.</p>
<br>

<h3 id="다양한-exporter">다양한 exporter</h3>
<p>Oracle, MySQL, Redis, Kafka, Elasticsearch, RabbitMQ 자체를 모니터링 할 수있는 방법은 무엇이 있는지 생각해보자.</p>
<p>우선 위 툴들에서 자체적으로 제공하는 모니터링 툴들이 존재하는 경우도 있지만, 지금 우리는 Grafana 라는 통합 모니터링 환경에서 구축하는 것이 목표이므로 조금은 다르게 접근해보자.</p>
<p>그러면 두 가지의 방식이 존재한다.</p>
<ol>
<li>Grafana Datasource 에 직접 연동하는 방법
이 방법은 각 인스턴스들이 Grafana 와 호환이 되는지부터 검토해야 한다.
Redis, Elasticsearch, RabbitMQ 등이 가능하지만 여러가지 고려사항이 존재한다.<ul>
<li>제공하는 Metric 들의 양과 질이 모니터링하기에 적합한가</li>
<li>Grafana 버전과 호환되는가 (Elasticsearch 의 경우엔 버전 제약이 강하다)</li>
</ul>
</li>
<li>각 인스턴스들의 metric 정보들을 Prometheus 으로 수집해 Grafana 에서 시각화
이 방법은 Prometheus 에 metric 을 전달하기 위한 exporter 라는 인스턴스를 별개로 띄워야 한다.
이 exporter 들은 각 인스턴스들이 prometheus 와 호환이 되지 않더라도, 개인이 직접 커스텀한 exporter 로 인스턴스들의 metric 정보를 수집해 prometheus 와 호환이 되게 만들어 주는 녀석들이다. </li>
</ol>
<p>여러가지 종류의 인스턴스 종류들을 모니터링하기 위해서 1번과 2번의 방식이 혼합되어 사용되지만 이번의 경우엔 RabbitMQ 만 1번 방식을 사용하고 나머지는 2번 방식을 
채택했다. 
그 이유에 대해서는 exporter 환경을 구성할 때 설명한다.</p>
<br>
<br>

<hr>
<br>
<br>

<h2 id="구성도">구성도</h2>
<br>

<h3 id="기본-구성도">기본 구성도</h3>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5dd7fe1a-658f-433c-acca-c82549b45ca6/image.png" alt=""></p>
<br>

<ol>
<li><p>Spring Boot 인스턴스들의 실시간 메트릭 정보들을 요청하고 저장하기 위해 Prometheus 를 도입했다. 각 Spring Boot 인스턴스들은 /actuator/prometheus 엔드포인트를 활성화해야 하고, Prometheus 에서 어느 인스턴스를 몇초마다 Polling 할 건지 지정할 수 있다.</p>
</li>
<li><p>Grafana 에서 Spring Boot 인스턴트들을 시각화할 DashBoard 를 만들고, Prometheus 를 Polling 하여 시계열 메트릭 정보를 시각화한다.</p>
</li>
<li><p>K6 부하테스트를 진행하고, 각 진행상황 및 결과들을 InfluxDB 에 저장(Push)한다.</p>
</li>
<li><p>Grafana 에서 부하테스트 결과들을 시각화할 DashBoard 를 만들고, K6 에서 진행한 부하테스트 시계열 데이터를 Polling 하여 시각화한다.</p>
</li>
</ol>
<br>

<h3 id="추가-tool-exporter-구성도">추가 Tool Exporter 구성도</h3>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/42ad75c6-4e04-4cd4-b9bd-610e927c3884/image.png" alt=""></p>
<br>

<ol>
<li>Oracle, Redis, Kafka, MySQL, Elasticsearch 는 각각 exporter 를 띄워 각각의 metric 을 수집 후 Prometheus 에서 재수집하고, Grafana 에서 Prometheus 를 Polling 해 시각화한다.
RabbitMQ 의 경우엔 exporter 필요 없이 자체 플러그인으로 Prometheus 에서 metric 수집하도록 구성한다.</li>
</ol>
<br>

<p>결론적으로 부하테스트 진행 시 SpringBoot Instance 와 K6 부하테스트 Dashboard 두 개를 확인하고, 이외 추가로 사용한 툴이 있다면 해당 exporter 에 맞는 Dashboard 를 같이 확인하면서 진행할 수 있다.</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="구성-특이사항">구성 특이사항</h2>
<p>아래 소개할 설치과정에서 조직 공통으로 사용하기 위해 설정한 특이사항들을 소개한다.</p>
<ul>
<li>2024/12 기준 최신 버전인 
  prometheus:v3.0.1
  grafana:11.3.1
을 기준으로 진행하고, InfluxDB 는 2.x 버전에서 아직 K6 와의 호환성이 떨어지므로 1.x 버전 중 최신인 1.11.8 으로 진행한다. 대신 InfluxDB 1.x 버전은 웹 UI 를 지원하지 않는다.</li>
<li>docker-compose 로 prometheus, grafana, influxDB 를 하나로 관리한다.</li>
<li>K6 는 각 로컬 환경에서 실행하는 것이 일반적이나, 테스트 스크립트 중앙화 및 조직 내 편의성을 위해 폐쇄망에서 Docker 로 설치한다.
로컬 PC 의 하드웨어 성능 제약을 벗어나려는 의도도 존재한다.
다만 Docker K6 는 컨테이너를 일회성으로 띄우는 방식이므로, 위 docker-compose 로 같이 관리하지 않고 docker run 명령어로 실행시킬 수 있게 한다.
물론 동시에 여러개의 컨테이너를 띄워 부하테스트를 수행하는 것도 가능하다.</li>
<li>각 Spring Boot 인스턴스들의 데이터를 바로 Influx DB 에 저장하지 않은 이유는
운영환경에서는 이미 Jennifer 모니터링 도구를 사용하고 있기 때문이다.
개발 환경에서 InfluxDB 에 데이터를 저장하는 request 를 보내는 코드가 운영환경에서 돌지 않기를 원했고, 결론적으로 Spring Boot Instance 들은 actuator endpoint 만 제공하고 Prometheus 에서 해당 API 를 Polling 하는 방식을 채택해 운영환경에서 불필요한 오버헤드가 발생하지 않게 설정했다.</li>
<li>다수의 구성원들이 작성한 테스트 결과들이 중첩되는 것을 방지하기 위해, Grafana 에서 K6 모니터링 Dashboard 를 조금 커스텀했다.</li>
<li>각 exporter 들과 Dashboard 들은 종류가 많아 사내 환경에 적합한 것을 임의적으로 선택했다.</li>
</ul>
<br>
<br>

<hr>
<br>
<br>

<h2 id="설치-과정">설치 과정</h2>
<h3 id="1-spring-boot-에-prometheus-메트릭-수집-활성화">1. Spring Boot 에 Prometheus 메트릭 수집 활성화</h3>
<pre><code># build.gradle.kts

dependencies {
    implementation(&quot;io.micrometer:micrometer-registry-prometheus&quot;)
}

# actuator 의 prometheus endpoint 노출하도록 해야 한다.
# 여기서는 /actuator 의 전체 endpoint 를 노출하게 했지만, 각자 필요에 맞게 설정한다.
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: &quot;*&quot;</code></pre><p>아래는 Postman 으로 /actuator/prometheus 을 호출한 예시이다.
json 형태가 아니라 일반 text 로 보냄에 참고하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/34565d44-b682-4cdf-827e-65dc6c442e36/image.png" alt=""></p>
<p>아래 정보들이 포함됐음을 확인 가능하다.</p>
<blockquote>
<p>기본적으로는 아래 내용을 포함하지만, rabbitmq 나 redis 와 같은 외부 도구를 사용할 경우 추가 metric 이 노출되는 것이 확인된다. 각자 Spring Boot 에서 /actuator/prometheus API 를 호출해보자.</p>
</blockquote>
<ol>
<li>애플리케이션 상태</li>
</ol>
<ul>
<li><code>application_ready_time_seconds</code>: 애플리케이션이 요청을 처리할 준비가 되기까지 걸린 시간.</li>
<li><code>application_started_time_seconds</code>: 애플리케이션이 시작되기까지 걸린 시간.</li>
</ul>
<ol start="2">
<li>디스크 사용량</li>
</ol>
<ul>
<li><code>disk_free_bytes</code>: 사용 가능한 디스크 공간 (바이트).</li>
<li><code>disk_total_bytes</code>: 전체 디스크 용량 (바이트).</li>
</ul>
<ol start="3">
<li>쓰레드 풀 관련 메트릭 (Executor)</li>
</ol>
<ul>
<li><code>executor_active_threads</code>: 현재 활성 상태인 쓰레드 개수.</li>
<li><code>executor_completed_tasks_total</code>: 완료된 작업의 총 개수.</li>
<li><code>executor_pool_core_threads</code>: 풀의 핵심 쓰레드 수.</li>
<li><code>executor_pool_max_threads</code>: 풀의 최대 쓰레드 수.</li>
<li><code>executor_pool_size_threads</code>: 현재 풀의 쓰레드 수.</li>
<li><code>executor_queue_remaining_tasks</code>: 큐에서 수용 가능한 작업의 남은 공간.</li>
<li><code>executor_queued_tasks</code>: 큐에 대기 중인 작업 수.</li>
</ul>
<ol start="4">
<li>HikariCP (JDBC Connection Pool)</li>
</ol>
<ul>
<li><code>hikaricp_connections</code>: 전체 커넥션 수.</li>
<li><code>hikaricp_connections_acquire_seconds</code>: 커넥션 획득 시간 통계.</li>
<li><code>hikaricp_connections_active</code>: 활성 상태 커넥션 수.</li>
<li><code>hikaricp_connections_idle</code>: 유휴 상태 커넥션 수.</li>
<li><code>hikaricp_connections_max</code>: 최대 커넥션 수.</li>
<li><code>hikaricp_connections_min</code>: 최소 커넥션 수.</li>
<li><code>hikaricp_connections_pending</code>: 대기 중인 스레드 수.</li>
<li><code>hikaricp_connections_timeout_total</code>: 타임아웃 발생 횟수.</li>
<li><code>hikaricp_connections_usage_seconds</code>: 커넥션 사용 시간 통계.</li>
</ul>
<ol start="5">
<li>HTTP 요청 메트릭</li>
</ol>
<ul>
<li><code>http_server_requests_seconds</code>: HTTP 요청 처리 시간 통계.</li>
<li><code>http_server_requests_active_seconds</code>: 활성 요청 처리 시간 통계.</li>
<li><code>http_server_requests_seconds_max</code>: 요청 처리 시간의 최대값.</li>
</ul>
<ol start="6">
<li>JDBC 커넥션 메트릭</li>
</ol>
<ul>
<li><code>jdbc_connections_active</code>: 활성 JDBC 커넥션 수.</li>
<li><code>jdbc_connections_idle</code>: 유휴 JDBC 커넥션 수.</li>
<li><code>jdbc_connections_max</code>: 최대 JDBC 커넥션 수.</li>
<li><code>jdbc_connections_min</code>: 최소 JDBC 커넥션 수.</li>
</ul>
<ol start="7">
<li>JVM 메모리 메트릭</li>
</ol>
<ul>
<li><code>jvm_memory_committed_bytes</code>: JVM이 커밋한 메모리.</li>
<li><code>jvm_memory_max_bytes</code>: JVM이 사용할 수 있는 최대 메모리.</li>
<li><code>jvm_memory_used_bytes</code>: JVM이 사용 중인 메모리.</li>
<li><code>jvm_memory_usage_after_gc</code>: GC 이후 사용 중인 메모리 비율.</li>
</ul>
<ol start="8">
<li>JVM 쓰레드 메트릭</li>
</ol>
<ul>
<li><code>jvm_threads_live_threads</code>: 현재 활성 쓰레드 수.</li>
<li><code>jvm_threads_daemon_threads</code>: 현재 활성 데몬 쓰레드 수.</li>
<li><code>jvm_threads_peak_threads</code>: JVM 시작 이후 최고 쓰레드 수.</li>
<li><code>jvm_threads_states_threads</code>: 각 상태별 쓰레드 수 (Runnable, Waiting 등).</li>
</ul>
<ol start="9">
<li>JVM 클래스 로딩 메트릭</li>
</ol>
<ul>
<li><code>jvm_classes_loaded_classes</code>: 현재 JVM에 로드된 클래스 수.</li>
<li><code>jvm_classes_unloaded_classes_total</code>: JVM 시작 이후 언로드된 클래스 수.</li>
</ul>
<ol start="10">
<li>JVM GC (Garbage Collection) 메트릭</li>
</ol>
<ul>
<li><code>jvm_gc_pause_seconds</code>: GC로 인한 일시 중단 시간 통계.</li>
<li><code>jvm_gc_memory_promoted_bytes_total</code>: 힙의 old generation으로 승격된 메모리 총량.</li>
<li><code>jvm_gc_memory_allocated_bytes_total</code>: GC 후 힙에 할당된 메모리 총량.</li>
</ul>
<ol start="11">
<li>JVM CPU 및 프로세스 메트릭</li>
</ol>
<ul>
<li><code>process_cpu_usage</code>: JVM 프로세스의 CPU 사용량.</li>
<li><code>process_cpu_time_ns_total</code>: JVM 프로세스의 CPU 사용 시간 (나노초).</li>
<li><code>process_start_time_seconds</code>: JVM 프로세스 시작 시간.</li>
<li><code>process_uptime_seconds</code>: JVM 프로세스 실행 시간.</li>
</ul>
<ol start="12">
<li>시스템 메트릭</li>
</ol>
<ul>
<li><code>system_cpu_count</code>: CPU 코어 수.</li>
<li><code>system_cpu_usage</code>: 시스템 CPU 사용률.</li>
</ul>
<ol start="13">
<li>로깅 메트릭</li>
</ol>
<ul>
<li><code>logback_events_total</code>: 로그 레벨별 발생 이벤트 수.</li>
</ul>
<ol start="14">
<li>Tomcat 세션 메트릭</li>
</ol>
<ul>
<li><code>tomcat_sessions_active_current_sessions</code>: 현재 활성 세션 수.</li>
<li><code>tomcat_sessions_active_max_sessions</code>: 최대 활성 세션 수.</li>
<li><code>tomcat_sessions_created_sessions_total</code>: 생성된 세션 총 수.</li>
<li><code>tomcat_sessions_expired_sessions_total</code>: 만료된 세션 총 수.</li>
<li><code>tomcat_sessions_rejected_sessions_total</code>: 거부된 세션 총 수.</li>
</ul>
<ol start="15">
<li>JVM 정보</li>
</ol>
<ul>
<li><code>jvm_info</code>: JVM의 버전 및 런타임 정보.</li>
</ul>
<br>
<br>



<h3 id="2-prometheus-grafana-influxdb-설치-및-설정">2. Prometheus, Grafana, InfluxDB 설치 및 설정</h3>
<p>사내 조직은 폐쇄망이라, Windows docker 에서 이미지를 받은 후, tar 파일로 변환 후 폐쇄망으로 이관해 다시 이미지로 변환하는 과정을 거친다.</p>
<p>폐쇄망에서 바로 이미지를 pull 받을 수 있는 경우에는 그럴 필요가 없으니 
바로 docker-compose.yml 파일로 이동하면 된다.</p>
<p>준비 환경</p>
<ul>
<li>Local PC : Windows, WSL2, Docker</li>
<li>폐쇄망 : Linux, Docker</li>
</ul>
<h4 id="2-1-local-pc">2-1. Local PC</h4>
<pre><code># docker image download

docker pull prom/prometheus:v3.0.1
docker pull grafana/grafana:11.3.1
docker pull influxdb:1.11.8

# docker image to tar
docker save -o prometheus.tar prom/prometheus:v3.0.1
docker save -o grafana.tar grafana/grafana:11.3.1
docker save -o influxdb.tar influxdb:1.11.8

cd ;
explorer.exe .
# 이후 열린 WSL 파일 탐색기에서 Window 로 파일 이관 → 폐쇄망으로 이관한다. 또는 바로 SFTP 로 파일 업로드를 해도 된다.</code></pre><br>


<h4 id="2-2-폐쇄망">2-2. 폐쇄망</h4>
<p>.tar 파일이 이관됐으면 이제부터는 폐쇄망에서 작업한다.</p>
<pre><code># 폐쇄망에서 아래 명령어로 tar 파일을 docker image 로 변환한다. .tar 파일을 저장한 위치를 지정해야 한다.

docker load -i /path/to/target/prometheus.tar
docker load -i /path/to/target/grafana.tar
docker load -i /path/to/target/influxdb.tar

# image 변환 확인
docker images</code></pre><br>

<h4 id="2-3-docker-composeyml">2-3. docker-compose.yml</h4>
<p>관리 편의성을 위해서 ${docker 외부에서 마운트할 디렉토리} 는 모니터링 관련 디렉토리를 따로 만들어서 마운트하자.</p>
<pre><code>version: &#39;3.7&#39;

services:
  prometheus:
    image: prom/prometheus:v3.0.1
    container_name: prometheus
    ports:
      - &quot;9090:9090&quot; # Prometheus 웹 UI
    volumes:
      -  ${docker 외부에서 마운트할 디렉토리}/prometheus.yml:/etc/prometheus/prometheus.yml
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정
    restart: always

  grafana:
    image: grafana/grafana:11.3.1
    container_name: grafana
    ports:
      - &quot;3000:3000&quot; # Grafana 웹 UI
    environment:
      - GF_SECURITY_ADMIN_USER=admin # Grafana 기본 사용자
      - GF_SECURITY_ADMIN_PASSWORD=admin # Grafana 기본 비밀번호
    volumes:
      - grafana-data:/var/lib/grafana
      - ${docker 외부에서 마운트할 디렉토리}/provisioning:/etc/grafana/provisioning # 프로비저닝 디렉토리
    depends_on:
      - prometheus
      - influxdb
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정
    restart: always

  influxdb:
    image: influxdb:1.11.8    # influxdb 2.x 버전은 k6 와 호환성이 떨어져 1.x 버전 중 최신으로 진행
    container_name: influxdb
    ports:
      - &quot;8086:8086&quot; # InfluxDB API
    environment:
      - INFLUXDB_DB=metrics # 기본 데이터베이스 이름
      - INFLUXDB_ADMIN_USER=admin
      - INFLUXDB_ADMIN_PASSWORD=admin
      - INFLUXDB_HTTP_AUTH_ENABLED=false    # Grafana DashBoard 에서 바로 접근 가능하도록 HTTP Auth 인증을 false 로 지정
    volumes:
      - influxdb-data:/var/lib/influxdb
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정
    restart: always

volumes:
  grafana-data:
  influxdb-data:

networks:                # 모니터링 전용 network 이름을 지정
  monitoring_network:
    driver: bridge
    name: monitoring_network</code></pre><br>

<h4 id="2-4-docker-외부에서-마운트할-디렉토리prometheusyml-파일-작성">2-4. ${docker 외부에서 마운트할 디렉토리}/prometheus.yml 파일 작성</h4>
<p>아래 scrape_configs 내부에 Spring Boot 인스턴스 별로 job 을 추가하자.
prometheus 자체 메트릭은 기본값으로 추가해두자.</p>
<pre><code># prometheus.yml
global:
  scrape_interval: 5s # 메트릭 수집 주기

scrape_configs:
  - job_name: &#39;test&#39;
    metrics_path: &#39;/actuator/prometheus&#39;
    static_configs:
      - targets: [&#39;localhost:8080&#39;]  # 

  - job_name: &#39;prometheus&#39;
    metrics_path: &#39;/metrics&#39;
    static_configs:
      - targets: [&#39;localhost:9090&#39;] # Prometheus 자체 메트릭</code></pre><br>

<h4 id="2-5-docker-외부에서-마운트할-디렉토리provisioningdatasourcesdatasourceyml-파일-작성">2-5. ${docker 외부에서 마운트할 디렉토리}/provisioning/datasources/datasource.yml 파일 작성</h4>
<pre><code># datasource.yml
apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090 # Prometheus 컨테이너 이름 사용
    isDefault: true

  - name: InfluxDB
    type: influxdb
    access: proxy
    url: http://influxdb:8086 # InfluxDB 컨테이너 이름 사용
    database: metrics
    user: admin
    password: admin
    jsonData:
      httpMode: POST   </code></pre><pre><code>docker compose up -d    # docker-compose.yml 이 존재하는 위치에서 실행
docker ps -a    # Grafana, Prometheus, InfluxDB 컨테이너 기동 확인</code></pre><br>
<br>

<h3 id="3-spring-boot-grafana-dashboard-적용">3. Spring Boot Grafana DashBoard 적용</h3>
<p>Grafana, Prometheus, InfluxDB 컨테이너들이 모두 기동이 완료되었다면, Grafana 에서 DashBoard 로 시각화해보자.</p>
<p>우선 Prometheus 에 metric 정보들이 잘 수집이 되는지 확인한다.
http://폐쇄망Host:3000/targets 으로 Prometheus 웹 UI 에 진입하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/9cac647f-5592-477b-a994-0bf060ffcb5d/image.png" alt=""></p>
<p>기본 prometheus metric 과, 별도로 Spring Boot 를 모니터링하기 위한 test job 이 5초 주기로 잘 수집되는 것을 확인할 수 있다.</p>
<p>이후 Grafana 에 접속한다.
초기 username/pw 는 docker-compose.yml 에 설정했던 값으로 진행한다. (admin/admin)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8d6bfcfa-2620-4d4b-ae75-eb2e12862d74/image.png" alt=""></p>
<p>이후 pw 변경까지 완료하면 아래와 같은 좌측 사이드 탭이 뜬다.
먼저 <code>Data sources</code> 탭에서 Promethues 와 InfluxDB 와의 Connection 이 잘 되는지 확인한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/be5f8346-085a-40a5-86cc-39f5e4046b1b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ba802856-1c4a-4b10-a551-9d5348988ff9/image.png" alt=""></p>
<p>이후 DashBoards 탭으로 진입해 시각화 템플릿을 import 하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/551bba55-3898-4810-aaa0-273f1f599bf0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8fa4b725-d2bf-47da-b9ac-0ab3bdcffe21/image.png" alt=""></p>
<p>여기서 DashBoard Id 나 Json 은 <a href="https://grafana.com/grafana/dashboards/">https://grafana.com/grafana/dashboards/</a>
에서 검색해서 가져온다.
Grafana 의 장점이 사용자가 많다보니 이런 오픈소스 템플릿이 잘 구비되어있다는 점이다.</p>
<p>사내에서는 Spring Boot 2.1 버전이 가장 많이 쓰이고 있으므로, 
<a href="https://grafana.com/grafana/dashboards/11378-justai-system-monitor/">https://grafana.com/grafana/dashboards/11378-justai-system-monitor/</a>
위 Grafana Labs solution 에서 직접 제공하는 Spring Boot 2.1 버전용 DashBoard template 을 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c79bb579-1903-49d0-a163-65ff9e227f1d/image.png" alt=""></p>
<p>외부망과 연결이 되는 상태라면 Copy ID 로 설치한 Grafana 에서 ID 만 넣으면 되고, 연결이 되지 않는 상태라면 .json 을 다운받아 코드를 복사해 붙여넣고 Load 하면 된다.</p>
<p>이후 아래 화면에서 연결할 data source 를 prometheus 로 지정하고 Import 한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dff8e07b-bbfd-45b2-b2e5-0a922c3899f2/image.png" alt=""></p>
<p>그러면 아래와 같이 시각화되는 모습을 볼 수 있다.
Instance 탭에는 prometheus.yml 파일에서 지정했던 targets 을 선택해서 원하는 Spring Boot 인스턴스의 메트릭 정보를 확인하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/202a3c59-825f-48dc-bcdf-59db741e4860/image.png" alt=""></p>
<br>
<br>


<h3 id="4-k6-부하테스트-설치-및-grafana-dashboard-적용">4. K6 부하테스트 설치 및 Grafana DashBoard 적용</h3>
<p>앞서 언급했다시피, Docker K6 는 일회성으로 컨테이너가 생성되다보니, docker-compose.yml 로 통합시키지 않고 Docker run 으로 실행시키고자 한다.</p>
<p>폐쇠망의 경우, 역시 위 Windows 에서 진행했던 것과 같이 image 를 pull 하고 tar 파일로 변환해 폐쇄망으로 이관 후 다시 image 로 변환하는 과정을 거치자.</p>
<h4 id="41-k6-image-pull-후-변환-해-이관-역변환">4.1 k6 image pull 후 변환 해 이관, 역변환</h4>
<pre><code>docker pull grafana/k6:0.55.0
docker save -o k6.tar grafana/k6:0.55.0

cd ;
explorer.exe .

# 이후 아까와 같이 .tar 파일을 폐쇄망으로 이관한다.
# load 시 .tar 파일을 저장한 위치를 지정해야 한다.

docker load -i /path/to/target/k6.tar
docker images  #설치 확인</code></pre><h4 id="42-docker-외부에서-마운트할-디렉토리load-test팀명test-scriptjs-작성">4.2 <code>${docker 외부에서 마운트할 디렉토리}/load-test/${팀명}/test-script.js</code> 작성</h4>
<p>기본적으로 k6 테스트 스크립트는 Javascript 로 작성한다. 
(Javascript Interpreter 를 통해 런타임에서는 Go 엔진으로 동작한다.)
크게 어려울 것은 없고 자세한 스크립트 작성법은 
<a href="https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95">K6 부하테스트 스크립트 작성법</a>
에서 설명한다.</p>
<pre><code>// #test-Script.js
import http from &#39;k6/http&#39;;
import { sleep } from &#39;k6&#39;;

export let options = {
    // InfluxDB 저장 시에 이러한 테스트 스크립트로 실행되었다 라는 것을 명시하기 위해 작성한 커스텀 필드를 작성한다.
    // Grafana 에서 시각화 시 여러 개가 중첩되어 보이는 것을 방지하기 위함이고, DashBoard 역시 커스텀해야 한다. 
    // 이 커스텀 필드는 K6 Grafana DashBoard 에서 추가로 설명한다.
    tags: { test_name: &quot;test-script-1&quot; }, // 태그 추가
};

export default function () {
    // 테스트할 API 를 지정하고, group 명을 지정한다.
    // 만약 Linux 에서 Spring Boot 인스턴스가 docker 외부의 localhost 에 존재한다면, localhost -&gt; 172.17.0.1 으로 대체한다.
    // Windows 또는 Mac 환경이라면 host.docker.internal 로 대체.
    group(&#39;POST /test&#39;, function () {
        const res = http.get(&#39;http://localhost:8080/test&#39;);
        sleep(1);    
    }
}</code></pre><br>

<h4 id="43-k6-test-scriptjs-실행">4.3 K6 test-script.js 실행</h4>
<p>이제 K6 스크립트를 Docker 로 실행해보자.</p>
<pre><code>docker run --rm --network monitoring_network \
  -v ${docker 외부에서 마운트할 디렉토리}/load-test/:/scripts grafana/k6:0.55.0 run \
  --out influxdb=http://influxdb:8086/metrics \
  /scripts/${팀명}/test-script.js</code></pre><p>명령어를 하나하나 살펴보면 이렇다.</p>
<ul>
<li>--rm : K6 컨테이너는 일회성이라 실행이 끝나면 컨테이너가 자동으로 종료되지만, 삭제되지는 않아 디스크 남용을 방지하기 위해 삭제를 명시한다.\</li>
<li>--network monitoring_network : 이전 docker-compose.yml 에 명시했던 docker network 에서 influxDB 와의 connection 을 위함</li>
<li>-v : 스크립트를 docker 외부에서 설정하고 끌어오기 때문에 마운트 설정</li>
<li>--out influxdb=<a href="http://influxdb:8086/metrics">http://influxdb:8086/metrics</a> : 테스트 결과를 InfluxDB 로 저장</li>
<li><code>/scripts/${팀명}/test-script.js</code> : 마운트한 디렉토리에서 (${docker 외부에서 마운트할 디렉토리}/load-test) ${팀명}/test-script.js 를 실행함을 알린다.</li>
</ul>
<p>각 개발자는 <code>${docker 외부에서 마운트할 디렉토리}/load-test/${팀명}</code> 디렉토리에서 스크립트를 작성하고, 위 명령어에서 ${팀명}/test-script.js 대신 팀명과 본인이 작성한 스크립트 명을 넣기만 하면 된다.</p>
<p>터미널에서 실행한 K6 스크립트 결과는 아래 예시와 같이 출력된다.
참고로, K6 는 진행상황과 결과를 InfluxDB 에 1초마다 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ca24afb4-b638-44c0-b0fe-d3dd27cb1b02/image.png" alt=""></p>
<br>

<h4 id="44-influx-db-저장-확인">4.4 Influx DB 저장 확인</h4>
<p>Grafana 로 시각화 전에 Influx DB 에 정상적으로 저장됐는지 확인해보자.</p>
<pre><code># docker influxdb 컨테이너 내부로 진입, influx 명령어 사용
docker exec -it influxdb influx

# DATABASES 목록 확인
SHOW DATABASES

# 결과 예시
# docker-compose.yml 의 influxdb 에서 INFLUXDB_DB=metrics 을 설정했음을 기억하자.
# name: databases
# name
# ----
# metrics
# _internal


# metrics DATABASE 사용
USE metrics


# MEASUREMENT 목록 확인. 수집된 컬럼들이 존재해야 한다.
SHOW MEASUREMENTS

# 결과 예시
# name: measurements
# name
# ----
# data_received
# data_sent
# http_req_blocked
# http_req_connecting
# http_req_duration
# http_req_failed
# http_req_receiving
# http_req_sending
# http_req_tls_handshaking
# http_req_waiting
# http_reqs
# iteration_duration
# iterations
# vus
# vus_max


# test-script.js 에서 설정한 team, test_name, group 이라는 tag 값이 잘 저장되었는지 확인
SHOW TAG VALUES WITH KEY = &quot;team&quot;
SHOW TAG VALUES WITH KEY = &quot;test_name&quot;
SHOW TAG VALUES WITH KEY = &quot;group&quot;


# 저장된 값 중 상위 10개 확인 예시 (MEASUREMENT 목록 중 하나를 선택)
SELECT * FROM http_req_connecting LIMIT 10

# 결과 예시
# name: http_req_connecting
# time                expected_response method name                        proto    scenario status test_name     tls_version url                         value
# ----                ----------------- ------ ----                        -----    -------- ------ ---------     ----------- ---                         -----
# 1732982789752285597 true              GET    http://httpbin.test.k6.io   HTTP/1.1 default  308    test-script-1             http://httpbin.test.k6.io   1.085468
# 1732982790340330238 true              GET    https://httpbin.test.k6.io/ HTTP/1.1 default  200    test-script-1 tls1.3      https://httpbin.test.k6.io/ 1.010407</code></pre><br>

<h4 id="45-grafana-k6-dashboard-적용">4.5 Grafana K6 DashBoard 적용</h4>
<p>기본적으로는 <a href="https://grafana.com/grafana/dashboards/2587-k6-load-testing-results/">https://grafana.com/grafana/dashboards/2587-k6-load-testing-results/</a>
템플릿을 사용하려 했으나, 테스트 결과가 중첩되는 문제가 발생해 템플릿을 조금 커스텀했다.</p>
<p>DashBoard 의 variabels 에 team, test_name, group 을 추가하고,
SHOW TAG VALUES WITH KEY = &quot;team&quot; 
SHOW TAG VALUES WITH KEY = &quot;test_name&quot; 
SHOW TAG VALUES WITH KEY = &quot;group&quot; 
값을 넣었다.
이후 DashBoard 의 각 패널에서 test_name 변수 값을 기준으로 아래와 같은 WHERE 조건문을 넣었다.
<code>WEHRE team =~ /^$team$/ AND test_name =~ /^$test_name$/ AND \&quot;group\&quot; =~ /^$group$/</code></p>
<p>그래서 완성된 json 파일은 아래 Github 에 넣어두었다.
<code>k6 Load Testing Results-with-test_name.json</code> 코드를 복붙하면 된다.</p>
<p><a href="https://github.com/isckd/integration-monitoring/blob/main/grafana-custom-dashboard/k6%20Load%20Testing%20Results-with-test_name.json">https://github.com/isckd/integration-monitoring/blob/main/grafana-custom-dashboard/k6%20Load%20Testing%20Results-with-test_name.json</a></p>
<p>json 파일을 기준으로 DashBoard 를 import 하는 것은 위에서 이미 설명했으므로 생략한다.
import 가 완료되었다면 아래와 같은 화면이 출력된다.</p>
<blockquote>
<p>내가 커스텀한 것은 team, test_name, group 이라는 변수 값으로, 강조한 박스 안에서 원하는 tean, test_name, group 태그를 선택하면 해당 결과만 출력할 수 있다.
또한 기존 템플릿의 Error Per Second 패널가 보이지 않는 이슈를 해결하고,
최상단에는 총 Http request 수, failed 수, data sent, data received 를,
최하단에는 URL 별로 http_req_duration 값을 Table 형태로 노출시켰다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/bf4701ac-ae69-4b72-a27d-02633aa261f9/image.png" alt=""></p>
<p>DashBoard 를 어떻게 커스텀했는지는 아래에 작성한다.</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="grafana-dashboard-커스텀-방법-변수-지정">Grafana DashBoard 커스텀 방법 (변수 지정)</h2>
<p>Grafana DashBoard 커스텀 방법을 알아보자. (내용이 많아 변수 지정만 설명한다.)
크게는 두 가지로 나뉜다.</p>
<ul>
<li>UI 에서 변경하는 방법</li>
<li>Json 코드를 변경하는 방법</li>
</ul>
<p>UI 에서 변경하면, 자동으로 Json 코드도 변경된다.
단순 반복적인 InfluxDB 쿼리 변경이라고 하면, UI 에서 필요없이 Json 코드에서 변경해도 무방하다.</p>
<p>내가 커스텀한 내용을 기반으로 진행해보자.
필요한 것은 K6 테스트 스크립트 별로 유니크한 태그 값이 필요한 상황이므로, 
K6 테스트 스크립트 안에 tag 값을 집어넣는다.</p>
<pre><code>// #test-Script.js
import ...

export let options = {
    tags: {                            // 태그 추가
      team : &#39;server2&#39;,
      test_name: &#39;test-script-2&#39; 
      }, 
};

export default function () {
  group(&#39;GET /api/books&#39;, function () {
      ...
  }
  group(&#39;POST /api/books&#39;, function () {
      ...
  }
}</code></pre><p>이 team, test_name, group 이라는 InfluxDB 값이 저장되었으므로, Grafana 에서 불러와야 한다.
K6 Grafana DashBoard 에 진입해 우측 상단의 Edit -&gt; Settings 에 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/23b290a8-c73e-4241-9645-4c018b276fcd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/75dc8f3c-d705-4c03-b7d8-363526d00057/image.png" alt=""></p>
<p>이후 Variables 탭 -&gt; New variable 으로 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/01964b5d-39b5-4ca2-9da9-8cb6aec94e9d/image.png" alt=""></p>
<p>아래 번호에 맞게 진행한다. 여기서는 test_name 만 진행했지만, team 과 group 도 반복해 진행하자.</p>
<ol>
<li>InfluxDB 에서 Query 로 가져올 것이므로 Query 를 선택한다.</li>
<li>변수의 명을 지정한다.</li>
<li>Data source 를 InfluxDB 로 지정한다.</li>
<li>변수들을 가져올 쿼리명을 지정한다. 이번에는 
<code>SHOW TAG VALUES WITH KEY = &quot;test_name&quot;</code> 와 같이 TAG 를 가져온다.</li>
<li>DashBoard 상단의 변수 선택에서 정렬을 어떻게 할 건지를 지정한다. 입맛에 맞게 진행한다.</li>
<li>Multi-value : 다중 선택이 가능한지를 묻는다.
Include All option : All(전체 선택) 옵션이 가능한지를 묻는다.</li>
<li>현재 DashBoard 에 변수로 보여질 값들이 노출된다. 6. 번에서 All 옵션을 선택했으므로 All 변수도 추가된다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b042d7b2-06eb-4ffc-93c2-4ef1b1185c24/image.png" alt=""></p>
<p>** group 변수의 Query 는 아래와 같이 진행하자. 확인해보니 ::setup, ::teardown 과 같은 메서드들도 group 에 포함되니 정규식으로 제거하자.
<code>SHOW TAG VALUES WITH KEY = &quot;group&quot; WHERE &quot;group&quot; !~ /^::(setup|teardown)$/</code> **</p>
<br>

<p>다시 DashBoard 탭으로 돌아와서, 아직 Save dashboard 로 따로 저장하지 않은 상태임에도 Grafana에서 저장 전 실시간 DashBoard 업데이트한 화면을 보여준다. 
아래 화면과 같이 test_name 이라는 변수들이 잘 노출됨을 보여준다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/4624b47f-6b0d-47fc-8d62-98f825db7bd4/image.png" alt=""></p>
<p>아직 끝이 아니다. 각 패널들에 변수 WHERE 조건을 추가해주어야 한다.
각 패널들도 결국 InfluxDB 에서 값을 조회해서 노출해주는 것일 뿐이다.
먼저 패널 하나를 선택해 쿼리를 지정하는 방법을 알아보자.</p>
<h3 id="1-ui-에서-패널별로-커스텀하는-방법">1. UI 에서 패널별로 커스텀하는 방법</h3>
<p>패널에 마우스를 올리면 메뉴 바가 노출되고, 그것을 클릭해 Edit 탭으로 진입한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5eaa9488-738c-4298-b516-267e0a05c5d8/image.png" alt=""></p>
<p>이후 쿼리 수정 버튼을 눌러 쿼리를 수정하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e826660e-c159-4d91-89fe-7ae93f592e45/image.png" alt=""></p>
<p>기존에는 <code>SELECT mean(&quot;value&quot;) FROM &quot;vus&quot; WHERE $timeFilter GROUP BY time($__interval) fill(none)</code> 처럼 되어 있었지만, 
여기서 WHERE 절 뒤에<code>test_name =~ /^$test_name$/ AND</code> 절을 추가하자.
결론적으로 
<code>SELECT mean(&quot;value&quot;) FROM &quot;vus&quot; WHERE test_name =~ /^$test_name$/ AND $timeFilter GROUP BY time($__interval) fill(none)</code>
와 같이 수정하면 된다.</p>
<p>이후 상단의 test_name 변수값을 조정하며 정상적으로 노출되는지 확인한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/64db4c7b-5590-4375-85d4-5693c118689b/image.png" alt=""></p>
<br>

<h3 id="2-json-에서-일괄-적용하는-방법">2. Json 에서 일괄 적용하는 방법</h3>
<p>다시 Settings 탭으로 돌아와 JSON Model 탭에서 Json 코드를 수정해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e8bfeda7-a215-4fc6-b18c-a5182a52c513/image.png" alt=""></p>
<p>단순 작업이므로 JSON 코드에서 <code>WHEHE</code> 이라는 문자열을
<code>team =~ /^$team$/ AND test_name =~ /^$test_name$/ AND \&quot;group\&quot; =~ /^$group$/</code> 으로 일괄 변경하고 저장하자.
저장은 좌측 하단의 Save dashboard 로 저장해야 한다.</p>
<blockquote>
<p>내가 커스텀한 panel 들 중 아래 세 개는 Group 설정이 적용되지 않아 InfluxDB 쿼리에서 조건을 제거했음을 참고하자. K6 자체에서 아래 메타데이터들은 Group 설정이 적용되지 않는다.</p>
</blockquote>
<ul>
<li>Data Sent</li>
<li>Data Received</li>
<li>virtual Users</li>
</ul>
<p>Grafana DashBoard 커스텀 방법 중 변수 설정만 작성했지만,
이외 커스텀한 panel 을 만드는 방법은 기능이 워낙 많고 복잡해서 이 글 안에 전부 소개하기에는 무리가 있다.</p>
<p>차후 기회가 된다면 DashBoard 커스텀 방법을 소개할 예정이다.
그 전에 비슷한 화면을 구현하고자 한다면, 
<a href="https://grafana.com/docs/grafana/latest/dashboards/">https://grafana.com/docs/grafana/latest/dashboards/</a>
위 문서를 참고하거나 panel 들을 복사해서 사용하길 바란다.
참고로 내용이 너무 방대해서 학습하는 데 시간을 쏟는게 조금 아깝기는 하다..</p>
<br>
<br>

<hr>
<br>
<br>


<h2 id="이외-추가-exporter-적용사항">이외 추가 Exporter 적용사항</h2>
<p>위 Spring Boot 외 사내 모니터링할만한 도구들은 아래와 같았다.</p>
<ul>
<li>rabbitmq</li>
<li>oracle</li>
<li>redis</li>
<li>kafka</li>
<li>mysql</li>
<li>elasticsearch</li>
</ul>
<p>위와 같은 오픈소스들의 metric 들을 수집하기 위해 가장 일반적인 방법이, exporter 를 활용하는 방법이다.
각 도구들의 metric 들을 수집하는 exporter 인스턴스를 띄우고, Prometheus 에서 일괄적으로 metric 들을 수집한 다음 Grafana 에서 시각화 하는 구조이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/42ad75c6-4e04-4cd4-b9bd-610e927c3884/image.png" alt=""></p>
<blockquote>
<p>규모가 큰 오픈소스들 (ex : Redis, Elasticsearch, Kafka ...) 들은 Exporter 필요 없이 바로 Prometheus 로 수집이 가능하지만, Exporter 를 적용한 이유를 아래에서 설명한다. <br>
RabbitMQ 만 Exporter 없이 구성했다.</p>
</blockquote>
<p>이와 같이 적용하는 방법을 알아보자.</p>
<blockquote>
<p>위 Grafana, InfluxDB, Prometheus, K6 설치과정에서는 폐쇄망이라 외부에서 docker pull 후 tar 파일로 변환 후 이관, 역변환해 하는 과정이 있었지만 이를 일일이 언급하면 내용이 길어져 아래에서는 생략한다.</p>
</blockquote>
<p>각자 필요한 도구들만 선정해 아래 예시와 같이 오픈소스 도구 metric 수집용 docker-compose.yml 을 작성한다.</p>
<br>

<h3 id="exporter-구성을-위한-docker-composeyml">exporter 구성을 위한 docker-compose.yml</h3>
<pre><code>services:
  oracledb-exporter:
    image: ghcr.io/iamseth/oracledb_exporter:0.6.0
    container_name: oracledb-exporter
    ports:
      - &quot;9161:9161&quot;  # Oracle Exporter 메트릭 엔드포인트
    environment:
      # 특수문자는 인식하지 못하므로 인코딩해 넣어야 한다.
      - DATA_SOURCE_NAME=oralce://${Root권한계정명}:${Root권한계정PW}@${ORACLE_DB_HOST}:${ORACLE_DB_PORT}/${ORACLE_DB_SERVICE_NAME}
    volumes:
      # 커스텀한 metric 수집을 위해 작성한 쿼리파일을 mount    
      - ./oracle/metrics.yaml:/etc/oracledb_exporter/metrics.yaml
    command: [&quot;--custom.metrics&quot;, &quot;/etc/oracledb_exporter/metrics.yaml&quot;]
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정    
    restart: unless-stopped      

  redis-exporter:
    image: oliver006/redis_exporter:v1.66.0
    container_name: redis-exporter
    ports:
      - &quot;9121:9121&quot;
    environment:
      - REDIS_ADDR=${REDIS_HOST}:${REDIS_PORT}
      - REDIS_PASSWORD=${REDIS_PW}
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정      

  kafka-exporter:
    image: danielqsj/kafka-exporter:v1.8.0
    container_name: kafka-exporter
    ports:
      - &quot;9308:9308&quot;
    command: [&quot;--kafka.server=${KAFKA_HOST}:${KAFKA_POERT}&quot;]
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정    
    extra_hosts:
      - &quot;${KAFKA_HOST_매핑된_NAME}:${KAFKA_HOST}&quot;      # 컨테이너 내부에서 인식 못하는 ${KAFKA_HOST_매핑된_NAME} 를 ${KAFKA_HOST} 으로 host mapping      

  mysqld-exporter:
    container_name: mysqld-exporter
    image: prom/mysqld-exporter:v0.16.0
    ports:
      - 9104:9104
    command:
      - &quot;--mysqld.username=${MYSQL_ROOT_계정명}:${MYSQL_ROOT_계정_PW}&quot;
      - &quot;--mysqld.address=${MYSQL_HOST}:${MYSQL_PORT}&quot;      
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정      

  elasticsearch-exporter:
    image: quay.io/prometheuscommunity/elasticsearch-exporter:v1.8.0
    container_name: elasticsearch-exporter
    ports:
      - &quot;9114:9114&quot;
    command:
      - &#39;--es.uri=https://${ELASTICSEARCH_ROOT_계정명}:${ELASTICSEARCH_ROOT_PW}@${ELASTICSEARCH_HOST}:${ELASTICSEARCH_PORT}&#39;      # PW 특수문자로 인해 인코딩
      - &#39;--es.ca=/certs/ca.crt&#39;  
      - &#39;--log.level=info&#39;       
    volumes:
      - ./elasticsearch/certs:/certs:ro              # Elasticsearch CA 인증서를 컨테이너에 마운트
    networks:
      - monitoring_network    # 모니터링 전용 network 이름을 지정      
    extra_hosts:
      - &quot;${ELASTICSEARCH_HOST_매핑된_NAME}:${ELASTICSEARCH_HOST}&quot;      # 컨테이너 내부에서 인식 못하는 ${ELASTICSEARCH_HOST_매핑된_NAME} 를 ${ELASTICSEARCH_HOST} 으로 host mapping      

networks:                # 모니터링 전용 network 이름을 지정
  monitoring_network:
    external: true</code></pre><br>

<p>특이사항은 아래와 같다.</p>
<ul>
<li>기존 prometheus, influxDB, grafana 등이 존재하는 docker compose 의 네트워크와 연동한다.</li>
<li>container 내부에서 host name 매핑이 필요한 경우 (kafka, elasticsearch) 등은 extra_hosts 로 적용한다.</li>
<li>elasticsearch 의 경우 8.x ver 부터 ssl/tls 인증이 필수이므로, 인증서 파일 (.crt) 파일이 필요하다.</li>
<li>environment: 변수가 아닌 command: 에 변수를 넣는 경우 특수문자를 지원하지 않으므로 인코딩해 넣어야 한다.</li>
</ul>
<br>

<p>각 exporter 를 선정한 기준은 아래와 같다.</p>
<ul>
<li><code>oracledb-exporter</code> : Oracle DB 모니터링 툴 검색 결과 오픈소스들이 거의 없었다. 애초에 OracleDB 는 enterprise 용으로 많이 쓰여서 그런 것으로 예상된다.
그래서 Prometheus 와 연동되고, Grafana DashBoard 가 존재하는 oracle-exporter 를 <a href="https://github.com/iamseth/oracledb_exporter">https://github.com/iamseth/oracledb_exporter</a> 에서 선택했다.
그나마 Github Star 수가 높고, 내가 커스텀한 SQL 로 Metric 들을 수집할 수 있다는 것에 선택했다.</li>
<li><code>redis-exporter</code> : Redis 는 기본적으로 Grafana 자체에서 Prometheus 없이 기초적인 metric 수집이 가능하다. 하지만 실시간 metric 만 수집할 뿐 과거 데이터는 Redis 자체에서 보유하고 있지 않으므로 상당히 제한적인 정보만 얻을 수 있었다.
그래서 그런지 redis-exporter 관련해 <a href="https://github.com/oliver006/redis_exporter">https://github.com/oliver006/redis_exporter</a> 를 보면 사용자가 꽤 많은 것을 확인할 수 있었고, 이를 택했다.</li>
<li><code>kafka-exporter</code> : kafka 는 사실 Grafana 에서 모니터링하는 것보다 플러그인으로 제공하는 모니터링 툴을 사용하는 것이 더 일반적이다. 그래도 Grafana 에서 시각화해보기 위해 이것저것 방법을 알아본 결과, Kafka 에서 Kminion 으로 Kafka 도메인 수준의 정보를 모니터링 + jmx 로 JVM 수준의 모니터링으로 시각화하는 방법이 존재했다.
하지만 Kminion, jmx 설정을 적용하기 위해선 Kafka 설정 변경 후 재기동해야되는데, 나에게는 Kafka 서버 접근 권한이 없어 이 방법은 제한되었다.
어쩔 수 없이 kafka 에서 기본적으로 제공하는 제한적인 정보들만 수집하는 <a href="https://github.com/danielqsj/kafka_exporter">https://github.com/danielqsj/kafka_exporter</a> 를 택했다.</li>
<li><code>mysqld-exporter</code> : MySQL 은 오픈소스인 만큼 많은 Metric 수집 도구들이 존재했다. 그 중에서 prometheus 커뮤니티에서 제공하는 <a href="https://hub.docker.com/r/prom/mysqld-exporter">https://hub.docker.com/r/prom/mysqld-exporter</a> 를 택했다.</li>
<li><code>elasticsearch-exporter</code> : Elasticsearch Stack 중에 메트릭을 수집해 시각화 하는 도구가 자체적으로 존재하기도 하지만, 이는 기존 설정을 변경 후 재설치 해야되는 과정이 있으므로 제외한다. 또한 Elasticsearch 는 Grafana 와의 호환성이 매우 좋아 Prometheus 연동없이 Grafana 내부에서 Datasource conneciton 으로 바로 연동해 모니터링이 가능하다.
하지만 현 폐쇄망에 설치된 Elasticsearch 는 7.9 ver 이고, 이번에 설치한 Grafana 11.3.1 에서는 7.15 ver 이상만 지원하는 바람에 어쩔 수 없이 elasticsearch-exporter 를 사용했다.
만약 다른 환경이라면, Prometheus 없이 바로 시각화 하는 방법을 추천한다. 내가 선택한 exporter 는 <a href="https://quay.io/repository/prometheuscommunity/elasticsearch-exporter">https://quay.io/repository/prometheuscommunity/elasticsearch-exporter</a> 이다.</li>
</ul>
<br>

<p>rabbitmq 는 Grafana 와의 호환성이 뛰어나고, 재기동 필요없이 플러그인 설정 적용만해도 바로 시각화가 가능하다. 
<a href="https://grafana.com/grafana/dashboards/10991-rabbitmq-overview/">https://grafana.com/grafana/dashboards/10991-rabbitmq-overview/</a>
에서는 RabbitmQ 3.8.0 이상의 버전부터는 기본적으로 Prometheus 플러그인이 내장되어있다고 기재되어있다.</p>
<p>아래 그 방법을 소개한다.
linux 유저의 권한이 sudo 를 가지고 있거나, rabbitmq 라는 유저명으로 실행할 수 있는 환경이여야 한다.</p>
<pre><code># 아래에서 rabbitmq_prometheus 플러그인을 먼저 downlaod 한다.
https://github.com/rabbitmq/rabbitmq-server/releases/download/${rabbitmq_version}/rabbitmq_prometheus-${rabbitmq_version}.ez

# 해당 파일을 rabbitmq plugin 디렉토리로 이관한다.
sudo mv rabbitmq_prometheus-{rabbitmq_version}.ez /usr/lib/rabbitmq/lib/rabbitmq_server-{rabbitmq_version}/plugins/

# rabbitmq_prometheus 플러그인을 활성화한다.
rabbitmq-plugins enable rabbitmq_prometheus</code></pre><br>

<h3 id="exporter---prometheus-metric-수집을-위한-prometheusyml">Exporter -&gt; Prometheus Metric 수집을 위한 prometheus.yml</h3>
<p>위 exporter 및 rabbitmq metric 들을 prometheus 에서 수집하기 위한 prometheus.yml 을 작성한다.
<code>promteheus.yml 에서는 OS 의 .env 와 같은 변수들이 적용되지 않으므로 참고하자.</code></p>
<pre><code>global:
  scrape_interval: 5s # 메트릭 수집 주기

scrape_configs:

# Spring Boot 인스턴스, prometheus 기본 metric 등 나머지 내용들...
...

  - job_name: &#39;rabbitmq&#39;
    static_configs:
      - targets: [&#39;${RABBITMQ_HOST}:15692&#39;]  # ${RABBITMQ_HOST} : rabbitmq 의 prometheus 전용 port      

  - job_name: &#39;oracle&#39;
    metrics_path: &#39;/metrics&#39;
    static_configs:
      - targets: [&#39;oracledb-exporter:9161&#39;]  # Oracle Exporter가 실행 중인 호스트와 포트    

  - job_name: &#39;redis&#39;
    metrics_path: &#39;/metrics&#39;
    static_configs:
      - targets: [&#39;redis-exporter:9121&#39;]

  - job_name: &#39;kafka&#39;
    metrics_path: &#39;/metrics&#39;
    static_configs:
      - targets: [&#39;kafka-exporter:9308&#39;]      

  - job_name: &#39;mysql&#39;
    static_configs:
      - targets: [&#39;mysqld-exporter:9104&#39;]

  - job_name: &#39;elasticsearch&#39;
    static_configs:
      - targets: [&#39;elasticsearch-exporter:9114&#39;]      </code></pre><br>

<h3 id="exporter-와-연동되는-grafana-dashboard">exporter 와 연동되는 Grafana Dashboard</h3>
<p>Grafana Dashboard 들은 그 종류와 수가 많다.
내가 선택한 Dashboard 말고도 다양한 것이 존재하니, 어떠한 DataSource (prometheus, InfluxDB ...) 를 사용하는지와 버전 호환성 확인 후 다른 것을 골라도 무방하다.</p>
<h4 id="oracledb">oracledb</h4>
<p>사내에서는 OracleDB 집중화 되어있어, Dashboard 를 기반으로 필요한 정보들을 커스텀했다.
Dashboard ref : <a href="https://grafana.com/grafana/dashboards/13555-oracledb-monitoring-performance-and-table-space-stats/">https://grafana.com/grafana/dashboards/13555-oracledb-monitoring-performance-and-table-space-stats/</a></p>
<p>위 docker-compose.yml 의 oracledb-exporter 컨테이너 설정 내부에 아래처럼 적었었다. </p>
<pre><code>    volumes:
      # 커스텀한 metric 수집을 위해 작성한 쿼리파일을 mount    
      - ./oracle/metrics.yaml:/etc/oracledb_exporter/metrics.yaml</code></pre><p>이 설정은 기본적으로 exporter 에서 제공되는 metric 외 추가적으로 내가 원하는 것들을 SQL 로 조회해서 prometheus 에 저장할 수 있게 하는 외부의 설정파일을 mount 하겠다는 의미이다.</p>
<p>해당 exporter 는 custom metric 수집을 위해 .toml, .yaml 파일 설정을 지원하는데, 익숙한 .yaml 으로 설정했다.
<a href="https://github.com/iamseth/oracledb_exporter/blob/master/custom-metrics-example/custom-metrics.yaml">https://github.com/iamseth/oracledb_exporter/blob/master/custom-metrics-example/custom-metrics.yaml</a>
가 그 .yaml 파일 설정 예시이다.</p>
<p>해당 github 에는 각 필드들에 대한 설명이 없어 직접 시행착오를 겪으면서 깨달은 의미를 간략하게 설명한다.</p>
<ul>
<li>context: Prometheus 에 저장될 때 붙을 이름의 prefix</li>
<li>metricsdesc: 좌측의 값은 Prometheus 에 저장될 때 붙을 이름의 suffix. request 에서 조회된 값을 매칭해 실제 값을 <code>외부에</code> 저장하며, Prometheus 특성 상 실제 값에는 문자열이 들어가지 못하고, 숫자만 들어갈 수 있음에 유의하자.
metricsdesc 가 여러개면 그 개수만큼 데이터 row가 생성된다.
우측의 값은 해당값의 description 을 의미한다.</li>
<li>labels: Prometheus 에 저장될 때 <code>내부에</code> 저장될 labels 값. request 에서 조회된 값을 매칭해 실제 값을 <code>내부에</code> 저장하며, 문자열도 저장이 가능하다.
labels 가 여러개여도 하나의 데이터 row 안에 들어간다.</li>
<li>request: 실제 조회할 SQL 을 의미한다. 추출된 값들은 metricsdesc, labels 에 매핑된다.</li>
</ul>
<p>아래 Prometheus 데이터 값으로 간단한 예시를 보자.
<code># HELP</code> 에는 metricsdesc 내부 각각의 값에서 우측의 값이 들어간다.
<code>job, instance</code> 는 각각 labels 들이다. 하나의 row 에 여러 개의 값이 들어간다.
<code>85.7</code>, <code>65.3</code> 은 각각 row 에 대한 숫자 값이다. </p>
<pre><code># HELP cpu_usage CPU usage in percentage
# TYPE cpu_usage gauge
cpu_usage{job=&quot;app-server&quot;, instance=&quot;10.0.0.1&quot;} 85.7
cpu_usage{job=&quot;app-server&quot;, instance=&quot;10.0.0.2&quot;} 65.3</code></pre><p>내가 직접 커스텀한 grafana dashboard json 파일은 아래에 넣어두었다.</p>
<p><a href="https://github.com/isckd/integration-monitoring/blob/main/grafana-custom-dashboard/OracleDB%20Monitoring%20-%20performance%20and%20table%20space%20stats-1734088562418.json">https://github.com/isckd/integration-monitoring/blob/main/grafana-custom-dashboard/OracleDB%20Monitoring%20-%20performance%20and%20table%20space%20stats-1734088562418.json</a></p>
<p>아래는 내가 커스텀한 DashBoard 를 캡처한 화면이다.
<code>Custom Panel -</code> 로 시작하는 Panel 은 내가 커스텀한 것이다.
문제 요소가 될 만한 것들은 캡처 이미지에서 제외했다.</p>
<p>Oracle 과 같은 RDMBS 에서 성능적으로 중요하게 봐야 할 요소들이 많다.
SQL 캐싱이 잘 되었는지, full scan 이 되었는지, 몇 번이나 수행됐는지, cpu 사용률은 얼마나 되는지 등.. 고려할 요소가 많다.</p>
<p>그래서 아래 필드들을 중점적으로 모니터링할 수 있게 했다.</p>
<ul>
<li><code>OS_CPU_USAGE_%</code> : OracleDB 가 수행되는 Host OS 의 CPU 자원 사용률</li>
<li><code>OS_MEMORY_USAGE_%</code>: OracleDB 가 수행되는 Host OS 의 MEMORY 자원 사용률</li>
<li><code>URRENT_MEMORY</code>: OracleDB 가 수행되는 Host OS 의 사용중인 MEMORY (gb), 사용 가능한 MEMORY (gb) 을 현재 시간 기준으로 노출.
• <code>sid</code> : 세션을 고유하게 식별하는 ID
• <code>serial#</code> : 세션의 고유 시리얼 넘버. 세션 종료 후 재사용될 경우를 대비해 추가로 사용됨.
• <code>machine</code> : 세션이 연결된 클라이언트의 host name
• <code>program</code> : 세션을 시작한 application name (SQLPlus, JDBC Driver ... )
• <code>osuser</code> : 세션을 실행 중인 클라이언트의 OS user name
• <code>elapsed_seconds</code> : SQL 문이 실행된 지 경과한 시간 (unit : second)
• <code>sql_id</code> : 실행 중인 SQL 문을 고유하게 식별하는 ID. 일반적으로 WHERE 절에 들어가는 바인딩 변수는 제외한 구문이다.
• <code>plan_hash_value</code> : 실행 계획을 나타내는 Hash 값. 동일한 SQL 문이라도 바인딩 변수, 데이터 분포에 따라 인덱스 스캔이 다르게 되어 실행계획이분리될 수 있다.
세부적으로는 Buffer Cache, Shared Pool 등 메모리 사용량 및 SQL Hint, 파티셔닝, Curosr(커서) Sharing 등의 여부에 따라 변경될 수 있다.
• <code>executions</code> : SQL 실행 수
• <code>buffer_gets</code> : Logical l/O 수행 수 (많으면 인덱스 최적화)
• <code>disk_reads</code>: Physical I/O 수행 수 (많으면 캐싱 / 데이터 접근 패턴 점검)
• <code>cpu_time</code>: SQL 문이 실행 중 CPU 를 사용한 총 시간 (micro second)
• <code>elapsed_time</code>: SQL 문이 실행을 완료하는 데 소요된 총 시간 (micro second).
cpu_time 뿐 아니라 I/O, Lock 대기, 컨텍스트 스위칭 등의 시간이 포함된다.
• <code>cpu_ratio</code>: SQL 문이 총 CPU 자원에서 차지한 비율
• <code>elapsed_time_ratio</code>: SQL 문이 전체 실행 시간(Elapsed Time)에서 차지한 비율</li>
</ul>
<br>

<p>아래에서 붉은색 박스들은 metrics.yaml 에 커스텀한 SQL 을 반영해, 추출된 결과들을 Grafana 에서 시각화 및 설명한 부분이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f3c82433-df56-47a4-b799-6555a87f919c/image.png" alt=""></p>
<h4 id="redis">redis</h4>
<p>기본 Dashboard 사용.
Dashboard ref : <a href="https://grafana.com/grafana/dashboards/11835-redis-dashboard-for-prometheus-redis-exporter-helm-stable-redis-ha/">https://grafana.com/grafana/dashboards/11835-redis-dashboard-for-prometheus-redis-exporter-helm-stable-redis-ha/</a></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/3afe0928-7d47-4cfa-8177-7f03ec32a9cb/image.png" alt=""></p>
<h4 id="kafka">kafka</h4>
<p>기본 Dashboard 사용.
Dashboard ref : <a href="https://grafana.com/grafana/dashboards/7589-kafka-exporter-overview/">https://grafana.com/grafana/dashboards/7589-kafka-exporter-overview/</a></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1fab638f-14d7-4b44-9d67-9c34b9c9a59b/image.png" alt=""></p>
<h4 id="mysqld">mysqld</h4>
<p>사내에서는 OracleDB 를 주력으로 사용하므로 커스텀 없이 기본 Dashboard 사용.
Dashboard ref : <a href="https://grafana.com/grafana/dashboards/14057-mysql/">https://grafana.com/grafana/dashboards/14057-mysql/</a></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a6209c75-30f7-47f1-994d-a5e3ae5669cf/image.png" alt=""></p>
<h4 id="elasticsearch">elasticsearch</h4>
<p>기본 Dashboard 사용.
Dashboard ref : 
<a href="https://grafana.com/grafana/dashboards/14191-elasticsearch-overview/">https://grafana.com/grafana/dashboards/14191-elasticsearch-overview/</a></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/31947f1a-fa9e-46fd-9be0-fcb065a7a401/image.png" alt=""></p>
<h4 id="rabbitmq">rabbitmq</h4>
<p>기본 Dashboard 사용. RabbitMQ 자체 내장된 플러그인과 호환된다.
Dashboard ref : 
<a href="https://grafana.com/grafana/dashboards/10991-rabbitmq-overview/">https://grafana.com/grafana/dashboards/10991-rabbitmq-overview/</a></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ad4bd3b3-0db8-4b38-8df2-fcad55cd9f4e/image.png" alt=""></p>
<hr>
<br>
<br>


<blockquote>
<p>관련해 추가로 작성한 포스트</p>
</blockquote>
<ul>
<li><a href="https://velog.io/@mud_cookie/K6-%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95">K6 부하테스트 스크립트 작성법</a></li>
<li><a href="https://velog.io/@mud_cookie/%EC%BD%94%EB%A3%A8%ED%8B%B4%EC%9D%B4%EB%9E%80">코루틴이란</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[도메인 구매, DNS 적용, SSL 인증, 신규 도메인 추가(가비아, Oracle Cloud)]]></title>
            <link>https://velog.io/@mud_cookie/%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B5%AC%EB%A7%A4-DNS-SSL-%EC%9D%B8%EC%A6%9D-%EA%B0%80%EB%B9%84%EC%95%84-Oracle-Cloud</link>
            <guid>https://velog.io/@mud_cookie/%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B5%AC%EB%A7%A4-DNS-SSL-%EC%9D%B8%EC%A6%9D-%EA%B0%80%EB%B9%84%EC%95%84-Oracle-Cloud</guid>
            <pubDate>Fri, 25 Oct 2024 11:20:51 GMT</pubDate>
            <description><![CDATA[<p>서버를 운영하는 과정에서 IP 주소의 직접적인 노출을 방지하고 보안을 강화하기 위해 도메인을 연결하고 HTTPS를 통해 SSL 인증을 설정하는 것은 필수라고 볼 수 있다. </p>
<p>이 포스팅에서는 각 개념을 설명하고 이러한 과정, 즉 클라우드 기반의 VM 서버를 도메인과 HTTPS로 연결하는 절차를 설명한다.</p>
<br>
<br>


<h2 id="📜-도메인-dns-네임서버">📜 도메인, DNS, 네임서버</h2>
<br>

<h3 id="도메인">도메인</h3>
<p>도메인은 웹 사이트를 식별하기 위해 사용되는 주소로, 사람이 기억하고 입력하기 쉬운 형태로 만들어진다. 
예를 들어 example.com과 같은 도메인은 특정 서버를 가리키며, 
사용자가 브라우저에 도메인을 입력하면 해당 서버로 접속하게 된다. 
이는 IP 주소와 같은 숫자들의 조합을 대신하는 역할을 한다.</p>
<p>도메인은 여러 구성 요소로 이루어져 있다. 주요 구성 요소는 다음과 같다:</p>
<ul>
<li>최상위 도메인(TLD, Top-Level Domain): .com, .net, .org와 같은 도메인의 마지막 부분이다. 이는 도메인의 목적이나 특성을 나타낸다.</li>
<li>2차 도메인: example.com에서 example 부분이 2차 도메인이다. 사용자가 등록하는 부분이며, 브랜드나 사이트의 정체성을 표현한다.</li>
<li>서브도메인(Subdomain): 2차 도메인 앞에 추가되는 요소로, <a href="http://www.example.com%EC%97%90%EC%84%9C">www.example.com에서</a> www가 서브도메인이다. 주로 특정 서비스를 분리하여 운영할 때 사용된다.</li>
</ul>
<br>

<h3 id="dnsdomain-name-system">DNS(Domain Name System)</h3>
<p>DNS는 도메인 이름을 해당 서버의 IP 주소로 변환해 주는 시스템이다. 
예를 들어 사용자가 example.com을 입력하면, 
DNS는 이 도메인에 해당하는 서버의 IP 주소를 찾아 연결을 돕는다.
DNS는 여러 유형의 레코드를 통해 도메인과 관련된 정보를 관리한다:</p>
<ul>
<li>A 레코드: 도메인 이름을 특정 IP 주소에 매핑하는 레코드이다.</li>
<li>CNAME 레코드: 도메인 이름을 다른 도메인 이름에 매핑하는 데 사용한다. 주로 서브도메인에 대한 리다이렉션을 위해 사용된다.</li>
<li>MX 레코드: 도메인에 대한 메일 서버 정보를 지정하는 레코드이다. 이메일 서비스와 관련된 설정에 필요하다.</li>
<li>TXT 레코드: 도메인에 대한 텍스트 정보를 저장하며, 도메인 소유 인증이나 보안 관련 정보를 포함할 수 있다.</li>
</ul>
<br>

<h3 id="네임서버nameserver">네임서버(Nameserver)</h3>
<p>네임서버는 DNS의 일부로, 도메인 이름을 IP 주소로 매핑하는 역할을 수행한다. 
도메인을 구매한 후에는 해당 도메인의 네임서버 설정을 변경해야 한다. 
일반적으로 도메인 등록 서비스 제공업체에서 기본 네임서버를 제공하지만, 
AWS Route 53이나 Cloudflare와 같은 서비스로 네임서버를 관리할 수도 있다. 
네임서버 설정을 통해 DNS 요청을 올바른 서버로 라우팅하고, 트래픽을 효과적으로 관리할 수 있다.</p>
<br>
<br>

<h2 id="📜-https-ssl-인증서">📜 HTTPS, SSL 인증서</h2>
<br>

<h3 id="https-가볍게-알아보기">HTTPS 가볍게 알아보기</h3>
<p>HTTPS(HyperText Transfer Protocol Secure)는 HTTP의 보안 버전으로, 데이터를 암호화하여 서버와 클라이언트 간의 통신을 보호한다. 
이를 통해 데이터가 제3자에 의해 도청되거나 변조되는 것을 방지할 수 있다. 
특히, 금융 거래나 로그인 정보와 같은 민감한 데이터를 다루는 웹사이트에서는 HTTPS가 필수적이다.
HTTPS는 기본적으로 443 포트를 사용한다. 443 포트는 웹 트래픽을 암호화하여 안전하게 전달하기 위한 표준 포트로, SSL/TLS 통신이 이 포트를 통해 이루어진다. 
반면 HTTP는 80번 포트를 사용한다. 443 포트를 열어두어야 클라이언트가 HTTPS로 서버에 안전하게 접속할 수 있다.</p>
<h3 id="https-이해하기">HTTPS 이해하기</h3>
<p>HTTPS 는 상호 간 통신에 대칭/비대칭 키 알고리즘을 모두 사용한다.
대칭키는 가볍지만 암/복호화에 필요한 key 를 양측이 모두 가지고 있어 탈취 시 위험 리스크가 있다.</p>
<ul>
<li>클라이언트가 A 라는 키로 내용을 암호화해 보냈을 때, 대칭키가 탈취당한다면 내용을 복호화할 수 있는 위험이 존재한다.</li>
</ul>
<p>비대칭키는 무겁지만 공개키와 비밀키가 서로 쌍을 이루어 복호화할 수 있으므로 리스크가 적다.
공개키 : 클라이언트에게 제공되는 암/복호화 key 값
비밀키 : 서버만이 가지고 있는 암/복호화 key 값
공개키로 암호화 된 것은 비밀키로만 복호화 가능하고, 비밀키로 암호화 된 것은 공개키로만 복호화 가능하다.
공개키는 탈취 가능성이 높은 반면, 비밀키는 서버만이 알고 관리하므로 탈취 가능성이 적다.</p>
<ul>
<li>클라이언트가 A 라는 키로 내용을 암호화해 보냈을 때, 그것을 복호화할 수 있는 건 서버의 B라는 비밀키 뿐이다. 이로 인해 탈취 당함에도 리스크가 적다.</li>
<li>또한 서버가 B 라는 키로 내용을 암호화해 보냈을 때, 클라이언트는 본인이 가지고 있는 공개키로 복호화가 가능하다면 서버2, 서버3 이 아니며, 변조가 되지 않아 내가 원하는 서버와 통신하고 있다는 것을 인증할 수 있는 것이다.</li>
</ul>
<p>다만 모든 통신 내용을 비대칭키로 관리한다면, 그 과정이 무거워 대칭/비대칭 키 알고리즘을 섞어 사용한다는 것이다.
일반적으로 대칭키로 내용을 공유하고, 대칭키를 주고받을 때 비대칭키를 사용한다.</p>
<h3 id="https-깊게-알아보기">HTTPS 깊게 알아보기</h3>
<p>SSL/TLS 핸드셰이크로 시작하며, 핸드셰이크 과정에서 필요한 인증과 키 교환이 이뤄진다. 
아래에 HTTPS의 핸드셰이크 과정과 대칭키/비대칭키 사용 순서에 대해 순서대로 자세히 설명해본다.</p>
<ol>
<li><p>Client Hello</p>
<ul>
<li>클라이언트가 서버에 연결을 시도하면서 클라이언트 헬로 메시지를 보낸다.</li>
<li>이 메시지에는 클라이언트가 지원하는 TLS 버전, 사용할 수 있는 암호화 알고리즘 목록, 무작위 숫자 (Client Random) 등이 포함되어 있다.</li>
</ul>
</li>
<li><p>Server Hello</p>
<ul>
<li>서버는 클라이언트 헬로 메시지를 받고, 다음과 같은 정보로 응답한다<ul>
<li>사용할 TLS 버전과 암호화 알고리즘을 선택.</li>
<li>서버가 생성한 무작위 숫자 (Server Random)</li>
</ul>
</li>
<li>서버의 디지털 인증서를 클라이언트에게 보낸다. 이 인증서에는 서버의 공개키가 포함되어 있다.</li>
</ul>
</li>
<li><p>서버 인증서 검증</p>
<ul>
<li>클라이언트는 서버가 보낸 디지털 인증서를 통해 서버의 신뢰성을 확인한다.</li>
<li>인증서는 공인된 인증 기관(CA)에 의해 서명된 것이며, 클라이언트는 이를 통해 서버가 신뢰할 수 있는 서버임을 확인한다.</li>
</ul>
</li>
<li><p>Pre-Master Secret 생성</p>
<ul>
<li>클라이언트는 새로운 난수인 Pre-Master Secret을 생성한다.</li>
<li>이 Pre-Master Secret은 서버의 공개키로 암호화되어 서버로 전송된다. 이 단계에서 비대칭키 암호화가 사용된다.</li>
<li>비대칭키 암호화를 사용하는 이유는 클라이언트가 생성한 Pre-Master Secret을 안전하게 서버로 전달하기 위함이다. 서버는 자신의 비밀키로 이를 복호화하여 Pre-Master Secret을 얻는다.</li>
</ul>
</li>
<li><p>세션 키 생성</p>
<ul>
<li>서버와 클라이언트는 각각 Client Random, Server Random, Pre-Master Secret을 이용해 세션 키를 생성한다.</li>
<li>이 세션 키는 대칭 키이며, 이후의 통신에서 사용된다.</li>
<li>대칭키는 암호화와 복호화에 동일한 키를 사용하는 방식으로, 대칭키 암호화는 비대칭 암호화에 비해 훨씬 더 빠르다. 따라서 실제 데이터를 주고받을 때는 이 대칭키가 사용된다.</li>
</ul>
</li>
<li><p>핸드셰이크 완료</p>
<ul>
<li>이제 클라이언트와 서버는 세션 키를 공유하게 되었고, 대칭키 암호화를 사용해 안전하게 데이터를 주고받을 수 있게 된다.</li>
<li>클라이언트와 서버는 &quot;Finished&quot; 메시지를 교환하여 핸드셰이크가 완료되었음을 알린다. 이 메시지 또한 새롭게 생성된 세션 키로 암호화되어 전송된다.</li>
</ul>
</li>
</ol>
<p>요약하자면 아래와 같다.
<code>초기 핸드셰이크 (비대칭키 암호화)</code> :</p>
<ul>
<li>클라이언트는 서버의 공개키를 받아서 Pre-Master Secret을 암호화해 서버로 전송한다.</li>
<li>이때 비대칭키 암호화를 사용하는 이유는 키 교환을 안전하게 하기 위함이다.</li>
</ul>
<p><code>세션 키 생성 후 데이터 전송 (대칭키 암호화)</code>:</p>
<ul>
<li>클라이언트와 서버가 Pre-Master Secret을 기반으로 <strong>세션 키(대칭키)</strong> 를 생성한다.</li>
<li>이후의 모든 데이터 통신은 이 세션 키를 사용하여 대칭키 방식으로 암호화된다.</li>
<li>대칭키 암호화는 비대칭키에 비해 속도가 빠르기 때문에, 실시간 데이터 전송에 적합하다.</li>
</ul>
<br>


<h3 id="ssl-인증서와-tls">SSL 인증서와 TLS</h3>
<p>SSL(Secure Sockets Layer)은 HTTPS를 구현하기 위해 사용되는 프로토콜로, 
현재는 SSL의 후속 버전인 TLS(Transport Layer Security)가 널리 사용되고 있다. 
SSL 인증서는 클라이언트와 서버 간의 안전한 통신을 보장하는 역할을 하며, 클라이언트가 서버의 신원을 확인하고 신뢰할 수 있게 한다. </p>
<br>

<h4 id="-서브도메인과-와일드카드-도메인"># 서브도메인과 와일드카드 도메인</h4>
<p>서브도메인은 메인 도메인의 앞에 추가되어 메인 도메인의 특정 하위 영역을 식별하는 역할을 한다. 
예를 들어 blog.example.com, shop.example.com 등으로 서로 다른 서비스를 제공할 수 있다.</p>
<p>와일드카드 도메인은 *.example.com과 같이 정의되며, 모든 서브도메인을 허용하는 방식이다. 
이를 통해 특정 도메인 하위에서 무제한의 서브도메인을 생성할 수 있다. 
예를 들어, 와일드카드 도메인을 사용하면 app1.example.com, app2.example.com, anything.example.com 등의 주소를 자유롭게 사용할 수 있다.</p>
<p>SSL/TLS 인증서 플랫폼을 들여다보면 와일드카드 도메인 서비스를 많이 제공하는 것을 볼 수 있다.
와일드카드 도메인은 유연한 도메인 관리를 가능하게 하며, 
특정 서비스가 다양한 하위 도메인에서 동일한 SSL 인증서를 사용할 수 있도록 해준다.</p>
<br>


<h3 id="ssltls-공인사설인증서">SSL/TLS 공인/사설인증서</h3>
<h4 id="사설인증서">사설인증서</h4>
<p><code>공인된 인증 기관(CA)</code>에서 발급받은 인증서가 아닌, 조직 또는 개인이 자체적으로 생성하여 사용하는 인증서. 
일반적으로 내부 네트워크, 테스트 환경 또는 외부 사용자를 필요로 하지 않는 비공개 서비스에서 사용된다.</p>
<ul>
<li>발급 주체 : 조직 내에서 신뢰할 수 있는 서버나 개인 컴퓨터에서 생성하며, 자체적으로 인증서 발급을 관리</li>
<li>신뢰도: 사설 인증서는 공인 인증 기관에서 발급받지 않으므로, 외부에서 신뢰되지 않는다. 예를 들어, 브라우저나 시스템에서는 기본적으로 경고 메시지가 표시된다.</li>
<li>사용 용도: 내부 시스템에서 암호화된 통신을 유지하거나 테스트 서버의 HTTPS 통신을 설정하는 데 주로 사용된다.</li>
<li>설치: 사설 인증서를 사용하는 각 클라이언트는 인증서를 수동으로 신뢰하도록 설정해야 한다.</li>
<li>사용 예시 : 회사 내부 네트워크 보안 / 개발 및 테스트 환경 / VPN 및 원격 서버</li>
<li>장점 : 자체 생성이라 무료 / 조직 내부에서 관리할 수 있어 편리함</li>
<li>단점 : 공인되지 않아 브라우저에서 경고 표시 / 외부 네트워크에는 부적합</li>
<li>대표적인 도구로 openssl 이 있음.</li>
</ul>
<p>회사가 무료 공인 인증서(예: Let’s Encrypt) 대신 유료 공인 인증서를 사용하는 데는 몇 가지 중요한 이유가 있다. 
주요 이유는 보안 수준, 신뢰성, 추가 기능 지원과 관련이 있다.</p>
<h4 id="공인인증서">공인인증서</h4>
<p><code>공인된 인증 기관(CA)</code>에서 발급받은 인증서들을 말한다.
회사가 무료 공인 인증서(예: Let’s Encrypt) 대신 유료 공인 인증서를 사용하는 데는 몇 가지 중요한 이유가 있다. 주요 이유는 보안 수준, 신뢰성, 추가 기능 지원과 관련이 있다.</p>
<ul>
<li>일부 산업 규제나 법적 요건(예: PCI DSS, HIPAA)은 검증 수준이 높은 인증서를 요구하는 경우가 많다. 무료 공인인증서는 이들을 만족하지 못한다.</li>
<li>Extended Validation (EV)와 Organization Validation (OV) 같은 인증서는 유료 인증 기관에서만 발급 가능하며, 무료 인증서에서는 DV 만을 제공한다.</li>
</ul>
<br>

<hr>
<br>

<h1 id="✏️-1-도메인-구매-및-dns-설정">✏️ 1. 도메인 구매 및 DNS 설정</h1>
<p>먼저, 도메인을 구매해야 한다. 
도메인 등록은 GoDaddy, Namecheap, AWS Route 53 등 다양한 도메인 등록 서비스 제공업체를 통해 용이하게 이루어질 수 있다. </p>
<blockquote>
<p>이 포스팅에서는 <code>가비아(Gabia)</code> 라는 도메인 제공 업체를 통해 진행한다.
국내 IT 인프라 및 웹 서비스업계에서 굴지의 기업으로, 한국어 지원과 통합적인 도메인·호스팅·보안 서비스를 제공하여 관리가 쉽고 편하다. </p>
</blockquote>
<br>

<h2 id="🔗-1-1-도메인-구매">🔗 1-1. 도메인 구매</h2>
<p><a href="https://www.gabia.com/">https://www.gabia.com/</a>   에 진입해 원하는 SLD (Second Level Domain) 도메인을 입력해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f4a86c3f-f2ea-4be3-b131-a153f0a6778b/image.png" alt=""></p>
<p>그러면 아래와 같이 사용 가능한 최상위 도메인 목록을 나열해준다.
한국 뿐 아니라 해외 국가 코드 도메인도 사용 가능하다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/7504a918-aac6-4a24-a3c9-e501433065c9/image.png" alt=""></p>
<p>2024년 10월 기준 .com 이라는 최상위 도메인을 1년간 부가세 포함 20,900 원에 이용 가능했다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/7a842c70-76e7-40f2-a9e9-a1c6bf487261/image.png" alt=""></p>
<p>다음 과정을 거치기 위해선 네임서버를 등록해야 한다.</p>
<br>
<br>


<h2 id="🔗-1-2-dns-레코드-및-nameserver-설정">🔗 1-2. DNS 레코드 및 Nameserver 설정</h2>
<br>

<p>내 서버는 개인용으로 쓸만한 프리티어를 제공해주는 Oracle Cloud(OCI, Oracle Cloud Infrastructure)에 올라가 있어, Oracle Cloud 기준으로 설명한다.
다만 프리티어는 리전 할당 받기가 매우 힘들어 몇 개월 이상 소요되고 문의를 넣어 겨우 받았으니 급한 사람들은 다른 클라우드 플랫폼을 이용하자.</p>
<p>좌측 상단 메뉴의 Networking &gt; DNS management &gt; Zones 에 진입한다.
그러면 아래와 같은 화면이 뜨는데, 우선 zone 을 생성해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/9a3ae5f4-f6aa-4405-bc83-77d65dd170d5/image.png" alt=""></p>
<p>Zone name 에는 내가 등록하고자 하는 도메인 명을 입력한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5ea91b55-e614-424f-973c-494093200f40/image.png" alt=""></p>
<p>그러면 아래와 같이 Nameserver 가 생성된 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2d5210cd-85b5-48d9-bd9c-b26ab486acda/image.png" alt=""></p>
<p>이후 Records 탭에 들어가 recode 를 등록해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e0136e7b-f171-4dd3-996b-b2825a657c1e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/ef4b4850-657d-4181-8843-57002e8b7204/image.png" alt=""></p>
<p>Type 과 TTL 을 지정한 후 Address 는 매핑할 서버의 IP 를 입력한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c78caa77-e7d0-49b9-97e5-e595d3bdfa10/image.png" alt=""></p>
<p>이후 하단의 Publish changes, Confirm publish changes 를 적용해 recode 등록을 확정하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a081844a-20ed-4997-9cf3-20967cce5567/image.png" alt=""></p>
<p>그러면 다시 가비아로 돌아와 네임서버를 입력한다.
IP 주소는 네임서버의 IP 를 입력한다. (nslookup 으로 확인 가능)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8013c782-eb70-497c-8886-3df2d8757516/image.png" alt=""></p>
<p>아래 결제를 마친 모습.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/097d91d8-ffc0-4404-9626-2eb681d47322/image.png" alt=""></p>
<p>이제 도메인 등록이 될 때 까지 여유있게 하루를 기다렸다가 SSL 설정을 마무리해보자.</p>
<br>

<hr>
<br>


<h1 id="✏️-2-ssltls-인증">✏️ 2. SSL/TLS 인증</h1>
<p>여유있게 24시간이 지나 도메인 연결이 완료되면 이제 HTTPS를 설정해야 한다. 
HTTPS는 SSL/TLS 인증서를 사용하여 웹 트래픽을 암호화함으로써 사용자와 서버 간의 통신을 보호한다. </p>
<p>SSL/TLS 인증서는 Let&#39;s Encrypt와 같은 무료 발급 기관에서 발급받을 수 있으며, 유료 인증서를 구매하는 것도 가능하다. 
물론 가비아에서도 높은 신뢰도를 보장하는 SSL/TLS 인증서를 제공한다.
이번 포스팅에서는 내 개인서버를 구축하는 데 주력해, Let&#39;s Encrpyt 라는 무료 TLS 공인 인증서를 적용한다.</p>
<p>우선 nslookup 으로 record 등록이 잘 됐는지 확인하자.
Address 에 내가 지정한 IP 가 잘 등록된 것이 확인된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/3dcfbf8a-d1f2-4324-9ac4-5d91002e1769/image.png" alt=""></p>
<p>이제는 도메인으로 직접 웹으로 접속해보자.
<a href="https://velog.io/@mud_cookie/Oracle-Cloud-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%83%9D%EC%84%B1">Oracle Cloud 프리티어 A1 인스턴스 생성 + 고정 public IP 생성</a>
의 가장 아래에 방화벽 open, Nginx 설치 및 IP 로 접속하는 방법을 설명해 두었으니 참고하길 바란다.</p>
<p>도메인 연결이 잘 된것을 확인할 수 있다.
이제 Nginx 로 HTTPS 를 설정하고, SSL 인증을 적용해보도록 하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/483cf9b9-2511-4ee6-867c-895b7064fde3/image.png" alt=""></p>
<br>
<br>

<h2 id="🔗-2-1-lets-encrypt-설치-nginx-반영">🔗 2-1. Let&#39;s Encrypt 설치, Nginx 반영</h2>
<p><code>도메인명</code>은 mud-cookie.com 과 같이 본인이 구매한 도메인과
<code>도메인명나열</code>은 mud-cookie.com <a href="http://www.mud-cookie.com">www.mud-cookie.com</a> 와 같이 TLS 인증을 적용할 서브도메인을 포함한 도메인 리스트를 작성한다.</p>
<pre><code class="language-bash"># Let&#39;s Encrypt 를 적용하기 위한 certbot 설치
sudo apt update
sudo apt install certbot python3-certbot-nginx -y

# certbot 에 도에민 반영 및 nginx 재시작
sudo certbot --nginx -d 도메인명나열

# nginx 설정 파일 업데이트
sudo vi /etc/nginx/sites-available/도메인명.conf</code></pre>
<pre><code class="language-bash"># /etc/nginx/sites-available/도메인명.conf
server {
    # HTTP 요청을 HTTPS로 리디렉션
    listen 80;
    server_name 도메인명;
    return 301 https://$host$request_uri;
}

server {
    # HTTPS 설정
    listen 443 ssl;
    server_name 도메인명나열;

    # SSL 인증서 파일 경로
    ssl_certificate /etc/letsencrypt/live/mud-cookie.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mud-cookie.com/privkey.pem;

    # SSL 설정 추가
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # 루트 디렉토리와 인덱스 파일 설정
    root /var/www/html;
    index index.html index.htm;

    location / {
        try_files $uri $uri/ =404;
    }

    # 8000 포트로 프록시 설정 예시 (Optional)
#    location / {
#        proxy_pass http://localhost:8000;
#        proxy_set_header Host $host;
#        proxy_set_header X-Real-IP $remote_addr;
#        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#        proxy_set_header X-Forwarded-Proto $scheme;
#    }
}</code></pre>
<pre><code class="language-bash"># 심볼릭 링크로 설정 파일을 활성화
sudo ln -s /etc/nginx/sites-available/도메인명.conf /etc/nginx/sites-enabled/

# nginx 설정 테스트
sudo nginx -t

# nginx 재시작
sudo systemctl restart nginx\</code></pre>
<p>이제 https 접속을 테스트해보자.
웹으로도 가능하다.</p>
<pre><code class="language-bash">curl -I http://도메인명</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/13b8d1bc-c8db-456e-ac9d-74c5bee7a11a/image.png" alt=""></p>
<h2 id="🔗-2-2-인증서-자동-갱신">🔗 2-2. 인증서 자동 갱신</h2>
<br>

<p>Let&#39;s Encrypt 의 인증서는 90일 주기로 만료되므로, 일정 주기마다 갱신이 필요하다.
갱신을 일일이 챙기기 힘드므로, 자동으로 갱신되도록 해보자.
crontab 을 활용해 매일 새벽 3시에 갱신되고, nginx 를 reload 하자.
restart 는 재기동이고, reload 는 설정을 다시 반영한다는 것이니 참고하자.</p>
<pre><code class="language-bash"># crontab 설정 진입
crontab -e

# 아래 명령어를 적용한다.
0 3 * * * /usr/bin/certbot renew --quiet &amp;&amp; /bin/systemctl reload nginx

# crontab 적용 확인
crontab -l</code></pre>
<br>

<hr>
<br>


<h1 id="✏️-3-서브-도메인을-추가해-포트를-매핑하려면">✏️ 3. 서브 도메인을 추가해 포트를 매핑하려면?</h1>
<br>

<p>서비스가 확장되어 서버내 application을 <code>8080</code> 포트로 띄웠다고 가정해보자.
이 서비스는 <code>test.mud-cookie.com</code> 과 같은 host 를 요청했을 때 위 인스턴스로 매핑시키는 작업을 해보자.</p>
<h2 id="🔗-31-신규-도메인-dns-레코드-등록">🔗 3.1 신규 도메인 DNS 레코드 등록</h2>
<br>

<p>1-2. DNS 레코드 및 Nameserver 설정을 참고해 record 를 등록하자.</p>
<br>

<h2 id="🔗-32-신규-도메인-인증서-발급-nginx-프록시-적용">🔗 3.2 신규 도메인 인증서 발급, Nginx 프록시 적용</h2>
<br>

<pre><code class="language-bash"># test.mud-cookie.com 인증서 등록
sudo certbot --nginx -d test.mud-cookie.com

# test.mud-cookie.com 도메인 Nginx 설정 진입
sudo vi /etc/nginx/sites-available/test.mud-cookie.com.conf</code></pre>
<pre><code class="language-bash"># /etc/nginx/sites-available/test.mud-cookie.com.conf

server {
    # HTTP 요청을 HTTPS로 리디렉션
    listen 80;
    server_name test.mud-cookie.com;
    return 301 https://$host$request_uri;
}

server {
    # HTTPS 설정
    listen 443 ssl;
    server_name test.mud-cookie.com;

    # SSL 인증서 파일 경로
    ssl_certificate /etc/letsencrypt/live/mud-cookie.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mud-cookie.com/privkey.pem;

    # SSL 설정 추가
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # 프록시 설정
    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}</code></pre>
<pre><code class="language-bash"># /etc/nginx/sites-available/test.mud-cookie.com.conf 파일 심볼릭으로 활성화
sudo ln -s /etc/nginx/sites-available/test.mud-cookie.com.conf /etc/nginx/sites-enabled/

# nginx 설정 확인
sudo nginx -t
# nginx 재기동
sudo systemctl restart nginx</code></pre>
<p>서버는 이미 springboot application 을 8080 포트에 띄운 상태이다.
아래처럼 접속해보자.
실제로는 https:// 로 redirect 된 상태이고, 404 페이지는 url 매핑을 하지 않았을 뿐 host 에 정상적으로 라우팅 되었음을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/71419fb7-a647-4cc4-931d-79669bacb67c/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Oracle Cloud 프리티어 A1 인스턴스 생성 + 고정 public IP 생성]]></title>
            <link>https://velog.io/@mud_cookie/Oracle-Cloud-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%83%9D%EC%84%B1</link>
            <guid>https://velog.io/@mud_cookie/Oracle-Cloud-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%83%9D%EC%84%B1</guid>
            <pubDate>Thu, 10 Oct 2024 13:11:12 GMT</pubDate>
            <description><![CDATA[<p>개인용 무료 클라우드를 사용하기에 최적의 플랫폼은 Oracle Cloud 이다.
AWS, Google Cloud, Microsoft Azure 등과 같은 플랫폼은 프리티어가 기간 제한적이고 볼륨도 적다. </p>
<p>그에 비해 Oracle Cloud 는 아래와 같은 프리티어를 평생 제공한다.
중점적으로 봐야 할 부분은 CPU 4개, Ram 24GB 가 무료라는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b03f3aac-2b59-4ffa-ba01-a0647b087b4d/image.png" alt=""></p>
<p>다만 개인이 계정을 생성하는 과정 자체가 까다롭다.
결제 카드 등록 부분에서 계속 막혀 지원팀에 직접 메일로 문의해 해결했고, 
뿐만 아니라 국내 Region 으로 가입하는 것도 언제까지 대기해야 되는지 모른다.</p>
<p>오랜 시간을 들여 춘천 Region 으로 계정 생성에 성공했고, 
이번 포스팅에서는 A1 VM 인스턴스 1개를 올리고 Public IP 를 지정하는 과정을 담는다.</p>
<h1 id="✏️-1-고정-public-ipv4-주소-생성">✏️ 1. 고정 public IPv4 주소 생성</h1>
<p>고정 public IP 를 지정하지 않으면 IP 가 유동적으로 변경된다.
그러면 외부에서 접속 시마다 IP 를 변경해서 접속해야 되는 불편함이 있다.
물론 도메인을 적용할 때부터 유동IP 로 지정하는 DDNS 방법도 존재하지만, 
Oracle Cloud 에서 무료 고정 public IP 를 1개 제공해주니 그것을 사용해보자.</p>
<p>우선 인스턴스 생성 전에 public IP 를 할당받아보자. 
<br></p>
<p>좌측 상단 메뉴 탭 -&gt; 네트워킹 -&gt; 예약된 퍼블릭 IP
<img src="https://velog.velcdn.com/images/mud_cookie/post/99364d0a-8e33-41d2-bcdb-5fff0edef7e8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/489b0617-1c82-4c3e-a8b4-4f16fde0e857/image.png" alt=""></p>
<p>퍼블릭 IP 주소 예약 -&gt; 이름 지정 후 예약</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f66e6273-cc42-4cd5-b5f1-4702b29a2d9f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e9fd8777-b06b-4a73-83fb-c737967bfdbf/image.png" alt=""></p>
<p>IP 생성 확인 및 OCID 복사</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/cc6e3921-cc33-45e5-884b-4e8beec730d2/image.png" alt=""></p>
<h1 id="✏️-2-vm-인스턴스-생성">✏️ 2. VM 인스턴스 생성</h1>
<p>이제는 A1 VM 인스턴스를 생성해보자.
무료로 제공되는 A1 CPU 4개, RAM 24GB 중 A1 CPU 1개와 RAM 12GB 를 할당하고자 한다.</p>
<p>Home -&gt; 리소스 실행 -&gt; VM 인스턴스 생성</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/862b3ac7-fc9d-456b-bc00-ef81422a68ba/image.png" alt=""></p>
<p>VM 인스턴스 이름 지정</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/3a7304f9-407a-4e3b-ac13-fec44ea9cf52/image.png" alt=""></p>
<p>OS, CPU-Memory 구성 (커스텀)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/32897fc7-f188-403d-86f3-a19ceef3656c/image.png" alt=""></p>
<p>나의 경우에는 익숙한 Ubuntu 24 선택 (각자 필요한 환경에 맞게 설정)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/5672970d-eddb-48b9-8a3f-82de4d8959b7/image.png" alt=""></p>
<p>Shape 은 무료 제공되는 Arm 기반 A1 Flex 모델 선택
총 사용 가능한 무료 제한은 OCPU 4, 24 GB Memory 이므로 각자 환경에 맞게 설정</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/6d6417be-832a-4031-b3fb-39dae7b2441a/image.png" alt=""></p>
<p><del>위 설정한 public IP 의 OCID 를 입력해 반영</del>
VM 인스턴스 생성 시 즉시 적용은 되지 않음. (20241010)
그래서 일단 신규 네트워크 생성 후, 3번 과정에서 연결을 시도한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/eab09ab8-3152-4186-bd06-8dee4568cbfe/image.png" alt=""></p>
<p>SSH key 로컬에 저장</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/07c300e1-c702-4874-b7a6-729d0612bc53/image.png" alt=""></p>
<p>부트 볼륨 및 VPU 설정
부트 볼륨 자체적으로 비용 부과되니 참고.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f5d17bcc-7566-4ca0-9c40-8bcdc89d4cc6/image.png" alt=""></p>
<p>비용 확인 후 생성
A1 인스턴스 무료 생성 제한과 부트 볼륨 크기에 주의
<img src="https://velog.velcdn.com/images/mud_cookie/post/26888728-a76b-4ab9-b60c-9ffe6f8b13a7/image.png" alt=""></p>
<p>인스턴스 생성 확인
요청 후 생성까지 약 1분 소요
<img src="https://velog.velcdn.com/images/mud_cookie/post/a160ad09-ff0c-4897-a47c-5117e1973fc5/image.png" alt=""></p>
<hr>
<h1 id="✏️-3-인스턴스에-고정-public-ip-설정">✏️ 3. 인스턴스에 고정 public IP 설정</h1>
<ol>
<li>에서 발급받은 고정 public IP 를 2. 에서 생성한 인스턴스에 적용해보자.
인스턴스 생성 시에 바로 적용이 안되는 이유는 모르겠지만.. 어찌됐든 아래와 같이 해결했다.</li>
</ol>
<p>홈의 좌측 상단 메뉴 -&gt; 컴퓨트 -&gt; 인스턴스 진입</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/c9401135-31a3-4c2a-95e1-a0881c82c457/image.png" alt=""></p>
<p>인스턴스 선택</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dca805cb-ec28-42fe-969d-ae61a9155bf2/image.png" alt=""></p>
<p>좌측 하단의 리소스 -&gt; 연결된 VNIC 진입</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/491fc37a-43a2-465b-927b-0c2d14f811fd/image.png" alt=""></p>
<p>인스턴스 선택</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/8c672c72-917c-460a-921b-fc707ebb05eb/image.png" alt=""></p>
<p>Resources -&gt; IPv4 주소 -&gt; 우측 ... 의 편집 진입</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/66d02bf0-6780-4cb9-8b97-5cf83286983b/image.png" alt=""></p>
<p>IP 초기화를 위한 공용 IP 없음 선택 후 업데이트</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/0fd37cfb-0f8b-43ed-9705-36737a763da8/image.png" alt=""></p>
<p>다시 편집</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/66d02bf0-6780-4cb9-8b97-5cf83286983b/image.png" alt=""></p>
<p>기존 예약된 IP 주소 선택 후 업데이트
<img src="https://velog.velcdn.com/images/mud_cookie/post/2ffe0415-63ee-4c10-a21c-d3c73888fe10/image.png" alt=""></p>
<p>예약된 IP 로 변경 확인</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/68b230b3-49a4-4933-a918-551312fb0c42/image.png" alt=""></p>
<hr>
<h1 id="✏️-4-ssh-접속">✏️ 4. SSH 접속</h1>
<p>VM 인스턴스 생성 시 SSH 접속은 열어두었으니, 2. 에서 발급받은 SSH key 를 가지고 SSH 에 접속해보자.
22번 포트에 고정 IP 를 넣고, key 를 아까 로컬에 저장해둔 파일로 지정해야 한다.</p>
<p>MobaXterm 으로 진행해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1c3a06dc-b1d0-415c-84eb-c964dd8f45be/image.png" alt=""></p>
<p>최초 계정 ubuntu 로 접속</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/e5207ecd-50f4-4f96-9d38-944d8da2696f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b50cdb5c-fde4-405e-8feb-43f6928629cf/image.png" alt=""></p>
<hr>
<h1 id="✏️-5-특정-포트-방화벽-open">✏️ 5. 특정 포트 방화벽 open</h1>
<p>Oracle Cloud 는 AWS 와 다르게 방화벽을 두 번 open 해야 한다.
웹 콘솔에서 방화벽을 open 하고, 인스턴스 내 iptables 의 방화벽을 open 하자.</p>
<h2 id="🔗-5-1-웹-콘솔-방화벽">🔗 5-1. 웹 콘솔 방화벽</h2>
<p>Networking - Vitrual cloud networks 진입</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b69ad314-c767-4349-be1c-050e27efe841/image.png" alt=""></p>
<p>나의 경우엔 이미 보안 그룹(Security Group)을 만들어 두었으나, 없는 경우엔 Create Newtork Security Group 으로 생성한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/470f93e5-dcf3-4d96-986d-545d71af5289/image.png" alt=""></p>
<p>아래와 같이 80, 443 포트를 연다는 것을 명시한다.
Destination Port Range 에 80,443 이라고 한 번에 등록하면 에러가 발생하니 Rule 탭을 하나 더 추가해서 만들자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/64bbf108-88b0-43e4-a327-7e56d9918ec2/image.png" alt=""></p>
<p>보안 그룹이 생성되었으면 다시 인스턴스 탭에 들어가 보안 그룹을 지정한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dc2d8d6d-8030-4ad1-956b-d91c5d61701c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/1048763d-07c9-4225-996d-98a870db892b/image.png" alt=""></p>
<br>

<h2 id="🔗-5-2-인스턴스-방화벽">🔗 5-2. 인스턴스 방화벽</h2>
<p>Oracle Cloud 에서는 기본적으로 iptables 방화벽이 내장되어 있다.</p>
<pre><code class="language-bash"># 443번 포트로 들어오는 입력을 허용
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
# 443번 포트 방화벽 open 확인
sudo iptables -L -v -n | grep &#39;:443&#39;</code></pre>
<br>

<h2 id="🔗-5-3-nginx-설치-및-80-포트-테스트">🔗 5-3. Nginx 설치 및 80 포트 테스트</h2>
<br>

<pre><code class="language-bash">sudo apt update
sudo apt install nginx -y

sudo systemctl start nginx
sudo systemctl enable nginx  # 부팅 시 자동 시작 설정

sudo systemctl status nginx  # Nginx 상태 확인
sudo lsof -i :80             # 80 포트에서 수신 대기 중인 프로세스 확인

curl -I http://localhost     # 로컬 접속 확인</code></pre>
<p>웹에서도 접속해보자.
인스턴스가 올라간 서버의 IP 를 입력해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dba6780b-12fa-49db-93ae-7649ff9793fe/image.png" alt=""></p>
<p>다음 포스팅에는 도메인을 구매해 DNS 를 적용하고, 
HTTPS 접속이 가능하게 SSL 인증을 해보려고 한다.</p>
<blockquote>
</blockquote>
<p><a href="https://velog.io/@mud_cookie/%EB%8F%84%EB%A9%94%EC%9D%B8-%EA%B5%AC%EB%A7%A4-DNS-SSL-%EC%9D%B8%EC%A6%9D-%EA%B0%80%EB%B9%84%EC%95%84-Oracle-Cloud">도메인 구매, DNS, SSL 인증 (가비아, Oracle Cloud)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ElasticSearch 8.x Docker compose 설치 및 환경 구성]]></title>
            <link>https://velog.io/@mud_cookie/ElasticSearch-8.x-Docker-compose-%EC%84%A4%EC%B9%98-%EB%B0%8F-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%84%B1</link>
            <guid>https://velog.io/@mud_cookie/ElasticSearch-8.x-Docker-compose-%EC%84%A4%EC%B9%98-%EB%B0%8F-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%84%B1</guid>
            <pubDate>Tue, 13 Aug 2024 13:02:29 GMT</pubDate>
            <description><![CDATA[<br>

<p>24/08 기준 최신 버전인 8.15 로 진행한다.</p>
<p>해당 포스팅은 elastic 에 대한 전반적인 개념은 다루고 있지 않다.
다만 single-node 로 구성된 것과, multi-node 로 구성된 것의 차이 정도는 아래 간략하게 설명한다.</p>
<br>
<br>

<h2 id="✏️-elasticsearch-에서의-master-node-data-node">✏️ ElasticSearch 에서의 Master Node? Data Node?</h2>
<br>

<p><strong>Elasticsearch</strong>는 분산 검색 및 분석 엔진으로, 다음과 같은 주요 구성 요소로 이루어져 있다.</p>
<ul>
<li>클러스터 (Cluster): 하나 이상의 노드 집합. 전체 데이터를 보유하고 모든 노드에서 통합 인덱싱 및 검색 기능을 제공</li>
<li>노드 (Node): 클러스터의 단일 서버. 데이터를 저장하고 클러스터의 인덱싱 및 검색 기능</li>
<li>인덱스 (Index): 유사한 특성을 가진 문서들의 모음</li>
<li>샤드 (Shard): 인덱스를 여러 조각으로 나눈 것. 수평적 확장과 성능 향상을 위해 사용</li>
<li>레플리카 (Replica): 샤드의 복제본. 고가용성과 읽기 성능 향상을 위해 사용</li>
</ul>
<br>

<h3 id="노드-유형">노드 유형</h3>
<p>Elasticsearch에서는 여러 유형의 노드가 있지만, 주로 Master 노드와 Data(Cluster) 노드로 구분된다.</p>
<h3 id="master-node">Master Node</h3>
<ol>
<li><p><strong>역할</strong>:</p>
<ul>
<li>클러스터 전체의 메타데이터를 관리</li>
<li>노드 추가/제거 등 클러스터 상태 변경을 관리</li>
<li>인덱스 생성/삭제를 관리</li>
<li>클러스터 전체의 설정을 관리함</li>
</ul>
</li>
<li><p><strong>특징</strong>:</p>
<ul>
<li>일반적으로 데이터를 저장 X (설정에 따라 다를 수 있음)</li>
<li>클러스터당 하나의 액티브 마스터 노드만 존재함</li>
<li>상대적으로 적은 리소스를 사용</li>
</ul>
</li>
<li><p><strong>설정</strong>:</p>
<ul>
<li><code>node.roles: [ master ]</code>로 설정하여 마스터 전용 노드로 구성할 수 있음</li>
</ul>
</li>
</ol>
<h3 id="data-node">Data Node</h3>
<ol>
<li><p><strong>역할</strong>:</p>
<ul>
<li>실제 데이터를 저장</li>
<li>CRUD, 검색, 집계 등의 데이터 관련 작업을 수행</li>
</ul>
</li>
<li><p><strong>특징</strong>:</p>
<ul>
<li>높은 I/O, CPU, 메모리를 사용</li>
<li>클러스터의 데이터 용량과 성능을 결정</li>
<li>수평적으로 확장 가능</li>
</ul>
</li>
<li><p><strong>설정</strong>:</p>
<ul>
<li><code>node.roles: [ data ]</code>로 설정하여 데이터 전용 노드로 구성할 수 있음</li>
</ul>
</li>
</ol>
<h3 id="주요-차이점">주요 차이점</h3>
<ol>
<li><p><strong>데이터 저장</strong>:</p>
<ul>
<li>Master Node: 일반적으로 데이터를 저장하지 않음</li>
<li>Data Node: 실제 문서 데이터를 저장함</li>
</ul>
</li>
<li><p><strong>리소스 사용</strong>:</p>
<ul>
<li>Master Node: 상대적으로 적은 리소스를 사용</li>
<li>Data Node: 높은 리소스를 사용함 (특히 I/O, CPU, 메모리)</li>
</ul>
</li>
<li><p><strong>확장성</strong>:</p>
<ul>
<li>Master Node: 일반적으로 3-5개 정도면 충분</li>
<li>Data Node: 데이터 양과 처리량에 따라 수십, 수백 개로 확장 가능</li>
</ul>
</li>
<li><p><strong>작업 유형</strong>:</p>
<ul>
<li>Master Node: 클러스터 관리 작업을 중심으로 수행함</li>
<li>Data Node: 데이터 관련 작업을 중심으로 수행함</li>
</ul>
</li>
<li><p><strong>장애 영향</strong>:</p>
<ul>
<li>Master Node: 마스터 노드 장애 시 클러스터 전체에 영향을 미침</li>
<li>Data Node: 특정 데이터 노드 장애 시 해당 노드의 데이터만 영향을 받음 (레플리카로 대응 가능함)</li>
</ul>
</li>
</ol>
<p>Elasticsearch의 특정 구성 및 요구사항에 따라 마스터 노드와 데이터 노드의 역할을 분리하거나 결합할 수 있다.<br>대규모 클러스터에서는 역할을 분리하는 것이 일반적이지만, 소규모 클러스터에서는 모든 노드가 마스터와 데이터 역할을 동시에 수행할 수 있다.</p>
<br>
<br>

<hr>
<hr>
<br>
<br>


<h1 id="✏️-single-node-구성-docker">✏️ single-node 구성 (docker)</h1>
<br>

<p>우선 single-node 는 별도의 docker-compose 없이 진행해보자.
docker-compose 로 바로 설치할 사람은 <a href="#%E2%9C%8F%EF%B8%8F-multi-node-%EA%B5%AC%EC%84%B1-(docker-compose)">multi node 구성 (docker-compose)</a> 에서 진행하자.
docker 가 설치된 환경에서 cli 명령어로 진행한다.</p>
<h2 id="🔗-elasticseacrh-구성">🔗 ElasticSeacrh 구성</h2>
<pre><code class="language-bash"># Elastic stack 의 구성 요소들이 원할하게 통신하기 위한 네트워크 구성 
$ docker network create elastic
# 24/08 기준 최신 = 8.15.0  가급적 latest 말고 버전을 명시하자.
$ docker pull docker.elastic.co/elasticsearch/elasticsearch:8.15.0
# image 생성 확인
$ docker images
# image 를 기반으로 es01 이라는 컨테이너 띄우기
$ docker run --name es01 --net elastic -p 9200:9200 -it docker.elastic.co/elasticsearch/elasticsearch:8.15.0</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/dd594161-3d14-4184-984c-175695aed167/image.png" alt=""></p>
<br>
여기까지 왔다면, 실패했을 가능성이 높다. 가장 많이 일어나는 에러로는 가상메모리 영역이 부족해 발생하는데, <br>
우선 에러 로그부터 확인해보자.


<pre><code class="language-bash"># 기동중인 컨테이너 확인
$ docker ps -a
# es01 컨테이너의 에러 로그 json 형태로 출력
$ docker logs es01 | grep &#39;ERROR&#39; | jq .

# jq 가 설치되지 않았다면 아래 명령어로 설치한다. (ubuntu 기준)
$ sudo apt udpate
$ sudo apt install jq</code></pre>
<br>
그러면 아래와 같은 로그가 출력될 것이다. 

<pre><code class="language-json">{
  &quot;@timestamp&quot;: &quot;2024-08-12T10:01:15.462Z&quot;,
  &quot;log.level&quot;: &quot;ERROR&quot;,
  &quot;message&quot;: &quot;node validation exception\n[1] bootstrap checks failed. You must address the points described in the following [1] lines before starting Elasticsearch. For more information see [https://www.elastic.co/guide/en/elasticsearch/reference/8.15/bootstrap-checks.html]\nbootstrap check failure [1] of [1]: max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]; for more information see [https://www.elastic.co/guide/en/elasticsearch/reference/8.15/_maximum_map_count_check.html]&quot;,
  &quot;ecs.version&quot;: &quot;1.2.0&quot;,
  &quot;service.name&quot;: &quot;ES_ECS&quot;,
  &quot;event.dataset&quot;: &quot;elasticsearch.server&quot;,
  &quot;process.thread.name&quot;: &quot;main&quot;,
  &quot;log.logger&quot;: &quot;org.elasticsearch.bootstrap.Elasticsearch&quot;,
  &quot;elasticsearch.node.name&quot;: &quot;b8fc11aacbdd&quot;,
  &quot;elasticsearch.cluster.name&quot;: &quot;docker-cluster&quot;
}</code></pre>
<br>

<p>위를 보면 
<code>max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]</code> 
라는 로그가 보인다. 이는 아래와 같이 메모리 영역을 더 넓힐 수 있다.</p>
<br>

<pre><code class="language-bash"># 시스템 설정 파일 열기
$ sudo vi /etc/sysctl.conf

# 마지막 줄에 아래 내용을 추가한다.
vm.max_map_count=262144

# 즉시 적용
$ sudo sysctl -p</code></pre>
<br>

<p>다시 컨테이너를 기동해보자.</p>
<pre><code class="language-bash">$ docker start es01
$ docker ps -a</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/86be39e1-df83-4880-8469-82ca1ba8d2f0/image.png" alt=""></p>
<p>ElasticSearch 8.x 버전부터는 클라이언트에서 접속하기 위해서는 비밀번호와 SSL 인증서 정보가 필요하다. 
로그로도 확인할 수 있다.</p>
<pre><code class="language-bash">$ docker logs es01</code></pre>
<pre><code>✅ Elasticsearch security features have been automatically configured!
✅ Authentication is enabled and cluster connections are encrypted.

ℹ️  Password for the elastic user (reset with `bin/elasticsearch-reset-password -u elastic`):
  byH4ZsCd+XIX_uSsZxxM

ℹ️  HTTP CA certificate SHA-256 fingerprint:
  63b953f73c22500915986cecea40f5575ff47074c08a6a445c77bcac06a85143

ℹ️  Configure Kibana to use this cluster:
• Run Kibana and click the configuration link in the terminal when Kibana starts.
• Copy the following enrollment token and paste it into Kibana in your browser (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjE0LjAiLCJhZHIiOlsiMTcyLjE4LjAuMjo5MjAwIl0sImZnciI6IjYzYjk1M2Y3M2MyMjUwMDkxNTk4NmNlY2VhNDBmNTU3NWZmNDcwNzRjMDhhNmE0NDVjNzdiY2FjMDZhODUxNDMiLCJrZXkiOiJkWUliUnBFQjQyWExSUVdwcVNCZjpPWU1SeWdnblJNLWVVbmRpUmJLRUVBIn0=

ℹ️ Configure other nodes to join this cluster:
• Copy the following enrollment token and start new Elasticsearch nodes with `bin/elasticsearch --enrollment-token &lt;token&gt;` (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjE0LjAiLCJhZHIiOlsiMTcyLjE4LjAuMjo5MjAwIl0sImZnciI6IjYzYjk1M2Y3M2MyMjUwMDkxNTk4NmNlY2VhNDBmNTU3NWZmNDcwNzRjMDhhNmE0NDVjNzdiY2FjMDZhODUxNDMiLCJrZXkiOiJkb0liUnBFQjQyWExSUVdwcVNCZjpXZFpmVXNubFQ2R3BBalpjNjdBd2p3In0=

  If you&#39;re running in Docker, copy the enrollment token and run:
  `docker run -e &quot;ENROLLMENT_TOKEN=&lt;token&gt;&quot; docker.elastic.co/elasticsearch/elasticsearch:8.15.0`</code></pre><br>
docker 컨테이너 내부에 있는 crt 인증정보를 밖으로 복사하자.

<pre><code class="language-bash">$ docker cp es01:/usr/share/elasticsearch/config/certs/http_ca.crt .
$ ls</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/0169608e-0652-4654-92c1-3cd23004b0a2/image.png" alt=""></p>
<br>

<p>인증서까지 준비가 되었으니, curl 로 접속해보자. 
비밀번호는 위 <code>docker log es01</code> 에서 보았던 본인만의 비밀번호를 입력하면 된다.</p>
<pre><code class="language-bash">$ curl --cacert http_ca.crt -u elastic https://localhost:9200</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/9fcb4d7e-573d-491f-870f-a8f88ab97e51/image.png" alt=""></p>
<br>

<p>만약 비밀번호를 초기화하고 싶다면, 아래 명령어로 해결할 수 있다.</p>
<pre><code>$ docker exec -it es01 /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic</code></pre><br>

<hr>
<br>

<h2 id="🔗-kibana-구성">🔗 Kibana 구성</h2>
<br>

<pre><code class="language-bash"># iamge pull
$ docker pull docker.elastic.co/kibana/kibana:8.15.0
# kib01 이라는 컨테이너명으로 띄우기
$ docker run --name kib01 --net elastic -p 5601:5601 docker.elastic.co/kibana/kibana:8.15.0</code></pre>
<br>

<p>이후 웹에서 localhost:5601 에 접속해보자. (localhost -&gt; 서버 IP)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b87d8fdc-f0c9-4848-89b6-25cdf0846c03/image.png" alt=""></p>
<p>토큰을 입력하라는 modal 이 뜨면, 아래 명령어로 token 을 가져오자.
es01 컨테이너 내부에서 관리하는 token 을 출력하는 명령어이다.</p>
<br>

<pre><code class="language-bash">docker exec -it es01 /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/aae481fd-d61a-4aac-8cdd-5a7e4b9c2fe3/image.png" alt=""></p>
<br>

<p>그러면 또 귀찮게 6자리 인증번호를 입력하라고 한다..</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b9aa980b-420b-428b-8d43-c3389eac268f/image.png" alt=""></p>
<p>아래는 kib01 컨테이너 내부에서 관리하는 인증코드를 출력하는 명령어이다.</p>
<pre><code class="language-bash">$ docker exec -it kib01 ./bin/kibana-verification-code</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/0c87fa60-3592-49a7-aa48-3cc892d7082b/image.png" alt=""></p>
<p>그러면 여기까지 성공적으로 왔다. 이제 로그인만 남았다.
Username : elastic
Password : 아까 es01 컨테이너에서 발급받은 비밀번호 입력
<img src="https://velog.velcdn.com/images/mud_cookie/post/118fb072-91e6-4efb-8ecc-edd6fe85e717/image.png" alt=""></p>
<p>까먹었어도 괜찮다. 또 발급받으면 된다.</p>
<pre><code class="language-bash">$ docker exec -it es01 /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic</code></pre>
<p>그러면 ElasticSearch + Kibana 설치 및 환경 구성이 끝났다. 축하한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/61d6cb37-c8c1-4516-8197-a923bbef960e/image.png" alt=""></p>
<hr>
<h3 id="📜-elasticsearch-node-를-추가하고-싶다면">📜 ElasticSearch node 를 추가하고 싶다면?</h3>
<br>

<p>우선 node 등록에 필요한 token 을 발급받는다. 
토큰의 유효기간은 30분이니 참고하자.</p>
<pre><code class="language-bash">$ docker exec -it es01 /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s node</code></pre>
<pre><code class="language-bash">docker run -e ENROLLMENT_TOKEN=&quot;&lt;token&gt;&quot; --name es02 --net elastic -it -m 1GB docker.elastic.co/elasticsearch/elasticsearch:8.15.0</code></pre>
<br>

<p>아래와 같이 <code>&lt;token&gt;</code> 부분에 발급받은 토큰을 넣으면 된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/6e446588-64c3-4aa7-9b6a-b580970188e5/image.png" alt=""></p>
<p>이후 아래 cat nodex API 를 활용해 노드가 추가됐음을 인증한다.  </p>
<pre><code class="language-bash">$ curl --cacert http_ca.crt -u elastic:$ELASTIC_PASSWORD https://localhost:9200/_cat/nodes</code></pre>
<br>


<hr>
<hr>
<h1 id="✏️-multi-node-구성-docker-compose">✏️ multi node 구성 (docker-compose)</h1>
<p>위 설정들은 귀찮은 작업이 한 두개가 아니었다.
image pull 할 것도 없이 docker-compose.yml 에서 다 정의해보자.</p>
<br>
docker-compose.yml 에 적용될 변수들을 미리 정의하자.

<pre><code class="language-bash"># 설정을 모아놓을 디렉토리 생성
$ mkdir ~/elasticsearch
# .env 파일 생성, 확인, 편집
$ vi .env</code></pre>
<br>

<p>.env 파일 내부는 아래와 같이 공식 문서에서 버전, 비밀번호만 조금 변경했다. 
라이센스는 basic = 기본 기능을 무제한으로 제공하며, 
trial = 유료 기능을 30일 제한으로 제공하니 참고하자.
<a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.15/docker.html#run-kibana-docker">ElasticSearch docker 설치 공식문서</a>
<br></p>
<pre><code class="language-bash"># .env

# Password for the &#39;elastic&#39; user (at least 6 characters)
ELASTIC_PASSWORD=elastic

# Password for the &#39;kibana_system&#39; user (at least 6 characters)
KIBANA_PASSWORD=kibana_system

# Version of Elastic products
STACK_VERSION=8.15.0

# Set the cluster name
CLUSTER_NAME=docker-cluster

# Set to &#39;basic&#39; or &#39;trial&#39; to automatically start the 30-day trial
LICENSE=basic
#LICENSE=trial

# Port to expose Elasticsearch HTTP API to the host
ES_PORT=9200
#ES_PORT=127.0.0.1:9200

# Port to expose Kibana to the host
KIBANA_PORT=5601
#KIBANA_PORT=80

# Increase or decrease based on the available host memory (in bytes)
MEM_LIMIT=1073741824

# Project namespace (defaults to the current folder name if not set)
#COMPOSE_PROJECT_NAME=myproject
</code></pre>
<br>

<p>docker-compose.yml 을 작성하자.
docker-compose 로 build 및 실행할 때에는 docker-compose.yml 파일이 존재하는 위치에서 docker-compose 명령어를 실행해야 됨에 참고하자.
위 <code>mkdir ~/elasticsearch</code> 로 설정 파일을 모아놓은 디렉토리를 만든 이유이다.</p>
<pre><code># docker-compose.yml
version: &quot;2.2&quot;

services:
  setup:
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
    volumes:
      - certs:/usr/share/elasticsearch/config/certs
    user: &quot;0&quot;
    command: &gt;
      bash -c &#39;
        if [ x${ELASTIC_PASSWORD} == x ]; then
          echo &quot;Set the ELASTIC_PASSWORD environment variable in the .env file&quot;;
          exit 1;
        elif [ x${KIBANA_PASSWORD} == x ]; then
          echo &quot;Set the KIBANA_PASSWORD environment variable in the .env file&quot;;
          exit 1;
        fi;
        if [ ! -f config/certs/ca.zip ]; then
          echo &quot;Creating CA&quot;;
          bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
          unzip config/certs/ca.zip -d config/certs;
        fi;
        if [ ! -f config/certs/certs.zip ]; then
          echo &quot;Creating certs&quot;;
          echo -ne \
          &quot;instances:\n&quot;\
          &quot;  - name: es01\n&quot;\
          &quot;    dns:\n&quot;\
          &quot;      - es01\n&quot;\
          &quot;      - localhost\n&quot;\
          &quot;    ip:\n&quot;\
          &quot;      - 127.0.0.1\n&quot;\
          &quot;  - name: es02\n&quot;\
          &quot;    dns:\n&quot;\
          &quot;      - es02\n&quot;\
          &quot;      - localhost\n&quot;\
          &quot;    ip:\n&quot;\
          &quot;      - 127.0.0.1\n&quot;\
          &quot;  - name: es03\n&quot;\
          &quot;    dns:\n&quot;\
          &quot;      - es03\n&quot;\
          &quot;      - localhost\n&quot;\
          &quot;    ip:\n&quot;\
          &quot;      - 127.0.0.1\n&quot;\
          &gt; config/certs/instances.yml;
          bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key;
          unzip config/certs/certs.zip -d config/certs;
        fi;
        echo &quot;Setting file permissions&quot;
        chown -R root:root config/certs;
        find . -type d -exec chmod 750 \{\} \;;
        find . -type f -exec chmod 640 \{\} \;;
        echo &quot;Waiting for Elasticsearch availability&quot;;
        until curl -s --cacert config/certs/ca/ca.crt https://es01:9200 | grep -q &quot;missing authentication credentials&quot;; do sleep 30; done;
        echo &quot;Setting kibana_system password&quot;;
        until curl -s -X POST --cacert config/certs/ca/ca.crt -u &quot;elastic:${ELASTIC_PASSWORD}&quot; -H &quot;Content-Type: application/json&quot; https://es01:9200/_security/user/kibana_system/_password -d &quot;{\&quot;password\&quot;:\&quot;${KIBANA_PASSWORD}\&quot;}&quot; | grep -q &quot;^{}&quot;; do sleep 10; done;
        echo &quot;All done!&quot;;
      &#39;
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;[ -f config/certs/es01/es01.crt ]&quot;]
      interval: 1s
      timeout: 5s
      retries: 120

  es01:
    depends_on:
      setup:
        condition: service_healthy
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
    volumes:
      - certs:/usr/share/elasticsearch/config/certs
      - esdata01:/usr/share/elasticsearch/data
    ports:
      - ${ES_PORT}:9200
    environment:
      - node.name=es01
      - cluster.name=${CLUSTER_NAME}
      - cluster.initial_master_nodes=es01
      - discovery.seed_hosts=es02,es03
      - node.roles=master
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.key=certs/es01/es01.key
      - xpack.security.http.ssl.certificate=certs/es01/es01.crt
      - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.enabled=true
      - xpack.security.transport.ssl.key=certs/es01/es01.key
      - xpack.security.transport.ssl.certificate=certs/es01/es01.crt
      - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.verification_mode=certificate
      - xpack.license.self_generated.type=${LICENSE}
    mem_limit: ${MEM_LIMIT}
    ulimits:
      memlock:
        soft: -1
        hard: -1
    healthcheck:
      test:
        [
          &quot;CMD-SHELL&quot;,
          &quot;curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q &#39;missing authentication credentials&#39;&quot;,
        ]
      interval: 10s
      timeout: 10s
      retries: 120

  es02:
    depends_on:
      - es01
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
    volumes:
      - certs:/usr/share/elasticsearch/config/certs
      - esdata02:/usr/share/elasticsearch/data
    environment:
      - node.name=es02
      - cluster.name=${CLUSTER_NAME}
      - cluster.initial_master_nodes=es01
      - discovery.seed_hosts=es01,es03
      - node.roles=data
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.key=certs/es02/es02.key
      - xpack.security.http.ssl.certificate=certs/es02/es02.crt
      - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.enabled=true
      - xpack.security.transport.ssl.key=certs/es02/es02.key
      - xpack.security.transport.ssl.certificate=certs/es02/es02.crt
      - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.verification_mode=certificate
      - xpack.license.self_generated.type=${LICENSE}
    mem_limit: ${MEM_LIMIT}
    ulimits:
      memlock:
        soft: -1
        hard: -1
    healthcheck:
      test:
        [
          &quot;CMD-SHELL&quot;,
          &quot;curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q &#39;missing authentication credentials&#39;&quot;,
        ]
      interval: 10s
      timeout: 10s
      retries: 120

  es03:
    depends_on:
      - es02
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
    volumes:
      - certs:/usr/share/elasticsearch/config/certs
      - esdata03:/usr/share/elasticsearch/data
    environment:
      - node.name=es03
      - cluster.name=${CLUSTER_NAME}
      - cluster.initial_master_nodes=es01
      - discovery.seed_hosts=es01,es02
      - node.roles=data
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.key=certs/es03/es03.key
      - xpack.security.http.ssl.certificate=certs/es03/es03.crt
      - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.enabled=true
      - xpack.security.transport.ssl.key=certs/es03/es03.key
      - xpack.security.transport.ssl.certificate=certs/es03/es03.crt
      - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.verification_mode=certificate
      - xpack.license.self_generated.type=${LICENSE}
    mem_limit: ${MEM_LIMIT}
    ulimits:
      memlock:
        soft: -1
        hard: -1
    healthcheck:
      test:
        [
          &quot;CMD-SHELL&quot;,
          &quot;curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q &#39;missing authentication credentials&#39;&quot;,
        ]
      interval: 10s
      timeout: 10s
      retries: 120

  kibana:
    depends_on:
      es01:
        condition: service_healthy
      es02:
        condition: service_healthy
      es03:
        condition: service_healthy
    image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
    volumes:
      - certs:/usr/share/kibana/config/certs
      - kibanadata:/usr/share/kibana/data
    ports:
      - ${KIBANA_PORT}:5601
    environment:
      - SERVERNAME=kibana
      - ELASTICSEARCH_HOSTS=https://es01:9200
      - ELASTICSEARCH_USERNAME=kibana_system
      - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
      - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=config/certs/ca/ca.crt
    mem_limit: ${MEM_LIMIT}
    healthcheck:
      test:
        [
          &quot;CMD-SHELL&quot;,
          &quot;curl -s -I http://localhost:5601 | grep -q &#39;HTTP/1.1 302 Found&#39;&quot;,
        ]
      interval: 10s
      timeout: 10s
      retries: 120

volumes:
  certs:
    driver: local
  esdata01:
    driver: local
  esdata02:
    driver: local
  esdata03:
    driver: local
  kibanadata:
    driver: local</code></pre><br>

<p>위 내용을 간략히 요약하면, .env 에 있는 설정을 적용하고
SSL 인증을 넣어 node 를 3개 띄운다는 설정이다.
es01 컨테이너를 Master 노드로 잡고, es02, es03 컨테이너를 Data 노드로 잡아두었다.</p>
<blockquote>
<p>*<em>일반적으로 마스터 노드는 데이터를 저장하지 않고 인덱싱 및 관리를 담당하니, 데이터를 저장시키는 역할이 아닌 master 로서의 역할만 가지고 있는 것이 좋다. *</em></p>
</blockquote>
<br>

<p>.env 파일을 docker-compose 에 적용시키고 기동해보자.
앞서 말했듯이, docker-compose 가 존재하는 디렉토리에서 실행해야 한다.</p>
<pre><code class="language-bash"># env설정을 적용함.
docker-compose config

# 기동
docker-compose up -d</code></pre>
<br>

<p>docker-compose.yml 에 적혀있던 echo 출력들이 잘 되어 아래와 같이 나온 모습이다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/51890ff9-d800-451b-b949-11a1936aeec4/image.png" alt=""></p>
<br>

<p>컨테이너명들을 확인해보고, 인증서를 적용하자.</p>
<pre><code class="language-bash">$ docker ps</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/6b0ac16a-2dd3-4913-83ee-64a90b1055cf/image.png" alt=""></p>
<p>elasticsearch-es-0?-? 으로 node (컨테이너) 들이 기동된 것이 확인된다. 
elasticsearch-es01-1 컨테이너의 인증서를 밖으로 가져오자.</p>
<pre><code class="language-bash"># elasticsearch-es01-1
$ docker cp elasticsearch-es01-1:/usr/share/elasticsearch/config/certs/ca .</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/fa1cc1ea-98b5-4f9d-b22f-3c9910ebf0c5/image.png" alt=""></p>
<br>

<p>인증서를 가져왔으니, 이제 curl 로 접속을 테스트해보자.
비밀번호는 위 .env 파일에서 적용한 elasticsearch 의 비밀번호를 입력하면 된다. (ELASTIC_PASSWORD)</p>
<pre><code>$ curl --cacert ca.crt -u elastic https://localhost:9200alhost:9200</code></pre><p><img src="https://velog.velcdn.com/images/mud_cookie/post/62f9873d-e6cb-4bc9-b43b-1a4fe432e157/image.png" alt=""></p>
<p>인증서는 잘 보관해두고, kibana 도 접속해보자.
웹에서 localhost:5601 에 접속하자. localhost -&gt; ip
여기에서의 비밀번호 역시 .env 에서 적용한 비밀번호를 입력하면 된다.
KIBANA_PASSWORD=kibana_system 은 kibana 내부적으로 사용하는 비밀번호 이므로, ELASTIC_PASSWORD 를 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/3b9b7340-f8e8-4042-8fbd-84b211ef116b/image.png" alt=""></p>
<br>

<p>설정은 끝났다. 
만약 Data node 를 추가하고 싶다면, 
docker-compose.yml 에서 es02 나 es03 설정을 그대로 복사해 es04 를 만들면 된다.</p>
<br>

<blockquote>
<p>이 외로 MetricBeat, FileBeat, LogStash, APM, Fleet 등을 추가로 설치해 데이터 저장소 뿐 아니라 외부 로그 및 통합 관제로서의 역할도 할 수 있으나,
해당 기능들은 K8s 를 사용하는 상태라면 굳이 필요가 없다.
오픈소스가 잘 되어있기도 하고, 배포 / 모니터링 / 관리를 목적으로 사용하는 k8s 에서 담당하는 것이 맞다고 생각된다. <br>
만약 위 기능들을 사용하고 싶다면 
<a href="https://www.youtube.com/watch?v=q74_FfM7sn0&amp;list=PLPatHYWw1RVsoX4jww-N4W6x-TscezmaC&amp;index=4">https://www.youtube.com/watch?v=q74_FfM7sn0&amp;list=PLPatHYWw1RVsoX4jww-N4W6x-TscezmaC&amp;index=4</a>
에서 설정 방법들을 확인하자.</p>
</blockquote>
<br> 
<br>

<hr>
<br>
<br>


<h3 id="reference">Reference</h3>
<blockquote>
<p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.15/docker.html">ElasticSearch docker 설치 공식문서</a>  </p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[python 첫 세팅 관련 (VS Code, 가상환경, Docker)]]></title>
            <link>https://velog.io/@mud_cookie/python-%EC%B2%AB-%EC%84%B8%ED%8C%85-%EA%B4%80%EB%A0%A8-%EC%9E%91%EC%84%B1%EC%A4%91</link>
            <guid>https://velog.io/@mud_cookie/python-%EC%B2%AB-%EC%84%B8%ED%8C%85-%EA%B4%80%EB%A0%A8-%EC%9E%91%EC%84%B1%EC%A4%91</guid>
            <pubDate>Fri, 09 Aug 2024 11:06:04 GMT</pubDate>
            <description><![CDATA[<h3 id="python-download">Python Download</h3>
<ul>
<li><a href="https://www.python.org/downloads/">https://www.python.org/downloads/</a></li>
</ul>
<h3 id="vs-code-download">VS Code Download</h3>
<ul>
<li><a href="https://code.visualstudio.com/">https://code.visualstudio.com/</a></li>
</ul>
<br>
<br>

<h2 id="🔗-python-환경-변수-설정">🔗 Python 환경 변수 설정</h2>
<ul>
<li>시스템 변수의 Path 에 추가한다.</li>
<li>C:\Users{UserName}\AppData\Local\Programs\Python{PythonVersion}</li>
<li>C:\Users{UserName}\AppData\Local\Programs\Python{PythonVersion}\Scripts
<img src="https://velog.velcdn.com/images/mud_cookie/post/b38c82c2-ec72-4da9-866e-d58d270a4185/image.png" alt=""></li>
<li>cmd 에서 python --version 입력 후 환경변수 세팅 완료 및 버전 확인
<img src="https://velog.velcdn.com/images/mud_cookie/post/33b1d88e-8afd-4b71-b78c-9ad84be1815b/image.png" alt=""></li>
</ul>
<br>

<h2 id="🔗-vs-code-terminal-git-bash-설정">🔗 VS Code Terminal Git Bash 설정</h2>
<ul>
<li>Windows 기본값 : PowerShell  (PowerShell 로는 conda 명령어 인식이 잘 안 될 수 있음)</li>
<li>Ctrl + ,  으로 세팅 진입 → terminal.integrated.defaultprofile.windows 입력</li>
<li>Git Bash 선택 후 재기동</li>
<li><img src="https://velog.velcdn.com/images/mud_cookie/post/2f9f9a8d-dedc-401d-af67-0b7bea693539/image.png" alt=""></li>
</ul>
<br>

<h3 id="📜-python-가상환경이란">📜 Python 가상환경이란?</h3>
<ul>
<li>가상환경(Virtual Environment)은 파이썬에서 독립적인 프로젝트를 위한 개별적인 공간.
여러 프로젝트를 진행할 때 각 프로젝트의 &#39;의존성(dependencies)&#39;과 &#39;라이브러리(libraries)&#39;를 구분하여 관리할 수 있게 해줌.
이를 통해 한 시스템에서 서로 다른 버전의 파이썬 라이브러리를 사용할 수 있다.<ul>
<li>일반적으로 하나의 디렉토리 안에 여러 개의 프로젝트가 있을 때, 각 프로젝트별로 가상환경을 만든다.</li>
</ul>
</li>
<li>가상환경 세팅의 장점<ul>
<li><strong>프로젝트 분리</strong>: 다양한 프로젝트에서 서로 다른 라이브러리 버전을 사용할 수 있어, 한 프로젝트에서의 변경이 다른 프로젝트에 영향을 미치지 않음.</li>
<li><strong>의존성 관리</strong>: 프로젝트별로 필요한 라이브러리와 버전을 명확히 관리할 수 있어, 코드의 호환성 및 재현성을 높일 수 있음.</li>
<li><strong>개발 환경 일관성</strong>: 다른 개발자와 협업 시, 같은 환경에서 작업함으로써 발생할 수 있는 문제를 최소화.</li>
</ul>
</li>
<li>가상환경 디렉토리는 git 에 올리지 않는다.</li>
</ul>
<br>

<h3 id="📜-venv-vs-conda">📜 Venv vs Conda</h3>
<ul>
<li>venv<ul>
<li>venv는 Python <strong>표준 라이브러리에 포함된</strong> 가상환경 관리 도구. </li>
<li>Python 3.3부터 기본적으로 제공되며, 특정 프로젝트의 종속성 관리를 위해 가상환경을 생성할 수 있음.</li>
<li>터미널에서 python, pip 명령어로 사용한다.</li>
</ul>
</li>
<li>conda<ul>
<li>conda는 Anaconda와 Miniconda 배포판에 포함된 가상환경 및 패키지 관리 도구. </li>
<li>Python뿐만 아니라 다른 언어와의 호환성을 제공하며, 데이터 과학 및 머신러닝 프로젝트에서 주로 사용됨.<ul>
<li>멀티 언어 지원: Python뿐만 아니라 R, Ruby, Lua 등 다양한 언어의 패키지를 관리할 수 있음.</li>
<li>패키지 관리: 패키지 관리와 가상환경 관리를 통합하여 더 나은 종속성 해결을 제공합니다.</li>
<li><strong>대형 패키지 지원</strong>: 데이터 과학, 머신러닝 등에서 자주 사용되는 대형 패키지(예: NumPy, Pandas, TensorFlow 등)를 쉽게 설치하고 관리할 수 있음.</li>
</ul>
</li>
<li>터미널에서 conda 명령어로 사용한다.</li>
</ul>
</li>
</ul>
<br>

<h2 id="🔗-vs-code-에서-venv-가상환경-세팅">🔗 VS Code 에서 Venv 가상환경 세팅</h2>
<ul>
<li><p>python 표준 라이브러리에 포함되어 별도 설치 X</p>
</li>
<li><p><strong>프로젝트별로 내부에 .venv 디렉토리를 생성하는 것이 일반적이다.</strong></p>
</li>
<li><p><a href="https://code.visualstudio.com/docs/python/environments">https://code.visualstudio.com/docs/python/environments</a></p>
</li>
<li><p>Ctrl + Shift + P   → Python: Create Environment</p>
</li>
<li><p><img src="https://code.visualstudio.com/assets/docs/python/environments/create_environment_dropdown.png" alt="Create Environment dropdown"></p>
</li>
<li><p>이후 설치 과정 중 python interpreter 를 선택하면, 좌측 프로젝트 구조에 .venv 디렉토리가 추가된 것을 볼 수 있다.</p>
</li>
<li><p><img src="https://code.visualstudio.com/assets/docs/python/environments/interpreters-list.png" alt="Virtual environment interpreter selection"></p>
</li>
<li><p><img src="https://velog.velcdn.com/images/mud_cookie/post/058becd2-f7b9-44ae-a700-8eaaf773140e/image.png" alt=""></p>
</li>
<li><p><img src="https://velog.velcdn.com/images/mud_cookie/post/f92f1db7-4bda-455f-aeba-0276ed11f1d3/image.png" alt=""></p>
</li>
<li><p>다만 위 방법은 현재 Terminal 의 위치가 아닌 VS Code 가 열린 프로젝트 의 root 에 설치가 되므로, 
원하는 디렉토리에서 터미널 명령어로 만드는 것이 더 좋아 보인다.</p>
<pre><code class="language-bash">$ python -m venv ./{디렉토리명}   # 현재 터미널 기준 디렉토리 하위에 {디렉토리명} 으로 생성</code></pre>
</li>
<li><p>venv 가상환경 activate / deactivate 하기</p>
<pre><code class="language-bash">$ source {디렉토리명}/Scripts/activate  # 활성화, {디렉토리명} 의 상위 디렉토리에서 실행
$ deactivate      # 비활성화</code></pre>
</li>
<li><p>활성화가 된다면,  아래와 같이 터미널의 라인 앞에 (venv) 텍스트가 뜬다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/4fd8d918-99bc-4737-bad8-498e52ddda96/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p>pip install 로 라이브러리 설치</p>
<pre><code class="language-bash">$ pip install {package_name}=={version}   # 패키지명만 입력하면 최신 버전 설치
$ pip list # 설치된 pip 라이브러리 확인
$ pip uninstall {package_name}</code></pre>
</li>
<li><p>로컬에서 python 실행</p>
</li>
</ul>
<br>

<h2 id="🔗-vs-code-에서-conda-가상환경-세팅">🔗 VS Code 에서 Conda 가상환경 세팅</h2>
<ul>
<li><p>Anaconda 가상환경 Download</p>
<ul>
<li><del><a href="https://docs.anaconda.com/anaconda/install/windows/">https://docs.anaconda.com/anaconda/install/windows/</a></del></li>
<li><a href="https://docs.anaconda.com/miniconda/">Miniconda 다운로드</a></li>
<li>Anaconda 라이센스 정책이 변경되어 200인 이상 규모에서는 유료로 변경되었다. <br> Miniconda + foge repository 를 사용하면 무료로 사용가능하다. <br> 참고 : <a href="https://devocean.sk.com/blog/techBoardDetail.do?ID=164615">Miniconda 참고</a><br></li>
</ul>
</li>
<li><p><strong>OS 전역적으로 conda 명령어를 통해 사용되며, 가상환경 세팅 경로는 특정 디렉토리 안에 모아둔다.</strong></p>
</li>
<li><p>Windows - 환경 변수 적용</p>
<ul>
<li>anaconda 설치 시 시스템이 지정하는 기본 경로에 설치했다면, 아래 3개의 변수를 시스템 변수에 추가한다.</li>
<li>C\Users{UserName}\miniconda3</li>
<li>C\Users{UserName}\miniconda3\Library</li>
<li>C\Users{UserName}\miniconda3\Scripts</li>
<li><img src="https://velog.velcdn.com/images/mud_cookie/post/7066d519-c08d-4b07-a997-affa61d05b5d/image.png" alt=""></li>
</ul>
</li>
<li><p>위 환경변수를 적용했음에도 conda 명령어 인식이 되지 않는다면, 변수를 아래 값으로 설정하자.</p>
<ul>
<li>C:\Users{UserName}\AppData\Local\miniconda3</li>
<li>C:\Users{UserName}\AppData\Local\miniconda3\Library</li>
<li>C:\Users{UserName}\AppData\Local\miniconda3\Scripts</li>
</ul>
</li>
<li><p>Terminal 에서 conda 명령어를 정상적으로 인식하는지 확인</p>
<pre><code class="language-bash">$ conda --version
$ conda init bash  # bash Terminal 을 사용한다면 최초 한 번 실행해주어야 한다.</code></pre>
</li>
<li><p>유료 repository 에서 라이브러리를 가져왔다가 괜히 불상사가 생길 수 있으므로, repository 채널을 바꾸도록 하자.</p>
<pre><code class="language-bash">$ conda config --add channels conda-forge
$ conda config --set channel_priority strict</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/f4364179-491e-4562-8d8b-8ab35631fa7b/image.png" alt="">
이후 repository channel 확인</p>
<pre><code class="language-bash">$ conda config --show channels</code></pre>
<p>위와 같이 conda-forge repository(channel) 이 우선적으로 사용되게 할 수 있는데, 이마저도 불안하니 defaults 를 삭제하자.</p>
<pre><code class="language-bash">$ conda config --remove channels defaults</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/0dd7c2cd-fca5-42b9-ba8b-26429ad40fba/image.png" alt=""></p>
</li>
<li><p>Terminal 에서 conda 명령어로 가상환경을 직접 생성해보자.</p>
<pre><code class="language-bash"># 가상환경 생성 (env_name은 가상환경 이름)
$ conda create --name {env_name} python={3.xx}
# 내 OS 에 생성된 가상환경 리스트 나열
$ conda env list</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b89356a3-b0f3-4f2c-8cf2-1cc9009405c9/image.png" alt=""></p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/29afe449-380a-4d65-84db-9636d352adf8/image.png" alt=""></p>
<p>  가상환경이 생성되었다면, 
  C:\Users{userName}\AppData\Local\miniconda3\envs 혹은 
  C:\Users{userName}\miniconda3\envs 디렉토리에 {env_name} 명으로 디렉토리가 생성되었을 것이다.  (환경변수가 인식하는 위치)
  앞으로는 conda 가 가상환경 디렉토리는 해당 위치에 모아놓을 것이니 참고해 두도록 하자.</p>
<pre><code class="language-bash">  # 가상환경 활성화
  $ conda activate {env_name}</code></pre>
<p>  활성화가 잘 되었다면, 위에서 언급한 venv 와 같이 터미널 라인 앞에 활성화된 (가상환경명)이 출력된다.
  <img src="https://velog.velcdn.com/images/mud_cookie/post/48d6a11a-ec7f-4068-9098-eae0ed2c3375/image.png" alt=""></p>
<pre><code class="language-bash">  # 가상환경 비활성화
  $ conda deactivate
  # 라이브러리 설치 (pip install 로 해도 무방)
  $ conda install {package_name}
  # 라이브러리 삭제 
  $ conda remove {package_name}  # pip 는 remove 가 아닌 uninstall
  # 해당 가상환경에서 설치된 라이브러리들 나열
  $ conda list</code></pre>
<ul>
<li><p>conda 가상환경이 활성화된 상태에서 라이브러리가 설치되면, </p>
<ul>
<li>C:\Users{UserName}\AppData\Local\miniconda3\envs{EnvName}\Lib\site-packages  혹은</li>
<li>C:\Users{UserName}\miniconda3\envs{EnvName}\Lib\site-packages  디렉토리에 설치되니 참고하자.</li>
</ul>
</li>
<li><p>참고로 내가 원하는 라이브러리를 설침했는데 import 오류가 발생하는 에러메시지가 출력되면, vscode 에서 interpreter 타겟을 제대로 설정했는지 확인해보자. <br>
Ctrl + Shift + P 를 눌러 Python: Select Inerpreter 를 검색하면 <br>
<img src="https://velog.velcdn.com/images/mud_cookie/post/9cb087d3-b882-45df-b270-194790f376cf/image.png" alt=""></p>
</li>
</ul>
<p>위와 같이 나오는 리스트에서 \miniconda3\envs\ 디렉토리 하위에 내가 설정한 conda env name 으로 선택하면 된다.</p>
<pre><code class="language-bash"># env 삭제 명령어
$ conda remove --name {env_name} --all</code></pre>
<br>


<h3 id="📜-conda-install-과-pip-install-의-차이">📜 conda install 과 pip install 의 차이</h3>
<table>
<thead>
<tr>
<th></th>
<th>pip</th>
<th>conda</th>
</tr>
</thead>
<tbody><tr>
<td>패키지 관리자</td>
<td>Python 환경에서 Python 패키지를 관리</td>
<td>Python 자체를 관리 가능하며, 다른 언어도 관리가 가능</td>
</tr>
<tr>
<td>패키지 출처</td>
<td>Python 패키지 인덱스 (PyPI)<br>최신 라이브러리가 많다.</td>
<td>Conda Repository<br>PyPI 에 있는 최신 라이브러리를 따라가지는 못하지만,<br>Data Science 관련한 라이브러리가 많다.</td>
</tr>
<tr>
<td>환경 관리</td>
<td>Python 패키지를 관리하나, 버전 호환성을 보장하지는 않음.</td>
<td>패키지 설치 시 종속성을 관리, 해당 패키지의 모든 종속성을<br>자동으로 설치하고, 충돌을 방지하기 위해 버전 호환성을 보장함.</td>
</tr>
<tr>
<td>속도</td>
<td>소스에서 패키지를 설치해야 하는 경우가 있어,<br>일부 패키지는 컴파일 과정이 필요하며 시간이 더 걸릴 수 있음.</td>
<td>바이너리 패키지를 설치하기 때문에 컴파일 과정이 필요 없어 일반적으로 설치가 빠름.</td>
</tr>
<tr>
<td>용도</td>
<td>Python으로 작성된 패키지를 설치할 때 주로 사용<br>특히 최신의 Python 라이브러리를 설치할 때 유용</td>
<td>복잡한 라이브러리 버전 호환이 필요할 때<br>Data Science 관련한 라이브러리를 사용할 때</td>
</tr>
</tbody></table>
<p>아래는 conda 가상환경을 python 3.12 로 만들어 활성화 한 후 , pip install selenium 후 conda env list 출력한 예시이다.</p>
<p>python 의 경우에는 conda 자체에서 관리되며, pip install 을 했으므로 Channel = pypi 로 출력됨을 볼 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/73f5f8ed-982a-409f-a96a-2a30184776ab/image.png" alt=""></p>
<hr>
<h2 id="🔗-python-프로젝트를-venv-docker-이미지로-build-하고-컨테이너-기동하기">🔗 Python 프로젝트를 venv Docker 이미지로 Build 하고, 컨테이너 기동하기</h2>
<br>

<p>우선 실행환경에 docker 설치 및 실행이 완료되었다는 가정하에 진행한다.
conda docker 로 사용하기에는 조금 무거운 감이 있어, venv 환경에서 띄우는 것을 진행해보자.</p>
<p>python 프로젝트 내부에 Dockerfile 파일을 추가한다.</p>
<pre><code># Dockerfile

# 베이스 이미지로 Python 사용
FROM python:3.12.4-slim

# 작업 디렉토리 생성
WORKDIR /app

# 필요 파일 복사
COPY requirements.txt requirements.txt
COPY app.py app.py

# 의존성 설치
RUN pip install --no-cache-dir -r requirements.txt

# app.py 파일 실행
CMD [&quot;python&quot;, &quot;app.py&quot;]</code></pre><p>위 파일에서는 requirements.txt 파일을 기반으로 pip install 을 진행하므로, 
requirements.txt 파일에 어떤 라이브러리들을 설치할 지 명시해야 한다.</p>
<pre><code># requirements.txt
# 예시)
Flask
Selenium</code></pre><p><img src="https://velog.velcdn.com/images/mud_cookie/post/a35baaf9-e778-446e-acfe-7519ad7fa62a/image.png" alt=""></p>
<p>이제 Terminal 에서 Docker 이미지를 만들어보자.</p>
<pre><code class="language-bash"># Docker 이미지 빌드
$ docker build -t {image_name} .
ex) docker build -t test .</code></pre>
<pre><code class="language-bash"># Docker 컨테이너 실행
$ docker run -d -p {host_port}:{container_port} {image_name}
ex) docker run -d -p 8080:5000 test</code></pre>
<br>

<p>실제로 잘 구동되는지 확인하기 위해, api 를 만들고 응답을 테스트해보자. flask 는 간단한 api 구현을 위한 라이브러리이다.
아래 코드는 localhost:5000 에 접속 시 &quot;Hello, World&quot; 를 리턴하는 API 를 명세한 것이다.</p>
<pre><code class="language-bash">$ pip install flask</code></pre>
<pre><code class="language-python"># app.py
from flask import Flask

app = Flask(__name__)

@app.route(&#39;/hello&#39;)
def hello():
    return &quot;Hello, World&quot;

if __name__ == &#39;__main__&#39;:
    app.run(host=&#39;0.0.0.0&#39;, port=5000)</code></pre>
<p>위에서 언급한대로 image 를 빌드하고 컨테이너를 띄워보자.</p>
<p>이미지를 Terminal 에서 띄운 모습.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/df48e9fc-ed3b-4cdb-9bad-4d5741962647/image.png" alt=""></p>
<p>Docker 에서 이미지가 생성된 화면. (Windows)</p>
<pre><code>$ docker images  # 명령어로도 image 생성 확인 가능.</code></pre><p><img src="https://velog.velcdn.com/images/mud_cookie/post/e50d8c09-d157-44ea-9122-cded87bce7d5/image.png" alt=""></p>
<p>docker run
<img src="https://velog.velcdn.com/images/mud_cookie/post/87bc700b-54ee-45ab-be26-c269e6070c90/image.png" alt=""></p>
<p>기동이 잘 됐는지 확인하기 위해, localhost:8080/hello 에 접속해보자.
외부 포트 8080 -&gt; 도커 내부 포트 5000 에 매핑하였으므로 정상적으로 출력될 것이다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/5c0163c1-a951-445e-884e-284a678a1f84/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java Stream 은 왜 등장했는가]]></title>
            <link>https://velog.io/@mud_cookie/Java-Stream-%EC%9D%80-%EC%99%9C-%EB%93%B1%EC%9E%A5%ED%96%88%EB%8A%94%EA%B0%80-%EC%82%AC%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@mud_cookie/Java-Stream-%EC%9D%80-%EC%99%9C-%EB%93%B1%EC%9E%A5%ED%96%88%EB%8A%94%EA%B0%80-%EC%82%AC%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Sun, 05 May 2024 13:38:56 GMT</pubDate>
            <description><![CDATA[<h2 id="🔗-stream-의-등장-배경">🔗 Stream 의 등장 배경</h2>
<br>
Java의 Stream API는 Java 8에서 처음 도입되었다. <br>

<p>이전의 Java 버전들에서는 대량의 데이터 처리 작업을 효율적으로 수행하기 위해
외부 반복을 사용했었는데,</p>
<p>외부 반복은 개발자가 명시적으로 데이터 컬렉션을 반복하는 코드를 작성해야 한다는 단점이 있었다.</p>
<p>이는 코드가 길어지고 복잡해지며 (<code>보일러 플레이트 코드</code>의 증가),
멀티코어 환경에서의 병렬 처리를 직접 관리해야 하는 어려움이 있다.</p>
<br>

<pre><code class="language-java">// java 8 이전의 반복문 예시
List&lt;String&gt; names = Arrays.asList(&quot;Steve&quot;, &quot;John&quot;, &quot;Jane&quot;, &quot;Tom&quot;);
List&lt;String&gt; filteredNames = new ArrayList&lt;&gt;();
for (String name : names) {
    if (name.startsWith(&quot;J&quot;)) {
        filteredNames.add(name);
    }
}
Collections.sort(filteredNames);
System.out.println(filteredNames); // [John, Jane]</code></pre>
<br>

<pre><code class="language-java">// java 8 이후의 반복문 예시
List&lt;String&gt; names = Arrays.asList(&quot;Steve&quot;, &quot;John&quot;, &quot;Jane&quot;, &quot;Tom&quot;);
List&lt;String&gt; sortedFilteredNames = names.stream()
    .filter(name -&gt; name.startsWith(&quot;J&quot;))
    .sorted()
    .collect(Collectors.toList());
System.out.println(sortedFilteredNames); // [Jane, John]
</code></pre>
<p>Stream API의 등장은 이러한 문제를 해결하기 위한 것으로, 
내부 반복을 사용하여 데이터를 추상화하고 데이터 컬렉션을 효율적으로 처리할 수 있도록 도움을 준다.
내부 반복을 통해, 개발자는 <code>무엇을</code> 처리할지에 초점을 맞추고, 
<code>어떻게</code> 처리할지는 라이브러리에 맡긴다. 이는 </p>
<ul>
<li>코드의 간결성을 높이고, </li>
<li>유지보수를 용이하게 하며,</li>
<li>병렬 처리를 자동으로 최적화할 수 있는 장점을 제공한다.</li>
</ul>
<br>

<h2 id="🔗-stream-api-의-기본-사용법">🔗 Stream API 의 기본 사용법</h2>
<br>

<p>Stream API는 java.util.stream.Stream 인터페이스를 통해 사용할 수 있고, 
컬렉션에 <code>.stream()</code> 메소드를 호출하여 스트림을 생성할 수 있다.</p>
<p>스트림을 사용하는 기본적인 패턴은 다음과 같다.</p>
<pre><code class="language-java">// 배열 정렬 후 출력 예시
List&lt;String&gt; strList01 = new ArrayList&lt;&gt;(Arrays.asList(&quot;B&quot;, &quot;A&quot;, &quot;E&quot;, &quot;D&quot;, &quot;C&quot;));
// 생성
Stream&lt;String&gt; stream01 = strList01.stream();    
// 중간 연산
Stream&lt;String&gt; sortedStream01 = stream01.sorted();    
// 종단 연산
List&lt;String&gt; sorted01 = sortedStream01.toList();    
System.out.println(sorted01); // [A, B, C, D, E]</code></pre>
<p>위는 하나하나씩 단계별로 나타낸 예시이고, 실제로는 아래와 같은 형태로 많이 사용된다.</p>
<pre><code class="language-java">// 배열 정렬 후 출력 예시
List&lt;String&gt; strList01 = new ArrayList&lt;&gt;(Arrays.asList(&quot;B&quot;, &quot;A&quot;, &quot;E&quot;, &quot;D&quot;, &quot;C&quot;));
// 생성, 중간 연산, 종단 연산을 체이닝하여 작성
List&lt;String&gt; sorted01 = strList01.stream().sorted().toList();
System.out.println(sorted01);</code></pre>
<br>

<p>한 가지 알아두어야 할 점으로, Stream은 원본 데이터를 읽는 기능만 할 뿐 원본데이터 자체를 변경하지 않는다.</p>
<p>그렇기 때문에 원본 데이터가 변형될 걱정은 하지 않아도 된다. </p>
<p>또한 Java 8 Stream은 일회성이기 때문에 한 번 사용될 경우 재사용이 불가능하다. 
즉 필요하다면 정렬된 결과를 배열 혹은 컬렉션에 담아 반환해야 한다. </p>
<p>Java 8 Stream도 기존 방식과 마찬가지로 작업을 내부적으로 반복하여 처리한다. 
반복 코드는 메소드 내부에 숨어져 있어 코드 상에 노출이 되지 않아 더욱 깔끔한 비즈니스 로직을 설계할 수 있다.</p>
<br>




<p><br><br></p>
<h2 id="❗-stream-주의점">❗ Stream 주의점</h2>
<br>
Stream 을 사용해봤다면, for-loop 으로 순회하는 것보다 성능이 떨어진다는 소리를 한 번쯤은 들어봤을 것이다.
loop와 순차 스트림(sequential stream), 그리고 병렬 스트림(parallel stream) 별로 퍼포먼스가 어떤지 벤치마크 실험을 아래 링크를 각색하여 재현해 보았다.

<blockquote>
<p>참고
<a href="http://www.angelikalanger.com/Conferences/Videos/Conference-Video-jDays-2016-Streams-in-Java-8-Reduce-vs-Collect-Angelika-Langer.html">http://www.angelikalanger.com/Conferences/Videos/Conference-Video-jDays-2016-Streams-in-Java-8-Reduce-vs-Collect-Angelika-Langer.html</a></p>
</blockquote>
<br>


<h3 id="✏️-for-loop-vs-순차-스트림">✏️ for-loop vs 순차 스트림</h3>
<br>

<p>아래 예시는 50만개의 랜덤 정수 primitive type 배열을 생성하고, 
각각 for-loop 와 stream 을 사용해 배열 내 최대값을 구하는 실행시간을
출력하는 코드이다. </p>
<pre><code class="language-java">// 50만개의 랜덤 정수 primitive type 배열 생성
int[] ints = new int[500000];
Random rand = new Random();
for (int i = 0; i &lt; ints.length; i++) {
    ints[i] = rand.nextInt();
}

// for-loop
int m = Integer.MIN_VALUE;
long forLoopStartTime = System.nanoTime();
for (int i = 0; i &lt; ints.length; i++) {
    if (ints[i] &gt; m) {
        m = ints[i];
    }
}
long forLoopEndTime = System.nanoTime();
System.out.println(&quot;Maximum value found: &quot; + m);
System.out.println(&quot;Execution time (for-loop): &quot; + (forLoopEndTime - forLoopStartTime) + &quot; nanoseconds&quot;);

// sequential stream
long streamStartTime = System.nanoTime();
int max = Arrays.stream(ints).reduce(Integer.MIN_VALUE, Math::max);
long streamEndTime = System.nanoTime();
System.out.println(&quot;Maximum value found: &quot; + max);
System.out.println(&quot;Execution time (Stream): &quot; + (streamEndTime - streamStartTime) + &quot; nanoseconds&quot;);</code></pre>
<br>

<p><img src="https://velog.velcdn.com/images/mud_cookie/post/73f0fbaf-eb9a-4f5b-a888-911c745571ee/image.png" alt="primitive type 배열 최대값 구하는 성능 측정"></p>
<p>10번 이상의 테스트를 직접 진행해 보았고, 보수적으로 </p>
<ul>
<li>for-loop : 800,000 ns (0.0008s, 0.8ms)</li>
<li>Stream : 6,000,000 ns (0.006s, 6ms)</li>
</ul>
<p>가 평균치로 측정되었다. 50 만건의 원소를 기준, 대략적으로 Stream 이 7~8 배 느린 것으로 판단된다.</p>
<p><strong>primitive type 이 아닌 wrapped type 으로 진행해보자.</strong></p>
<pre><code class="language-java">ArrayList&lt;Integer&gt; ints = new ArrayList&lt;&gt;(500000);
        Random rand = new Random();

        // ArrayList로 50만개의 무작위 정수 초기화
        for (int i = 0; i &lt; 500000; i++) {
            ints.add(rand.nextInt());
        }
        // for-loop
        int m = Integer.MIN_VALUE;
        long forLoopStartTime = System.nanoTime();
        for (int i = 0; i &lt; ints.size(); i++) {
            if (ints.get(i) &gt; m) {
                m = ints.get(i);
            }
        }
        long forLoopEndTime = System.nanoTime();

        System.out.println(&quot;Maximum value found: &quot; + m);
        System.out.println(&quot;Execution time (for-loop): &quot; + (forLoopEndTime - forLoopStartTime) + &quot; nanoseconds&quot;);

        // sequential stream
        long streamStartTime = System.nanoTime();
        int max = ints.stream().reduce(Integer.MIN_VALUE, Math::max);
        long streamEndTime = System.nanoTime();
        System.out.println(&quot;Maximum value found: &quot; + max);
        System.out.println(&quot;Execution time (Stream): &quot; + (streamEndTime - streamStartTime) + &quot; nanoseconds&quot;);</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/877ef8ea-57d9-4bf6-8cde-4a43c0711819/image.png" alt=""></p>
<p>보수적으로 </p>
<ul>
<li>for-loop : 3,000,000 ns (0.003s, 3ms)</li>
<li>Stream : 9,500,000 ns (0.0095s, 9.5ms)</li>
</ul>
<p>가 평균치로 측정되었다. Primitive type 에서 50 만건의 원소를 기준, 대략적으로 Stream 이 이전과는 달리 3배 정도만 더 소요되는 것으로 확인된다.</p>
<br>

<p>기본적으로 for-loop 문을 순회하는 것이 Stream 보단 성능이 우월하다.
특히 Stack 메모리에 직접 접근이 가능한 Primitive Type 인 경우에는 더 뛰어나다. </p>
<p>Heap 메모리에 간접적으로 접근하는 Wrapper 타입도 살펴보자.
위에서 언급했던 강의 영상에서는 Wrapper Type 으로 테스트 했을 경우에는 for-loop 문과 Stream 의 성능 차이가 1.3배 밖에 차이나지 않았다고 언급되었지만, 내 로컬 환경에서는 여전히 3배 정도의 차이가 발생했다.</p>
<p>더 많은 데이터의 양에는 어떨까 싶어 5,000 만개의 원소로 변경해 보았다.
50 만개로 테스트했을 경우와 비슷한 비율을 보인다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/327e9565-e38a-4ed1-b071-3ee45bba552a/image.png" alt=""></p>
<p>데이터의 양이 적을 때를 비교하기 위해 50개의 원소로 다시 테스트 해 보았다. 
primitive type 기준 300 배의 소요시간 차이를 보인다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/b123bbca-c0da-4360-94b5-4b4e90af02cb/image.png" alt=""></p>
<p><strong>항상 강의를 신뢰하지는 말고, 직접 테스트해보며 검증하자.
강의 영상에서 테스트하며 보여준 것은 강의에서 사용된 로컬 환경일 뿐이고, 주관적인 견해가 들어가 있을 수 있다.</strong></p>
<br>


<h3 id="✏️-stream-이-느린-결정적인-이유">✏️ Stream 이 느린 결정적인 이유</h3>
<br>

<p>Stack 메모리에 간접적으로 접근하는 방식으로 변경하였는데도 여전히 Stream 이 느리다. 
특히 적은 데이터셋 일수록 더 큰 차이를 보이는데, 
이는 Stream 을 활용하는 것 자체가 오버헤드를 발생시키며, 
계산 과정 자체도 for-loop 문보다 더 느리다는 것을 알 수 있다.</p>
<p>그 원인을 알아보자.</p>
<h4 id="1-for-loop-문은-jvm-이-최적화하기에-더-적합하다">1. for-loop 문은 JVM 이 최적화하기에 더 적합하다.</h4>
<ul>
<li>전통적인 for-loop 방식은 초창기부터 사용되며 충분히 옵티마이징이 된 상태이므로, Java 8 에서부터 도입된 Stream 에 비해 더 효율적으로 작동한다. </li>
<li>참고로 테스트한 Java 버전은 17이다.<h4 id="2-stream-의-오버헤드">2. Stream 의 오버헤드</h4>
</li>
<li>Stream pipeline 을 구성할 때, 각 연산(filtering, mapping, reducing 등)은 각각의 스테이지를 생성하고, 이러한 스테이지는 내부적으로 추가적인 함수 호출과 컨텍스트 전환을 필요로 한다. 즉, 연산 자체에 오버헤드를 발생시킨다.</li>
<li>메모리 사용: 스트림은 내부적으로 여러 중간 상태를 생성할 수 있다. 이는 추가적인 메모리 할당과 GC 부하를 초래할 수 있다. 반면 for-loop는 상대적으로 메모리를 덜 사용하고, 가비지 컬렉션에 덜 영향을 받는다.</li>
</ul>
<p>라는 것이 순차 스트림에서의 내 결론이다.</p>
<br>


<h3 id="✏️-병렬-스트림의-짧은-소개와-결론">✏️ 병렬 스트림의 짧은 소개와 결론</h3>
<br>

<p>앞서 Stream 은 병렬 처리 관리에 더 쉽다는 소개를 했었다.
병렬 처리를 할 수 있다는 것은, 더 많은 자원을 소모하더라도 그 만큼 빠른 처리를 할 수 있다는 것이 일반적인 상식이다.
단순히 이론만 보자면, 순차 스트림에 비해 병렬 쓰레드는 여러 개의 자원을 한 번에 사용할 수 있으므로 n배의 처리시간을 보이지 않을까?
라는 생각이 들 수 있다.</p>
<p>하지만, Java 에서의 Thread 는 그렇게 가볍지가 않을 뿐더러, 
하나의 작업을 여러 개로 분할한 만큼 오버헤드가 많이 발생한다.
세부적으로는 ForkJoin 이라는 task Object 를 만들고, 실행할 job 을 split 하고, 멀티코어의 병렬 실행을 위해 thread pool 스케줄링을 관리하는 등 단순하게 생각할 문제는 아니다.
물론 이는 Reactive Programming 등에서도 제기되는 문제이기도 하다.
그럼에도 불구하고 대량의 복잡한 연산을 수행해야 되는데 관리하기에 용이한 코드를 만들고자 한다면, 좋은 선택이 될 수는 있다.</p>
<br>


<p>글을 작성하다보니 Stream 에 대한 성능이슈로 인해 부정적인 글처럼 보일 수 있다. 그런 의도로 작성한 것은 아니지만.. </p>
<p>Java 에서 Stream API 는 개발 편의성에 더 맞추어져 있다고 판단한다.
최근 들어서는 하나의 서버PC 의 스펙이 Java Application 을 수십, 수백개 올릴만큼 PC 자체의 성능이 좋아져 위 예시와 같은 단순 연산에 대해서는 매우 짧은 시간 내에 처리가 가능하다.
크게 성능을 고려하지 않아도 될 대부분의 상황이라면, 개발하기 편하고 하독성이 좋은 Stream API 를 선택하는 것이 더 현명한 판단일 수 있다.</p>
<p>특히 개발을 배우면서 최근에 드는 생각은, 웹 개발에 있어 CPU Intensive (CPU 자원 활용을 많이하는) 한 작업은 Java Application 이 아닌 DB level 에서 처리해야되는 것이 맞다고 생각이 든다.
덕분에 DB 설계의 중요성을 크게 체감하는 중이다.</p>
<p>고로 데이터셋이 크고 복잡할수록 DB 단에서 처리하도록 하고,
단순 연산들은 약간의 오버헤드가 있더라도 개발의 속도와 유지보수성을 위해 Java Stream 과 같은 것들을 적극적으로 사용해 보는 것은 어떨까?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Ollama 로 private 한 AI 를 구성해보자 (feat. Docker in Linux, Windows)]]></title>
            <link>https://velog.io/@mud_cookie/OLlama-%EB%A1%9C-private-%ED%95%9C-AI-%EB%A5%BC-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B4%EC%9E%90-wnhxihpq</link>
            <guid>https://velog.io/@mud_cookie/OLlama-%EB%A1%9C-private-%ED%95%9C-AI-%EB%A5%BC-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B4%EC%9E%90-wnhxihpq</guid>
            <pubDate>Sat, 13 Apr 2024 11:00:43 GMT</pubDate>
            <description><![CDATA[<h2 id="🔗-localai-vs-ollama">🔗 LocalAI vs Ollama</h2>
<br>
보안 이슈 등으로 private 한 AI 를 사용하기 위해 local 에 LLM 을 설치해서 사용하는 경우가 있다. 
이는 기업에서 유료로 제공하는 모델과는 달리 가격을 책정할 수 없으므로,
오픈소스로 공개된 LLM 만을 사용할 수 있다.

<p>위 오픈소스 LLM 을 로컬에서 사용하기 쉽게 제공된 프레임워크에는 LocalAI, Ollama 등이 있다. 
왜 private 한 AI 를 사용해야 되는지와 LocalAI 에 대해서는 내가 작성한 아래 링크를 참고하자. 
<a href="https://velog.io/@mud_cookie/Docker-LocalAI-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0">LocalAI 로 private 한 AI 를 구성해보자.</a></p>
<p>그러면 LocalAI 를 사용하면 되지 왜 Ollama 를 택했냐? 함은
Ollama 를 사용하기 전에 LocalAI 를 구동해 설치해 보았는데, 기본 제공되는 LLM 모델의 답변 퀄리티가 매우 떨어졌기 때문이다. (특히 한국어)
지원하는 LLM 모델도, 사용법에 대한 레퍼런스도 Ollama 가 훨씬 많다.
<a href="https://ollama.com/library?q=llama">Ollama 지원 LLM</a>
<del>진작에 Ollama 로 구성할 것 그랬나보다..</del></p>
<p>나의 경우엔 사내 외부망이 차단된 개발망에서는 public 한 AI 를 사용할 수 없으므로, AI 를 활용한 개발이 무척 제한적이었다.
궁극적으로는 Intellij IDE 에서 사용가능한 AI 프로그래밍 도우미 플러그인을 만들고, Ollama 와 붙이는 작업을 진행해보고자 한다.</p>
<br>

<hr>
<br>

<h2 id="🔗-ollama-quick-start-docker-in-linux">🔗 Ollama Quick Start (Docker in Linux)</h2>
<p>시작하기에 앞서, 알아두면 좋을 내용이 있다.</p>
<h3 id="📜-llm-slm-2b-7b">📜 LLM? SLM? 2B? 7B?</h3>
<p>간략하게만 소개하자면, LLM(Large Language Model) 은 대량의 데이터를 학습한 모델이다. 그만큼 답변의 퀄리티가 높고, 무겁다.
무겁다는 뜻은 컴퓨팅 리소스를 많이 잡아먹고, 답변이 느리다는 것을 의미한다.
모델은 학습하는 방식에 따라서 이름이 다르고, 저작권이 있다. 다만 요즘에는 오픈소스로도 많이 공유되는 추세이다.
<a href="https://huggingface.co/">HuggingFace - LLM, DataSet 등 오픈소스 모델 공유 사이트</a></p>
<p>SLM(Small Language Model) 은 LLM 에 비해 학습한 양이 적은 대신, 그만큼 가벼운 모델을 뜻한다.
학습한 데이터(매개변수)의 양에 따라서 SLM, LLM 으로 구분하는데,
그 매개변수의 수가 딱 정해진 것은 아니고 통상적으로 300억 (30B) 이하의 모델들을 SLM 이라고 부른다. (1B = 10억)</p>
<p>참고로, OpenAI 에서 개발한 GPT-4 는 수천억개의 매개변수로 학습했다.
2024.04 현 시점 GPT-5 개발중에 더 이상 학습할만한 데이터셋이 부족하다는 기사가 나왔을 정도니 이제는 누가 더 많은 매개변수로 학습했냐가 문제가 아닌,
누가 더 적은 매개변수로 더 나은 퀄리티를 보여주냐의 경쟁으로 번지지 않을까 싶다.</p>
<br>

<h3 id="✏️-docker-로-ollama-설치">✏️ Docker 로 Ollama 설치</h3>
<p>서론이 길었다. Ollama 설치부터 진행해보자.</p>
<p><a href="https://hub.docker.com/r/ollama/ollama">Ollama Dockerhub</a>
위 링크에서 Docker image 를 다운받는 예시가 들어있다.
GPU 를 사용할 사람은 GPU 버전을 사용하면 되지만, 나의 경우엔 사내 CPU 만 있는 서버에서 굴릴 예정이므로 아래 명령어를 입력한다.</p>
<pre><code class="language-bash">$ docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama:latest</code></pre>
<pre><code class="language-bash"># 기동 확인
$ curl localhost:11434
# 아래와 같은 문구가 출력된다.
Ollama is running</code></pre>
<p>이제 각자 사용하고 싶은 모델을 선택해 다운받는다.
나의 경우엔 최근 구글에서 공개하여 프로그래밍에 특화된 codegemma 2B 모델을 사용하고자 한다.
<a href="https://ollama.com/library?q=llama">Ollama 지원 LLM</a>
<a href="https://discuss.pytorch.kr/t/google-code-llm-codegemma-2b-7b/4044">CodeGemma 2B/7B 모델 공개</a></p>
<pre><code class="language-bash">$ docker exec -it ollama ollama run codegemma:2b</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/2b479db3-586d-4244-a2b0-0d237996299c/image.png" alt="명령어 실행 후 결과"></p>
<p>Send a message 는 말 그대로 터미널에서 질문하라는 뜻이다.
java 라는 질문을 테스트 해보았다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/726b1837-6424-4d8a-afba-b951a1e07a77/image.png" alt="java 라는 질문"></p>
<p>prefix, suffix, separator 등 쓸데없는 것들이 붙어있지만 응답 자체는 문제가 없는 것으로 확인된다.</p>
<p>모델 구동을 중단하는 것은 Ctrl + D 로 가능하다.</p>
<br>



<h3 id="✏️-터미널은-불편하다-웹에서-사용해보자">✏️ 터미널은 불편하다. 웹에서 사용해보자.</h3>
<br>

<p>이 역시 Ollama 에서 쉽게 웹뷰로 볼 수 있도록 지원한다. 
ChatGPT 와 비슷한 UI 라 익숙하게 사용이 가능할 것이다.</p>
<ul>
<li>2024년 2월까지만 하더라도 Ollama WebUI 였는데, Open WebUI 로 이름이 변경되었다고 한다. 참고하자.</li>
</ul>
<p><a href="https://github.com/open-webui/open-webui">Open WebUI git repository</a>
아래 명령어에서 각자 여분의 포트로 진행한다.
[호스트의 포트]:[컨테이너의 포트]</p>
<pre><code>docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main</code></pre><br>


<p><strong><a href="http://localhost:3000">http://localhost:3000</a> 으로 접속해보자.</strong></p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/a59325bd-b380-4f4f-9122-c4b69c8e469f/image.png" alt="localhost:3000 접속화면"></p>
<p>어차피 local 에서 구동되므로 개인정보 걱정없이 Sign up (회원가입) 을 진행하자.</p>
<p>회원가입을 하면 아래와 같은 UI가 나온다.
아주 익숙한 ChatGPT 와 유사한 UI 다. 사용법도 비슷하다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/635145f2-d1b4-49fb-8b03-c5e57d4cdbff/image.png" alt="Ollama WebUI"></p>
<p>상단의 <code>Select a model</code> 에서 사용할 LLM 모델을 선택 가능하다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/25c8d42b-03b4-4fe1-88ab-75937f272714/image.png" alt="LLM 모델 선택"></p>
<p>만약 원하는 모델이 없다면, 좌측 하단의 프로필 클릭 -&gt; <code>Settings</code> 에서 모델을 직접 다운받을 수 있다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/86380db9-54bf-4cc0-9add-cad333fc8dfe/image.png" alt="LLM 모델 다운받기 - Web View 에서"></p>
<p>외부망이 차단된 서버라면, 외부망 접속이 가능한 PC 에서 아까 위에서 언급했던 docker 로 모델을 다운받았던 것과 같이 진행하면 된다. 실행과 동시에 LLM 모델이 다운받아진다.</p>
<pre><code class="language-bash">$ docker exec -it ollama ollama run LLM모델명</code></pre>
<p>이후 아래 내가 작성한 포스트의 최하단에서 image 를 tar 파일로 변환, 해당 파일을 SFTP 로 이관하는 방법에 대한 글을 참고하자.
<a href="https://velog.io/@mud_cookie/Docker-LocalAI-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0">LocalAI 로 private한 AI 를 구성해보자</a></p>
<p>이제 질문을 시작해보자. Springboot 백엔드 지식의 아주 기초적인 질문을 해보았다. 
여기서 사용한 LLM 모델은 codegemma 2b 버전으로, 가벼운 모델에 속하니 중간중간 대화에 prefix, suffix 등의 사소한 텍스트 오류는 무시하길 바란다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/96cd8a46-150b-4fb0-9d19-6481fc4cbbea/image.png" alt="실제 응답 예시"></p>
<p>응답이 잘 나온 것을 볼 수 있다. 캡처 이미지에는 표기가 되지 않았지만 마지막 설명까지 잘 마무리가 되었다.</p>
<h3 id="❗-cpugpu-사용량-확인-필수">❗ CPU(GPU) 사용량 확인 필수</h3>
<p>실행한 환경은 아래와 같다.</p>
<ul>
<li>CPU : AMD 5950X 16-Core
<del>- GPU : RTX 3080 -&gt; GPU 는 사용하지 않음.</del></li>
<li>RAM : DDR4 16 * 2 GB</li>
<li>LLM : codegemma 2B</li>
</ul>
<p>아래를 보면 어마어마한 CPU 사용량을 차지하고 있는 것을 알 수 있다.
개인 PC 치고 괜찮은 CPU 코어 수와 성능을 가지고 있음에도 불구하고 평소 10% 미만의 점유율을 차지하던 것이 70% 이상으로 급격하게 뛴다.</p>
<p><a href="https://velog.io/@mud_cookie/Docker-LocalAI-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0">LocalAI 로 private한 AI 를 구성해보자</a>
위에서 LocalAI 로 돌려봤을 때와는 다르게 CPU 점유율이 2~3 배 이상 많은데, 이는 다른 LLM 모델을 사용하긴 했지만 워낙 그 수치가 차이가 많이 난다. 
혹시 몰라서 codegemma 2B 보다 학습량이 더 많은 codegemma 7B 를 사용했을 때, 답변 퀄리티는 증가하고 응답속도는 느려졌지만 CPU 점유율은 동일했다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/95ad50d1-32c2-4552-99ad-c2fee80d219e/image.png" alt="CPU 사용량.."></p>
<p>이는 LLM 모델에 따른 차이라기 보다는 Ollama 라는 framework 자체가 리소스를 많이 잡아먹도록 설계된 것이 아닐까 예상한다.
물론 GPU 를 사용하는 서버라면 점유율이 낮아지는 건 당연하다.
GPU 를 사용할 수 있는 환경이라면 애초에 Ollama Docker 를 설치할 때부터 GPU 버전으로 사용하길 권장한다.
<a href="https://hub.docker.com/r/ollama/ollama">Ollama Dockerhub</a></p>
<br>

<h3 id="✏️-streaming-api-로도-사용이-가능하다">✏️ Streaming API 로도 사용이 가능하다</h3>
<p><a href="https://editor.swagger.io/?url=https://raw.githubusercontent.com/marscod/ollama/main/api/ollama_api_specification.json">Ollama 에서 제공하는 API - swagger</a>
위 링크에서 Ollama 에서 제공하는 API 에 대해 확인해보자.
개발자에게 익숙한 Swagger UI 로 보여주니 이런 부분에서는 신경을 많이 쓴 것을 볼 수 있다.
물론 Ollama framework 내부에서 Swagger UI 를 구현하지 않은 것은 아쉬운 부분이다.</p>
<p>터미널에서 아래와 같이 curl 로도 응답을 확인할 수 있다.</p>
<pre><code class="language-bash">$ curl http://localhost:11434/api/generate -d &#39;{
   &quot;model&quot;: &quot;codegemma:2b&quot;,
   &quot;prompt&quot;: &quot;Hi, who are you?&quot;
}&#39;</code></pre>
<p>아래와 같이 나오는데, 웹뷰에서 보았다시피 단어 하나씩 출력되는 걸 볼 수 있다. 
Codegemma 2B 가 아닌 Codegemma 7B 모델로 한 결과이다. 아직까진 2B 모델은 보완할 부분이 많아보인다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/84781866-7ffa-4aeb-a4e1-b18e024e3929/image.png" alt="curl 명령어 결과"></p>
<br>

<hr>
<hr>
<br>


<h2 id="🔗-ollama-quick-start-windows">🔗 Ollama Quick Start (Windows)</h2>
<p>위에서 작성한 설치방법은 Linux OS 환경에서 Docker 를 활용한 설치방법이었고, 이번에는 Windows OS 에서 설치해보고자 한다.
위 과정을 이미 밟았던 사람이라면, PC 에서 구동중인 docker container 를 중지시키고 진행하자.</p>
<p><a href="https://ollama.com/download/windows">Ollama Windows 다운로드</a>
위 링크에서 Windows 버전 다운로드를 진행 후 설치한다.
접속하면 아래와 같은 화면이 출력되는데, Windows 10 이상의 버전만 지원함에 유의하자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/501c0693-98d2-48de-b6bb-ff45e5455a25/image.png" alt="Ollama 공식 사이트 - Windows 다운로드"></p>
<p>이후 다운받은 OllamaSetup.exe 를 실행하기만 하면 바로 설치가 완료된다. 
setup 과정에서는 오프라인도 가능하니 참고하기 바란다.</p>
<br>

<p>이후 cmd 창을 열어 아래와 같이 입력해보자. 관리자 권한으로 접속하지 않아도 된다.</p>
<pre><code class="language-cmd">nvidia-smi</code></pre>
<p>명령어 입력 시 현재 보유한 Nvidia 그래픽카드의 정보가 출력된다.
아래에서 중요하게 봐야 될 부분은, CUDA Version 이다.
작성 기준 Windows preview 버전이라, 11버전 또는 12버전만 지원하는 것으로 알고있으니 참고하길 바란다.
만약 Nvidia 그래픽카드 &amp; Nvidia 그래픽카드 드라이버(11, 12 버전)가 없으면 CPU 만으로 동작할 것으로 예상된다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/01e6328b-08a7-4722-9a77-4cdbaf71f56c/image.png" alt="CUDA 버전 확인"></p>
<p>이제 설치가 잘 됐음을 확인해보기 위해 아래 명령어를 입력해보자.</p>
<pre><code class="language-cmd">ollama</code></pre>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/bd90dc7f-f550-465b-a26a-93d19b498bf3/image.png" alt="Ollama 설치 확인"></p>
<p>이전의 Docker 로 구동된 것과는 다르게, ollama 라는 명령어로 다양한 동작들을 수행할 수 있음을 알 수 있다.</p>
<p>이 명령어들을 활용해 cmd 창에서 다양한 작업을 해보자.
우선 현재 사용가능한 모델들을 나열해주는 명령어를 작성하자.</p>
<pre><code class="language-cmd">ollama list</code></pre>
<p>아직은 사용 가능한 모델이 없다고 출력된다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/c681a707-7359-4393-90a0-aff778dda4ed/image.png" alt="ollama list 결과"></p>
<br>

<p>그러면 이제 모델을 다운받아보자. pull 명령어 뒤에 사용하고 싶은 LLM 모델명을 입력한다.</p>
<pre><code>ollama pull LLM모델명</code></pre><p><img src="https://velog.velcdn.com/images/mud_cookie/post/da6719e7-b05e-4d30-82e3-14f430bfae28/image.png" alt="pull 명령어 동작 확인"></p>
<p>잘 다운로드가 받아졌으니 다시 한 번 list 명령어로 사용이 가능한지 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/73a33410-0d98-4833-9a03-5ca36a05b340/image.png" alt="모델 다운 확인"></p>
<br>

<p>이제 cmd 에서 모델을 구동시켜보자.</p>
<pre><code>ollama run LLM 모델명</code></pre><p>아래는 run 으로 구동 후 &quot;how are you?&quot; 라는 질문을 해본 예시이다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/868064a2-6dd8-495b-ac8d-94ba946ac6cd/image.png" alt="ollama run 결과"></p>
<p>혹시라도 오프라인 PC 환경에서 진행하고자 한다면, model 을 직접 pull 받을 수 없으므로, 다운로드 받은 model 이 어디에 위치하는지 공유한다. 이 model 을 SFTP 로 옮기면 된다.</p>
<p><strong>registry 위치</strong></p>
<pre><code>C:\Users\PC계정명\.ollama\models\manifests\registry.ollama.ai\library\</code></pre><p><strong>model 위치</strong></p>
<pre><code>C:\Users\PC계정명\.ollama\models\blobs</code></pre><p>주의점은 registry 에는 sha256 암호화된 파일명에 대한 정보를 그대로 담고 있으므로, 파일 이동 과정 중 파일명이 변해서는 안된다.</p>
<p>모델을 여러 개 받아서 sha256 암호화된 파일명이 어떤 모델인지 알 수 없다면, <code>ollama pull</code> 명령어 입력 시에 pulling 되어 암호화된 모델명이 출력되므로, 그 값을 참고하자.
codegemma:2b 모델 기준으로는 4개의 model 파일이 생성, 하나의 registry 파일이 생성되었다.</p>
<p>ollama 시작 &amp; 종료 방법</p>
<ul>
<li>시작 : cmd 에서 <code>ollama serve</code> 입력, localhost:11434 에서 기동 확인</li>
<li>종료 : windows 우측 하단 아이콘 목록에서 ollama 아이콘 우클릭 -&gt; Quit</li>
</ul>
<hr>
<br>

<h3 id="❗-webui-는-현재-docker-에서만-지원한다">❗ WebUI 는 현재 Docker 에서만 지원한다.</h3>
<p>2024년 4월 기준 Ollama WebUI 를 Windows OS 자체에서 구동시키려고 했으나, Windows 에서는 지원하지 않는다고 한다.</p>
<p>Windows 에서 Docker 를 띄우려면 WSL 이 필요하고, WSL 를 구동시킨다는 것 자체가 메모리를 많이 잡아먹어 별로 하고싶지 않은 선택이었으나.. 어쩔 수 없이 Docker 로 구동시키는 방법밖에 없다. </p>
<br>

<hr>
<br>

<h3 id="❗-cpugpu-사용량-확인-필수-1">❗ CPU(GPU) 사용량 확인 필수</h3>
<p>위 Docker in Linux 에서는 CPU 만으로 구동시켰으나, 이번에는 
GPU 를 사용하는 환경이었다.
GPU 를 사용하면 리소스를 얼마나 잡아먹을지 살펴보자.</p>
<p>구동 환경은 이렇다. 메모리는 애초에 WSL 을 구동하느라 Default 로 많이 잡아먹고 있는 상황임에 참고하자. WSL 만 16GB 를 기본으로 잡아먹고 있다.</p>
<ul>
<li>CPU 버전<ul>
<li>CPU : Ryzen 5950X - Docker 에 10 Core 할당</li>
<li>OS : Linux (WSL, Docker)</li>
</ul>
</li>
<li>GPU 버전 - Windows<ul>
<li>GPU : Nvidia Geforce 3080 10GB VRAM</li>
<li>OS : Windows </li>
</ul>
</li>
<li>공통사항<ul>
<li>Memory : 16 * 2 GB</li>
<li>LLM : codegemma (7B)</li>
<li>질문 : how to generate Entity in springboot data jpa?</li>
</ul>
</li>
</ul>
 <br>


<h3 id="✏️-단일-실행-성능-확인">✏️ 단일 실행 성능 확인</h3>
<p>** CPU 버전 **
아래 CPU 점유율은 위 Docker 로 설치할 때 보여주었다 시피 CPU 의 80% 가량의 점유율을 보인다. (평소 10% 미만)</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/d3d3293a-5962-46f8-899a-233652737263/image.png" alt="OS CPU 메모리 사용량"></p>
<p>다만 이번에 Docker 의 CPU 점유율을 보았더니..
내가 할당한 10Core 를 넘어서는 과부하가 걸린 것을 보았다. 이대로는 위험하다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/9d5ddf9b-4014-4962-a3bf-4a519b4d5cb1/image.png" alt="Docker CPU 사용량"></p>
<p>** GPU 버전 **
GPU VRAM (메모리) 사용량은 ollama 구동시작부터 대화가 끝날때까지 일정했다. 기동하는 것 자체만으로 VRAM 을 잡아먹는 것으로 보인다.
다만 3D 사용량은 순식간에 97% 점유율을 보인다. 사내 서버는 24시간 돌아가므로 이 정도 스펙으로 구동된다고 하면 주의해야 될 것으로 보인다.
<img src="https://velog.velcdn.com/images/mud_cookie/post/3994fa74-e4b0-45e9-ac72-31d750255432/image.png" alt="GPU 버전 단일실행 결과"></p>
<h3 id="✏️-병렬2개-실행-성능-확인">✏️ 병렬(2개) 실행 성능 확인</h3>
<p>** CPU 버전 **
예상했던 결과와는 달리, 동시에 질문한다고 하더라도 하나의 질문이 끝나고 난 후에 다른 질문이 실행된다.
안그래도 GPU 버전보다 응답시간이 체감상 5배 이상 느린데, 다음 질문을 하는 사람은 더 많이 기다려야 된다.
CPU 버전으로는 사내 서버에 구동시킬 수는 없을 것으로 판단된다.
성능도 그렇고 사용성이 너무 떨어진다.</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/915f84f6-6a90-43e9-b543-c14f887920dc/image.png" alt="CPU 버전 Docker CPU 사용량"></p>
<p>** GPU 버전 **
이 역시 CPU 버전과 동일하게, 동시에 질문한다고 하더라도 하나의 질문이 끝나고 난 후에 다른 질문이 실행된다.
그러므로, 처리시간만 두 배로 늘어난 꼴이 된다. 
질문에 따라 독립적인 쓰레드가 동기적으로 실행되는 것이 아닐까 예상하는데, 어찌보면 하나의 서버를 유지하는 데 있어서 질문을 동시에 처리한다고 하는 것은 더 큰 과부하를 줄 수 있으므로 이 방식은 비교적 안전하다고 볼 수 있다.</p>
<p>그렇다고 해서 서버가 안정적으로 실행된다는 것은 아니고, 질문이 계속 들어오면 그만큼 GPU 리소스를 계속 풀로드에 가깝게 돌려야 된다는 의미이므로 주의할 필요가 있다.</p>
<p>비트코인 채굴을 생각해보자. 
몇 개월동안 24시간 내내 GPU를 풀로드로 무식하게 돌려버리니 더 이상 못쓸 지경까지 된 그래픽카드를 중고시장에 풀어버리는 참사가 몇년째 계속되고 있지 않은가..</p>
<p><img src="https://velog.velcdn.com/images/mud_cookie/post/69a347f9-cae0-4dde-a29b-98faa1aba517/image.png" alt="GPU 버전 2개 동시실행 결과"></p>
<br>

<hr>
<hr>
<br>


<h2 id="🔗-마치며">🔗 마치며</h2>
<p>LLM 을 로컬에서 쉽게 구동하기 위한 framework 인 ollama 를 다양한 환경에서 설치 및 구동해보았다.
설치 자체는 개발자라 어려운 부분은 없었지만, 한국어로 된 레퍼런스는 많지 않아 불편함을 다소 느꼈다. 국내에서도 하루빨리 AI 에 대한 관심도가 높아졌으면 하는 바램이다.</p>
<p>추가적으로 생각보다 경량인 모델들도 리소스를 많이 잡아먹는 것을 알게되었다. 
최근 온디바이스 AI 라고 하면서 가벼운 모델들이 활성화되고 있는 가장 큰 이유가 바로 컴퓨팅 리소스떄문이 아닐까 싶다.</p>
<p>CPU 로 구동시키는 버전은 응답시간과 과부하때문에 도저히 활용할만한 수준이 못되고, 
GPU 로 구동시키는 버전은 준수한 응답속도를 가졌으나 풀로드에 가까운 리소스를 점유하므로 24시간 구동되는 사내 서버에서는 사용하기에 다소 무리가 있다.
내 그래픽카드가 개인PC 치고 괜찮은 수준인데도 이정도면 GPU 자원을 외부에서 끌어다 쓰는 이유가 있었구나 싶다..</p>
<p>결론적으로는 사내 테스트 서버에 ollama를 구동시켜, private 한 AI 프로그래밍 도우미 플러그인을 만들고자 했던 계획은 현실과 동떨어져 가고 있다.
사내 테스트서버는 Intel 제온 CPU 로 구동되는 매우 준수한 스펙을 가지고 있긴 하나, GPU 따위는 내장되어 있지 않다.
GPU 구매 요청을 날리기에는 회사 분위기가 비용 절감에 초점을 두고 있고, 그렇다고 해서 ollama 자체를 각 개발자의 개인 PC 에서 돌리기에는 GPU 가 없으니 결국 CPU 를 써야 하는데 개발 도중 CPU 에 과부하가 걸린다는 것은 크리티컬하므로 이 방법 역시 말이 되지 않는다.</p>
<p>회사 개발망은 보안때문에 이것저것 다 막아놨으니 클라우드 자원을 활용할 수도 없고.. 막막하다. 
정말 마지막으로 사내에 남은 GPU 장비가 있다고 하면, AI 프로그래밍 도우미 플러그인을 만들어 제작 과정 및 결과를 포스팅하고자 한다. </p>
<p>소식이 없으면 무산된 걸로..</p>
<br>]]></description>
        </item>
    </channel>
</rss>