<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>immersive 님의 블로그</title>
    <link>https://immersive.tistory.com/</link>
    <description>immersive 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sun, 7 Jun 2026 23:44:12 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>immersive</managingEditor>
    <item>
      <title>[Security] Wireguard VPN으로 내 EC2 지키기</title>
      <link>https://immersive.tistory.com/13</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;152&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cD5QJZ/dJMcafZco7N/FdXMZg9gTqDWurtcuFLuD0/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cD5QJZ/dJMcafZco7N/FdXMZg9gTqDWurtcuFLuD0/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cD5QJZ/dJMcafZco7N/FdXMZg9gTqDWurtcuFLuD0/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcD5QJZ%2FdJMcafZco7N%2FFdXMZg9gTqDWurtcuFLuD0%2Ftfile.svg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;860&quot; height=&quot;152&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;152&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;❓&lt;/span&gt;VPN을 도입하는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 클라우드 아키텍처에서 주요 데이터를 처리하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;RDS&lt;/b&gt;와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;API 서버 컨테이너가 기동 중인 EC2 인스턴스&lt;/b&gt;는 모두&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Private Subnet&lt;/b&gt;에 위치해 있어, 외부로부터의 직접적인 접근이 원천 차단되어 있습니다.&lt;br /&gt;하지만 내부 트래픽을 외부로 라우팅하기 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;groble-monitor-instance&lt;/b&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;NAT 인스턴스&lt;/b&gt;로 사용하고 있으며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;비용 절감을 위해 해당 인스턴스가 Bastion Host의 역할까지 겸하고 있습니다.&lt;/b&gt;&lt;br /&gt;즉, 외부 개발자가 Private Subnet 내 EC2 인스턴스에 접속하기 위해서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;groble-monitor-instance의 22번 포트를 통해 SSH 접속&lt;/b&gt;을 해야 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;444&quot; data-end=&quot;678&quot;&gt;이러한 구조는 주요 인스턴스를 Private Subnet에 배치해 외부 공격으로부터 보호하고 있음에도 불구하고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Public Subnet에 위치한 Bastion Host가 22번 포트로 외부에 노출되어 있다는 점에서 보안상 잠재적인 취약점을 내포하고 있습니다.&lt;/b&gt;&lt;br /&gt;이에 따라 저는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;groble-monitor-instance를 보다 안전하게 보호하기 위한 추가적인 보안 정책의 필요성&lt;/b&gt;을 인식하게 되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2424&quot; data-origin-height=&quot;1496&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Gy3b4/dJMcai2GUvY/dFFGIvfwy3u1rIgCkXmz1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Gy3b4/dJMcai2GUvY/dFFGIvfwy3u1rIgCkXmz1K/img.png&quot; data-alt=&quot;클라우드 아키텍처&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Gy3b4/dJMcai2GUvY/dFFGIvfwy3u1rIgCkXmz1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGy3b4%2FdJMcai2GUvY%2FdFFGIvfwy3u1rIgCkXmz1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2424&quot; height=&quot;1496&quot; data-origin-width=&quot;2424&quot; data-origin-height=&quot;1496&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;클라우드 아키텍처&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Wireguard VPN을 선택한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 고려한 선택지는 두 가지였습니다. 하나는 접속 가능한 IP 대역을 고정된 작업 공간으로 좁히는 방법이고, 다른 하나는 VPN 같은 인증 기반 솔루션을 도입하는 방법이었죠. 그런데 개발자들이 장소를 자주 옮기는 상황이라 첫 번째 방법은 현실적이지 않다고 판단했습니다. 따라서 groble-monitoring-instance에 VPN을 깔아서 &lt;b&gt;인증된 개발자만 접근&lt;/b&gt;하도록 만드는 쪽으로 방향을 잡았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 오래되고 널리 사용되는 &lt;b&gt;OpenVPN&lt;/b&gt;, 엔터프라이즈 환경에서 자주 쓰이는 &lt;b&gt;IPsec/IKEv2&lt;/b&gt;, 그리고 개인 프라이버시 보호용으로 활용되는 &lt;b&gt;상용 VPN 서비스&lt;/b&gt;과 같이 여러 가지 VPN 솔루션 선택지가 있었습니다. 하지만 저는 최신 오픈소스 VPN 프로토콜인 &lt;b&gt;WireGuard&lt;/b&gt;를 선택했습니다. VPN을 적용하면 일반적인 SSH 직접 접속보다는 다소 느려질 수밖에 없습니다. 그러나 개발자들이 원활하게 응답을 주고받는 것이 중요했기 때문에, &lt;b&gt;성능과 보안을 모두 만족시키는 경량 VPN 솔루션&lt;/b&gt;이 필요했습니다. 이 점에서 WireGuard는 가장 적합했습니다.&lt;/p&gt;
&lt;p data-end=&quot;484&quot; data-start=&quot;330&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;694&quot; data-start=&quot;486&quot; data-ke-size=&quot;size16&quot;&gt;WireGuard는 다른 VPN에 비해 &lt;b&gt;코드와 설정 구조가 매우 간단&lt;/b&gt;하여 오버헤드가 적고, &lt;b&gt;UDP 기반 통신으로 낮은 레이턴시&lt;/b&gt;를 제공합니다.&lt;br /&gt;또한 &lt;b&gt;Curve25519(키 교환), ChaCha20(대칭 암호화), Poly1305(인증)&lt;/b&gt; 등 &lt;b&gt;검증된 최신 암호 알고리즘만을 사용&lt;/b&gt;하기 때문에, 보안 측면에서도 높은 신뢰성을 확보할 수 있습니다. 결과적으로, 저는 &lt;b&gt;가볍고 빠르며 안전한 WireGuard VPN&lt;/b&gt;을 선택하게 되었습니다.&lt;/p&gt;
&lt;p data-end=&quot;694&quot; data-start=&quot;486&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚙️ Wiregurad VPN의 동작원리&lt;/h3&gt;
&lt;p data-end=&quot;503&quot; data-start=&quot;406&quot; data-ke-size=&quot;size16&quot;&gt;VPN은 &lt;b&gt;클라이언트와 서버 간에 암호화된 터널을 생성&lt;/b&gt;하여, 네트워크 트래픽을 안전하게 전송할 수 있도록 합니다. 이 터널을 통해 주고받는 모든 데이터는 &lt;b&gt;암호화된 형태로 이동&lt;/b&gt;하기 때문에, 중간에서 트래픽이 가로채이더라도 내용을 해독할 수 없습니다. WireGuard 역시 이러한 암호화된 터널링 방식을 기반으로 동작합니다.&lt;/p&gt;
&lt;p data-end=&quot;503&quot; data-start=&quot;406&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;클라이언트(개발자 PC)와 서버(EC2) 사이의 통신 흐름을 단계별로 살펴보면 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;607&quot; data-start=&quot;529&quot;&gt;&lt;b&gt;개발자 PC에서 SSH 연결 시도&lt;/b&gt;&lt;br /&gt;SSH 패킷이 생성되고, WireGuard 클라이언트가 해당 패킷을 암호화합니다.&lt;/li&gt;
&lt;li data-end=&quot;696&quot; data-start=&quot;608&quot;&gt;&lt;b&gt;암호화된 패킷 전송&lt;/b&gt;&lt;br /&gt;암호화된 SSH 패킷이 인터넷을 통해 서버의 WireGuard 포트(기본값: 51820/UDP)로 전송됩니다.&lt;/li&gt;
&lt;li data-end=&quot;802&quot; data-start=&quot;697&quot;&gt;&lt;b&gt;서버 수신 및 복호화&lt;/b&gt;&lt;br /&gt;EC2 인스턴스의 eth0 인터페이스가 암호화된 패킷을 수신하고, &lt;b&gt;WireGuard 커널 모듈&lt;/b&gt;이 이를 가로채 복호화합니다.&lt;/li&gt;
&lt;li data-end=&quot;931&quot; data-start=&quot;803&quot;&gt;&lt;b&gt;인증 및 복호화 처리&lt;/b&gt;&lt;br /&gt;WireGuard는 해당 패킷이 &lt;b&gt;인증된 클라이언트로부터 왔는지 검증&lt;/b&gt;한 뒤, &lt;b&gt;ChaCha20&lt;/b&gt; 알고리즘으로 복호화하고 &lt;b&gt;Poly1305&lt;/b&gt;로 무결성을 확인합니다.&lt;/li&gt;
&lt;li data-end=&quot;1022&quot; data-start=&quot;932&quot;&gt;&lt;b&gt;내부 트래픽 전달&lt;/b&gt;&lt;br /&gt;복호화된 SSH 패킷은 wg0 인터페이스를 통해 &lt;b&gt;로컬 네트워크(예: localhost:22)&lt;/b&gt; 로 전달됩니다.&lt;/li&gt;
&lt;li data-end=&quot;1083&quot; data-start=&quot;1023&quot;&gt;&lt;b&gt;SSH 데몬 처리&lt;/b&gt;&lt;br /&gt;결국 SSH 데몬이 정상적인 요청으로 인식하고 연결을 허용합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 개발자의 SSH 요청은 VPN 터널을 통해 &lt;b&gt;암호화된 상태로 전송 &amp;rarr; 커널 단에서 복호화 &amp;rarr; 내부 네트워크로 전달&lt;/b&gt;되는 구조입니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt; &lt;/span&gt;Wiregurad VPN의 동작 계층&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WireGuard는 &lt;b&gt;OSI 7 계층 중 네트워크 계층(Layer 3)에서&lt;/b&gt; 작동합니다. 그렇기 때문에 &lt;b&gt;애플리케이션 종류와 관계없이 모든 트래픽을 보호할 수 있다는 장점&lt;/b&gt;이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1346&quot; data-start=&quot;1309&quot; data-ke-size=&quot;size16&quot;&gt;실제 패킷 처리 과정을 OSI 계층별로 단순화하면 다음과 같습니다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1384&quot; data-start=&quot;1348&quot;&gt;&lt;b&gt;L1/L2&lt;/b&gt;: 물리적인 수신 &amp;rarr; 이더넷 프레임 처리&lt;/li&gt;
&lt;li data-end=&quot;1430&quot; data-start=&quot;1385&quot;&gt;&lt;b&gt;L3 초입&lt;/b&gt;: IP 패킷 확인 &amp;rarr; 51820 포트로 온 패킷임을 확인&lt;/li&gt;
&lt;li data-end=&quot;1476&quot; data-start=&quot;1431&quot;&gt;&lt;b&gt;WireGuard 커널 모듈&lt;/b&gt;: 패킷 가로채기 &amp;rarr; 인증/무결성 검증&lt;/li&gt;
&lt;li data-end=&quot;1525&quot; data-start=&quot;1477&quot;&gt;&lt;b&gt;복호화&lt;/b&gt;: ChaCha20으로 복호화 &amp;rarr; 새로운 IP 패킷으로 재조립&lt;/li&gt;
&lt;li data-end=&quot;1567&quot; data-start=&quot;1526&quot;&gt;&lt;b&gt;L3 재진입&lt;/b&gt;: 복호화된 패킷을 일반 네트워크 스택으로 전달&lt;/li&gt;
&lt;li data-end=&quot;1603&quot; data-start=&quot;1568&quot;&gt;&lt;b&gt;L4~L7&lt;/b&gt;: SSH 등 상위 프로토콜 정상 처리&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 과정은 &lt;b&gt;사용자 공간(user space)이 아닌 Linux 커널 공간(kernel space)에서&lt;/b&gt; 수행됩니다. 즉, 암호화와 복호화가 네트워크 스택 내부에서 이루어지므로 &lt;b&gt;속도 손실이 적고 오버헤드가 최소화&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt; ️&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Wireguard VPN 구축 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&amp;lt;Wireguard 설치&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EC2에 SSH 접속하여 Wireguard를 설치합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762569955051&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Ubuntu/Debian
sudo apt install wireguard

# 설치 확인
wg --version&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;서버 키 페어 생성&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Wireguard는 공개키 암호화 방식을 사용합니다. 따라서, 서버가 자신을 식별하고 클라이언트와 안전하게 통신하기 위해서는 고유한 키 페어(개인키 + 공개키)가 필요합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;개인키(Private Key)&lt;/b&gt;: 서버만 보관하며 절대 외부에 공개하지 않음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;공개키(Public Key)&lt;/b&gt;: 클라이언트들이 서버를 인증할 때 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762570117282&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Wireguard 설정 디렉토리로 이동
cd /etc/wireguard

# 서버 개인키 생성
wg genkey | tee server_private.key | wg pubkey &amp;gt; server_public.key

# 개인키 파일 권한 설정 (보안 중요!)
chmod 600 server_private.key

# 생성된 키 확인
cat server_private.key
cat server_public.key&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;chmod 600은 파일 권한을 소유자(owner)에게만 읽기(read)와 쓰기(write)를 허용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;서버 설정 파일 작성&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Wireguard 서버가 어떻게 동작할지 정의하는 설정 파일을 만듭니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VPN 네트워크의 IP 대역 설정&lt;/li&gt;
&lt;li&gt;어떤 포트에서 연결을 받을지 지정&lt;/li&gt;
&lt;li&gt;IP 포워딩 규칙 설정 (VPN을 통해 인터넷 또는 내부 네트워크 접근 허용)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762570367805&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;vim /etc/wireguard/wg0.conf

[Interface]
# EC2의 개인키
PrivateKey = {위 단계에서 생성한 개인키}
Address = 10.6.0.1/24
ListenPort = 51820
SaveConfig = true

# IP 포워딩 규칙 (VPN 트래픽이 EC2를 통해 라우팅되도록)
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;10.6.0.1: Wireguard 서버가 VPN 내부에서 사용할 가상 IP 주소
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;/24: 이 VPN 네트워크의 IP 대역 (10.6.0.0 ~ 10.6.0.255)&lt;/li&gt;
&lt;li&gt;Wireguard가 만드는 가상의 네트워크 공간
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버:&lt;span&gt;&amp;nbsp;&lt;/span&gt;10.6.0.1&lt;/li&gt;
&lt;li&gt;클라이언트 1:10.6.0.2&lt;/li&gt;
&lt;li&gt;클라이언트 2:10.6.0.3&lt;/li&gt;
&lt;li&gt;... 최대 254개 클라이언트까지 할당 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;PostUp / PostDown 이란
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;PostUp&lt;/b&gt;: Wireguard 인터페이스(wg0)가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;시작될 때&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;실행할 명령&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostDown&lt;/b&gt;: Wireguard 인터페이스가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;중지될 때&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;실행할 명령&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;각 iptables 규칙 설명
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;iptables -A FORWARD -i wg0 -j ACCEPT
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;wg0 인터페이스(VPN)에서 들어오는 패킷의 포워딩을 허용&lt;/li&gt;
&lt;li&gt;VPN 클라이언트가 EC2를 통해 다른 네트워크(인터넷 또는 내부 네트워크)로 패킷을 보낼 수 있게 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VPN에서 나가는 트래픽의 출발지 IP를 EC2의 IP로 변환 (NAT)&lt;/li&gt;
&lt;li&gt;VPN 클라이언트(10.6.0.2)가 인터넷에 접속할 때, 패킷의 출발지를 EC2의 공인 IP로 바꿔줌. 그래야 응답 패킷이 다시 EC2로 돌아올 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot; data-token-index=&quot;0&quot;&gt;IP 포워딩 활성화&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Linux 커널 레벨에서 IP 포워딩 기능을 활성화해 줍니다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;EC2가 VPN 클라이언트의 패킷을 다른 네트워크로 전달(라우팅)할 수 있음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;iptables 규칙만으로는 부족하고, 커널 설정도 함께 변경해야 함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762570971470&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 현재 IP 포워딩 상태 확인 (0이면 비활성화, 1이면 활성화)
cat /proc/sys/net/ipv4/ip_forward

# 일시적으로 활성화 (재부팅 시 초기화됨)
sysctl -w net.ipv4.ip_forward=1

# 영구적으로 활성화 (재부팅 후에도 유지)
echo 'net.ipv4.ip_forward=1' &amp;gt;&amp;gt; /etc/sysctl.conf

# 설정 적용 확인
sysctl -p&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot; data-token-index=&quot;0&quot;&gt;클라이언트 키 페어 생성&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 클라이언트마다 고유한 키 페어가 필요로 합니다. 이 키로 해당 클라이언트를 식별하고 인증할 수 있습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버(EC2)에서 클라이언트 키 생성&lt;/li&gt;
&lt;li&gt;클라이언트마다 별도의 키 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;나중에 특정 클라이언트 접속을 차단하려면 해당 키만 서버에서 제거하면 됨 &lt;/b&gt;(소규모 환경에서 적합)&lt;/li&gt;
&lt;li&gt;개인키를 네트워크로 전달해야 함 (보안상 조금 더 신경 써야 함)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762571032792&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 작업 디렉토리 유지
cd /etc/wireguard

# 클라이언트1 키 페어 생성
wg genkey | tee client1_private.key | wg pubkey &amp;gt; client1_public.key

# 생성된 키 확인
cat client1_private.key
cat client1_public.key&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot; data-token-index=&quot;0&quot;&gt;서버에 클라이언트 추가&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버가 클라이언트를 인식하고 VPN 연결을 허용하도록 설정합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 설정 파일에 클라이언트의 공개키와 VPN IP를 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762571718590&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;vim /etc/wireguard/wg0.conf

[Peer]
# 클라이언트의 공개키
PublicKey = {위에서 생성한 클라이언트의 공개키}
AllowedIPs = 10.6.0.2/32&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Wireguard 서비스 시작&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;지금까지 설정한 내용을 활성화하여 Wireguard VPN 서버를 실제로 구동합니다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;wg show&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;명령에서 인터페이스 정보와 Peer(클라이언트) 정보가 보여야 합니다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;systemctl status에서 active (running) 상태 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762571883560&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Wireguard 인터페이스 시작
wg-quick up wg0

# 상태 확인
wg show

# 부팅 시 자동 시작 설정
systemctl enable wg-quick@wg0

# 서비스 상태 확인
systemctl status wg-quick@wg0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;클라이언트 설정 파일 생성&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트(실제 접속할 PC/노트북)에서 사용할 설정 파일을 만듭니다. 이 파일에는 클라이언트의 개인키와 서버 정보가 포함됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762571998840&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo vim /etc/wireguard/client1.conf

[Interface]
# 클라이언트의 개인키
PrivateKey = {클라이언트의 개인키}
Address = 10.6.0.2/32
DNS = 8.8.8.8

[Peer]
# EC2의 공개키
PublicKey = {EC2의 공개키}
Endpoint = {서버의 공인IP}:51820
AllowedIPs = 10.0.0.0/16
PersistentKeepalive = 25&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PrivateKey: 클라이언트의 개인키&lt;/li&gt;
&lt;li&gt;Address: 클라이언트의 VPN IP&lt;/li&gt;
&lt;li&gt;Endpoint: 서버의 공인 IP와 포트&lt;/li&gt;
&lt;li&gt;AllowedIPs = 0.0.0.0/0: 모든 트래픽을 VPN으로 (split tunnel 원하면 수정 가능)&lt;/li&gt;
&lt;li&gt;PersistentKeepalive = 25: NAT 유지를 위해 25초마다 keepalive 패킷 전송&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot; data-token-index=&quot;0&quot;&gt;클라이언트에서 Wireguard 연결&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;클라이언트(실제 접속할 PC/노트북)에서&amp;nbsp;Wireguard VPN에 실제로 연결하여 설정이 제대로 되었는지 테스트합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762572406776&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;brew install wireguard-tools

# VPN 연결 시작
sudo wg-quick up /etc/wireguard/client1.conf

# 연결 상태 확인
sudo wg show

# VPN IP 확인
ifconfig utun6&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;VPN 연결 해제 방법&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762572417097&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo wg-quick down /etc/wireguard/client1.conf&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;EC2 인스턴스 보안 강화 및 접속 확인&lt;/span&gt;&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;보안 그룹(Security Group) 설정&lt;/span&gt;&amp;nbsp;&lt;/span&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;SSH (22): 10.0.6.0/24 &amp;larr; VPN 네트워크에서만 접근 가능&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;UDP (51820): 0.0.0.0/0 &amp;larr; VPN 연결은 허용&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1762572496522&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;맥북 공인 IP: 211.215.222.128
&amp;rarr; VPN 연결 (UDP 51820)
&amp;rarr; VPN IP 획득: 10.6.0.2
&amp;rarr; Security Group: SSH는 10.6.0.0/24만 허용
&amp;rarr; 10.6.0.2는 10.6.0.0/24에 포함 ✓
&amp;rarr; SSH 접속 성공! ✓&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  결론: WireGuard VPN으로 강화된 Zero Trust 접근 제어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WireGuard VPN을 구축한 이후, 기존의 외부에서 모든 접근을 허용한 Bastion Host 기반 접근 방식에서 벗어나 &lt;b&gt;Zero Trust 접근 모델&lt;/b&gt;을 도입할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이제는 &lt;b&gt;VPN을 통해 네트워크 레벨에서 먼저 인증을 통과한 사용자만 Bastion Host에 접근할 수 있도록 설계&lt;/b&gt;되었습니다. 이를 통해 접근 제어를 훨씬 더 엄격하게 관리할 수 있었으며, &lt;b&gt;WireGuard 클라이언트 인증서를 보유한 개발자만 VPN에 접속할 수 있도록 제한함으로써 세밀하고 확실한 접근 통제가 가능해졌습니다. &lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이제 팀원별로 공개키와 개인키 쌍을 생성해 Bastion Host에 대한 접근 권한을 부여할 수 있게 되었으며, 팀원이 탈퇴할 경우 EC2 인스턴스에서 해당 팀원의 공개키를 제거하여 접근 권한을 손쉽게 제어할 수 있게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;657&quot; data-start=&quot;519&quot; data-ke-size=&quot;size16&quot;&gt;또한 &lt;b&gt;공개키 기반 인증 구조&lt;/b&gt; 덕분에 권한이 없는 사용자는 애초에 VPN 연결 자체가 불가능해졌고, &lt;b&gt;SSH 포트가 외부 인터넷에 직접 노출되지 않음으로써&lt;/b&gt; 브루트포스 공격이나 취약점 스캐닝의 위협으로부터 완전히 벗어날 수 있었습니다. 결과적으로, WireGuard VPN을 도입함으로써&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;804&quot; data-start=&quot;690&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;733&quot; data-start=&quot;690&quot;&gt;Bastion Host에 대한 접근을 최소한의 신뢰 영역으로 한정하고,&lt;/li&gt;
&lt;li data-end=&quot;764&quot; data-start=&quot;734&quot;&gt;외부 노출 포트를 제거하여 공격 표면을 줄이며,&lt;/li&gt;
&lt;li data-end=&quot;804&quot; data-start=&quot;765&quot;&gt;개발자 인증 체계를 단순하면서도 안전하게 유지할 수 있었습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;877&quot; data-start=&quot;806&quot; data-ke-size=&quot;size16&quot;&gt;이로써 단순한 네트워크 분리 수준을 넘어,&lt;b&gt;&amp;nbsp;Zero Trust 보안 접근 제어&lt;/b&gt;를 구현할 수 있었습니다.&lt;/p&gt;</description>
      <category>Infra</category>
      <category>security</category>
      <category>VPN</category>
      <category>wireguard</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/13</guid>
      <comments>https://immersive.tistory.com/13#entry13comment</comments>
      <pubDate>Sat, 8 Nov 2025 13:08:45 +0900</pubDate>
    </item>
    <item>
      <title>[AWS] Terraform으로 인프라를 코드로 &amp;quot;잘&amp;quot; 관리하기</title>
      <link>https://immersive.tistory.com/12</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9jyjz/btsQ1TQKuzA/DNNHuaoSaDWorZASduvqG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9jyjz/btsQ1TQKuzA/DNNHuaoSaDWorZASduvqG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9jyjz/btsQ1TQKuzA/DNNHuaoSaDWorZASduvqG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9jyjz%2FbtsQ1TQKuzA%2FDNNHuaoSaDWorZASduvqG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;240&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;240&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 수동으로 관리되던 &quot;그로블&quot; 서비스의 클라우드 인프라를 비용 최적화 및 보안을 고려하여 재설계하는 업무를 맡았습니다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; 대표적인 IaC 툴인 Terraform을 도입하여 이번 업무를 체계적으로 수행하고자 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terraform은 형상 관리를 통해 인프라를 체계적으로 관리를 할 수 있으며, 의존성 관리를 통해 리소스를 안정적으로 배포 및 삭제하는 등 여러 가지 장점이 존재하는 것으로 알고 있습니다. 하지만, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Terraform을 쓰면서 개인적으로 느낀 가장 큰 장점은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;코드베이스를 통해 AI Agent와 자유롭게 논의할 수 있다는 점이었습니다. Claude Code, Gemeni CLI와 같은 AI Agent와 현재 내 인프라의 구성 정보에 대해 논의를 나누며 인프라를 발전해 나갈 수 있다는 점이 가장 좋았습니다. 하지만, 안타깝게도 AI Agent가 모든 것을 알려주는 것은 아닙니다... 이번 블로그에서는 수동으로 관리되던 AWS 인프라를 Terraform으로 재설계하며 겪은 시행착오 및 IaC 툴로 프로젝트를 &quot;잘&quot; 구성하는 방법에 대해서 소개해드리겠습니다!&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Terraform을 쓰면서 주의할 점 (상태 드리프트)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2436&quot; data-origin-height=&quot;1496&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kASG2/btsQ1lR7sZG/mpdghmTntnA15g1pfrW3IK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kASG2/btsQ1lR7sZG/mpdghmTntnA15g1pfrW3IK/img.png&quot; data-alt=&quot;Cloud Infra Architeture&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kASG2/btsQ1lR7sZG/mpdghmTntnA15g1pfrW3IK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkASG2%2FbtsQ1lR7sZG%2FmpdghmTntnA15g1pfrW3IK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;777&quot; height=&quot;477&quot; data-origin-width=&quot;2436&quot; data-origin-height=&quot;1496&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Cloud Infra Architeture&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;상황 정리&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 구축한 클라우드 인프라의 기본 구조는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;581&quot; data-start=&quot;304&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;438&quot; data-start=&quot;304&quot;&gt;&lt;b&gt;ALB (Application Load Balancer)&lt;/b&gt;&lt;br /&gt;ALB는 Public Subnet에 위치해 외부 트래픽을 수신합니다. 이후 리스너 규칙에 따라 트래픽을 대상 그룹(Target Group)에 속한 인스턴스로 라우팅합니다.&lt;/li&gt;
&lt;li data-end=&quot;581&quot; data-start=&quot;440&quot;&gt;&lt;b&gt;ECS + CodeDeploy&lt;/b&gt;&lt;br /&gt;API 서버는 무중단 배포를 위해 &lt;b&gt;CodeDeploy Blue/Green 배포&lt;/b&gt; 전략을 사용합니다. 따라서, 대상 그룹(Target Group)과 리스너 규칙은 배포 과정에서 동적으로 변경됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;문제 발생&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스는 정상적으로 운영되던 중이었고, 인프라에 사소한 변경 사항이 생겨 terraform apply를 실행했습니다. 그러자 &lt;b&gt;서비스 트래픽이 중단&lt;/b&gt;되는 상황이 발생하였고, 제가 분석한 상황은 다음과 같았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;820&quot; data-start=&quot;700&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;731&quot; data-start=&quot;700&quot;&gt;EC2 내부 컨테이너는 healthy 상태였음&lt;/li&gt;
&lt;li data-end=&quot;763&quot; data-start=&quot;732&quot;&gt;하지만 외부에서 접속하면 &lt;b&gt;503 에러&lt;/b&gt; 발생&lt;/li&gt;
&lt;li data-end=&quot;820&quot; data-start=&quot;764&quot;&gt;ALB 설정을 확인해 보니 &lt;b&gt;Target Group과 Listener 의존성이 깨져 있었음&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;865&quot; data-start=&quot;822&quot; data-ke-size=&quot;size16&quot;&gt;즉, 컨테이너는 정상 동작했지만, ALB 라우팅 경로가 꼬여버린 것입니다.&lt;/p&gt;
&lt;p data-end=&quot;865&quot; data-start=&quot;822&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;865&quot; data-start=&quot;822&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;원인 분석&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;865&quot; data-start=&quot;822&quot; data-ke-size=&quot;size16&quot;&gt;Terraform은 &quot;정의된 상태(desired state)&quot;를 기준으로 리소스를 관리하기 때문에, 외부에서 동적으로 바뀐 상태를 모른 채 다시 덮었어 버리는 문제가 발생합니다. 이러한 현상은 &lt;b&gt;&quot;상태 드리프트&quot;&lt;/b&gt;로 불리며 Terraform 사용 시 주의해야 되는 문제 중 하나였습니다. 제가 겪는 문제의 원인은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1135&quot; data-start=&quot;951&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;982&quot; data-start=&quot;951&quot;&gt;초기 Target Group은 &lt;b&gt;Blue&lt;/b&gt;였음&lt;/li&gt;
&lt;li data-end=&quot;1061&quot; data-start=&quot;983&quot;&gt;이후 실제 운영 중 새로운 버전이 배포가 되어 CodeDeploy가 새로운 &lt;b&gt;Green Target Group&lt;/b&gt;을 생성하고 ALB Listener를 갱신했음&lt;/li&gt;
&lt;li data-end=&quot;1135&quot; data-start=&quot;1062&quot;&gt;하지만 Terraform은 이러한 동적 변화를 모른 채, 제가 정의한 상태대로 &lt;b&gt;다시 Blue를 바라보도록 수정해 버렸음&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1199&quot; data-start=&quot;1137&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과, terraform apply 실행 후 실제 컨테이너는 Green 환경에서 실행 중임에도 불구하고 ALB의 Target Group은 Blue로 롤백되어 버렸습니다. 이로 인해 ALB와 컨테이너 간 연결이 꼬이고, 결국 트래픽이 끊기는 문제가 발생했습니다. 즉, &lt;b&gt;Terraform의 선언적 상태 관리와 CodeDeploy의 동적 Blue/Green 배포 방식이 충돌&lt;/b&gt;하면서 생긴 문제였던 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;865&quot; data-start=&quot;822&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;865&quot; data-start=&quot;822&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;해결&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;865&quot; data-start=&quot;822&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 다음과 같이 Terraform이 ECS Service의 특정 속성을 관리하지 않도록 설정해야 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1755613569353&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;lifecycle {
    ignore_changes = [task_definition, load_balancer]
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;1372&quot; data-start=&quot;1343&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 ignore_changes를 추가하면, &lt;b&gt;task_definition (&lt;/b&gt;배포 시점에 CodeDeploy가 자동으로 갱신), &lt;b&gt;load_balancer (&lt;/b&gt;CodeDeploy가 Blue &amp;harr; Green 전환 시 갱신)을 Terraform이 건드리지 않게 됩니다. 즉, &lt;b&gt;배포 관련 동적 리소스는 CodeDeploy가 책임지고 관리&lt;/b&gt;하게 하고, Terraform은 나머지 인프라만 관리하도록 분리한 것이죠.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;프로젝트가 확장되면서 고민하게 된 디렉터리 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;초기 버전&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1755525308656&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;project/
├── main.tf
├── variables.tf
├── provider.tf
├── outputs.tf
├── terraform.tfvars
└── README.md&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terraform을 사용하면서 처음에 코드를 기반으로 Terraform이 AWS에 실제로 리소스를 추가/변경/삭제하는 것이 신기했습니다. 처음에는 가장 단순하게 아래와 같은 파일 구조를 사용했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 main.tf에 몇 개의 리소스만 정의하고, 변수는 variables.tf로 분리하는 정도로 시작했습니다. 하지만 점차 리소스가 늘어나면서 main.tf 파일이 지나치게 비대해졌습니다. VPC 설정부터 ECS 클러스터, CodeDeploy 설정까지 모든 내용을 한 파일에 몰아넣다 보니, 특정 리소스를 찾는 데 시간이 오래 걸렸고 코드 리뷰 또한 쉽지 않았습니다. 현재는 혼자 프로젝트를 관리하고 있어 큰 불편이 없지만, 향후 협업이 필요해질 경우 이런 구조는 main.tf에서 잦은 충돌이 발생할 것이 뻔했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 프로젝트를 보다 체계적으로 관리하기 위한 구조 개선이 필요하다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;환경별 분리&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1755525628229&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;project/
├── 01-vpc.tf
├── 02-security-groups.tf
├── 03-load-balancer.tf
├── 04-iam-roles.tf
├── 05-ecs-cluster.tf
├── 06-ecs-task-definitions.tf
├── 07-ecs-services.tf
├── 08-codedeploy.tf
├── 09-ecr.tf
├── 10-routes53.tf
├── variables.tf
├── provider.tf
├── outputs.tf
├── terraform.tfvars
└── README.md&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 &lt;b&gt;논리적 단위별로 파일을 분리&lt;/b&gt;하는 구조로 전환했습니다. 변경된 파일 이름만 봐도 어떤 리소스에 수정 사항이 있었다는 것을 바로 알 수 있었습니다. 또한, 파일 순서별로 인프라가 어떤 순서로 구축되었는지 직관적으로 확인할 수 있기에 인프라 구축 중에 문제가 생기면 파일 순서대로 배포하면 원복 시킬 수 있는 장점도 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이 구조도 완벽하지 않았습니다. &lt;b&gt;사소한 리소스 변경이 있을 때마다 terraform이 모든 인프라 구조를 확인&lt;/b&gt;하고 적용했기에 배포 시간이 오래 걸리는 문제가 발생했습니다. API 서비스의 태스크 정의만 변경하고 싶은데 이것을 적용하기 위해 Terraform은 전체 인프라의 상태를 확인해야 했습니다. 프로젝트가 성장함에 따라 인프라를 수정하는데 소요되는 시간이 증가하는 비효율적인 구조라는 생각이 들었습니다. 또한, 하나의 state에서 의존성이 모두 묶여있어 하나의 잘못된 리소스 변경이 전체 인프라에 영향을 줄 수 있다는 사실도 위험했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 대규모 프로젝트에서 Terraform 파일 구조를 어떻게 설계하는지 찾아보고 프로젝트 구조를 리팩터링 하는 작업이 필요했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;레이어별 분리&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1759562143008&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;project/
├── README.md                    # 이 파일
├── .gitignore                   # Git 무시 파일
├── .terraform.lock.hcl         # Terraform 의존성 잠금
│
├── environments/               #   환경별 설정
│   ├── README.md              
│   ├── shared/                # 공유 환경 (인프라 기반 + 플랫폼)
│   │   ├── main.tf           # 공유 리소스 메인 설정
│   │   ├── terraform.tfvars  # 공유 환경 변수 값
│   │   ├── variables.tf      # 공유 환경 변수 정의
│   │   ├── versions.tf       # Terraform &amp;amp; Provider 버전
│   │   └── outputs.tf        # 다른 환경에서 참조할 출력값
│   ├── monitoring/           #   모니터링 환경 (관측성 스택)
│   │   ├── main.tf           # 모니터링 서비스 메인 설정
│   │   ├── terraform.tfvars  # 모니터링 환경 변수 값
│   │   ├── variables.tf      # 모니터링 환경 변수 정의
│   │   ├── versions.tf       # Terraform &amp;amp; Provider 버전
│   │   └── README.md         # 상세 모니터링 가이드
│   ├── dev/                   # 개발 환경 (서비스 계층)
│   │   ├── main.tf           # 개발 환경 메인 설정
│   │   ├── terraform.tfvars  # 개발 환경 변수 값
│   │   ├── variables.tf      # 개발 환경 변수 정의
│   │   └── versions.tf       # Terraform &amp;amp; Provider 버전
│   └── prod/                  # 프로덕션 환경 (서비스 계층)
│       ├── main.tf           # 프로덕션 환경 메인 설정
│       ├── terraform.tfvars  # 프로덕션 환경 변수 값
│       ├── variables.tf      # 프로덕션 환경 변수 정의
│       └── versions.tf       # Terraform &amp;amp; Provider 버전
│
├── modules/                   #   재사용 가능한 모듈들
│   ├── infrastructure/       #  ️ 인프라 기반 (변경 빈도: 낮음)
│   │   ├── vpc/             # VPC 및 네트워킹
│   │   ├── security-groups/ # 보안 그룹
│   │   ├── load-balancer/   # Application Load Balancer
│   │   ├── iam-roles/       # IAM 역할 및 정책
│   │   └── route53/         # DNS 및 도메인 관리
│   ├── platform/            # ⚙️ 플랫폼 계층 (변경 빈도: 중간)
│   │   ├── ecs-cluster/     # ECS 클러스터 관리
│   │   ├── ecr/             # 컨테이너 레지스트리
│   │   └── codedeploy/      # Blue/Green 배포
│   └── services/            #   서비스 계층 (변경 빈도: 높음)
│       ├── monitoring/      # 모니터링 환경 서비스
│       │   ├── grafana/        # Grafana 대시보드
│       │   ├── prometheus/     # Prometheus 메트릭 수집
│       │   ├── loki/           # Loki 로그 수집
│       │   └── otelcol/        # OpenTelemetry Collector
│       ├── development/     # 개발 환경 서비스
│       │   ├── api-service/    # Spring Boot API
│       │   ├── mysql-service/  # MySQL 데이터베이스
│       │   └── redis-service/  # Redis 캐시
│       └── production/      # 프로덕션 환경 서비스
│           ├── api-service/    # Spring Boot API
│           ├── mysql-service/  # MySQL 데이터베이스
│           └── redis-service/  # Redis 캐시
│
├── shared/                    #   공통 설정
│   ├── README.md
│   ├── providers.tf          # Terraform 프로바이더 공통 설정
│   ├── variables.tf          # 공통 변수 정의
│   └── outputs.tf           # 공통 출력 정의
│
├── docs/                      #   문서
│   └── README-deployment.md  # 배포 가이드
│
├── scripts/                   #   유틸리티 스크립트
│   └── deploy-step.sh        # 단계별 배포 스크립트
│
└── backups/                   #   백업 파일들
    ├── *.tf.backup          # 기존 Terraform 파일들
    └── *.old                # 이전 설정 파일들&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;현재는 위와 같이 인프라를 &lt;b&gt;변경 빈도와 의존성&lt;/b&gt;에 따라 레이어로 나누었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Infrastructure Layer&lt;/b&gt; (변경 빈도: 거의 없음) : VPC, Subnets, Internet Gateway&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Platform Layer&lt;/b&gt; (변경 빈도: 드묾) : Security Groups, Load Balancers, IAM Roles&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Service Layer&lt;/b&gt; (변경 빈도: 높음) : ECS Services, Task Definitions&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 각 레이어가 &lt;b&gt;독립적인 Terraform 상태&lt;/b&gt;를 가지며, 상위 레이어의 출력(outputs)을 하위 레이어가 참조하는 구조로 재설계했습니다. 즉, Service Layer의 변경 사항이 있을 때, Terraform은 변경 사항이 있는 Service Layer만 확인하고 나머지 레이어는 확인하지 않습니다. 그 결과 배포 속도가 향상되었으며 Service Layer에서의 실수가 다른 Layer의 오류로 전파되지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 Terraform을 만든 HashiCorp의 권장사항에서도 유사한 접근을 권장합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Separate your infrastructure into multiple, smaller state files to reduce blast radius and improve team collaboration.&quot;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;인프라 코드도 애플리케이션 코드와 마찬가지로, 변경 빈도와 책임에 따라 적절히 분리해야 한다는 것을 깨달았습니다. 단일 책임 원칙(Single Responsibility Principle)은 애플리케이션 코드뿐만 아니라 Terraform에도 동일하게 적용됩니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terraform을 활용하면서 실제로 비용 효율적인 작업을 할 수 있었습니다. 서비스 출시 전에는 인프라를 구축할 때만 terraform apply를 실행했고, 작업이 끝나면 terraform destroy로 모든 인프라를 삭제해 불필요한 비용을 줄였습니다. 이를 통해 초기 인프라 구축 단계에서 상당한 비용 절감 효과를 얻을 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Terraform이 가져온 진짜 변화는 단순한 비용 절감을 넘어섭니다. 파일 구조를 개선하면서 배포 시간이 단축되었고, 인프라를 레이어별로 분리하면서 작은 변경에도 전체를 검증해야 했던 비효율이 사라졌습니다. 이를 통해 여러 개발자가 동시에 작업할 수 있는 협업 체계의 기반을 마련할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 인프라를 코드로 관리하게 되면서 &lt;b&gt;&amp;ldquo;누가, 어떻게 인프라를 구축했는지&amp;rdquo;&lt;/b&gt;를 명확히 추적할 수 있게 되었고, 새로운 팀원도 AWS 콘솔에서 구조를 일일이 확인할 필요 없이 코드를 통해 전체 인프라를 이해할 수 있었습니다. 또한 문제가 발생했을 때 언제든 이전 상태로 롤백할 수 있다는 안정감 덕분에 자신 있게 새로운 리소스를 추가하고 삭제하며 개선을 시도할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/TEAM-LIAISON/groble-infra&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/TEAM-LIAISON/groble-infra&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1759560477910&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - TEAM-LIAISON/groble-infra: Groble Monitoring Infra&quot; data-og-description=&quot;Groble Monitoring Infra. Contribute to TEAM-LIAISON/groble-infra development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/TEAM-LIAISON/groble-infra&quot; data-og-url=&quot;https://github.com/TEAM-LIAISON/groble-infra&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/iqWwW/hyZKnA5o04/JuDo1emIbWGDJ9EC1YW160/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ZEBpr/hyZKlXxVVZ/AzFebkGyMCr06bayPdi9Ik/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/TEAM-LIAISON/groble-infra&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/TEAM-LIAISON/groble-infra&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/iqWwW/hyZKnA5o04/JuDo1emIbWGDJ9EC1YW160/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ZEBpr/hyZKlXxVVZ/AzFebkGyMCr06bayPdi9Ik/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - TEAM-LIAISON/groble-infra: Groble Monitoring Infra&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Groble Monitoring Infra. Contribute to TEAM-LIAISON/groble-infra development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>Cloud</category>
      <category>AWS</category>
      <category>IaC</category>
      <category>terraform</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/12</guid>
      <comments>https://immersive.tistory.com/12#entry12comment</comments>
      <pubDate>Sat, 4 Oct 2025 17:43:17 +0900</pubDate>
    </item>
    <item>
      <title>[AWS] Private Subnet으로 EC2 이관 여정</title>
      <link>https://immersive.tistory.com/11</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;562&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2pVva/btsPUlmHb3g/tWS64k3KE3OiKyPZthugR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2pVva/btsPUlmHb3g/tWS64k3KE3OiKyPZthugR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2pVva/btsPUlmHb3g/tWS64k3KE3OiKyPZthugR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2pVva%2FbtsPUlmHb3g%2FtWS64k3KE3OiKyPZthugR0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;696&quot; height=&quot;306&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;562&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 블로그에서는 보안을 강화하기 위해 기존에 Public Subnet에 위치했던 EC2 인스턴스를 Private Subnet으로 이전하게 된 배경과, 실제 이관 과정에서 발생했던 문제들과 이를 해결한 과정을 자세히 소개하려고 합니다. 실제 운영 환경에서 겪은 트러블 슈팅 경험과 적용한 설정들을 함께 공유함으로써, Private Subnet으로의 이관이 왜 필요한지와 구체적인 절차를 이해하는 데 도움을 드리고자 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 기존 인프라 구조와 취약점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이관 전에는 모든 EC2 인스턴스가 &lt;b&gt;Public Subnet&lt;/b&gt;에 위치해 있었습니다. 이는 개발 단계에서 소셜 로그인, 이메일 발송 등 인터넷과 직접 통신이 필요한 기능을 별도의 추가 설정 없이 간편하게 구현하기 위함이었습니다.&lt;/p&gt;
&lt;p data-end=&quot;349&quot; data-start=&quot;172&quot; data-ke-size=&quot;size16&quot;&gt;Prod/Dev 인스턴스 내부에는 ECS가 관리하는 &lt;b&gt;Spring API Server Service&lt;/b&gt;와 &lt;b&gt;MySQL Service&lt;/b&gt;가 함께 존재하며, 개발 단계에서 로그 확인과 MySQL 접속을 위해 22번(SSH)과 3306번(MySQL) 포트를 개방한 상태였습니다. 개발 단계에서의 인프라 구조는 다음과 같았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;2160&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PsxRu/btsPVefQWJY/JHJ4NSy3hbmM7Cdh1K4UW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PsxRu/btsPVefQWJY/JHJ4NSy3hbmM7Cdh1K4UW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PsxRu/btsPVefQWJY/JHJ4NSy3hbmM7Cdh1K4UW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPsxRu%2FbtsPVefQWJY%2FJHJ4NSy3hbmM7Cdh1K4UW0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3840&quot; height=&quot;2160&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;2160&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이러한 구조는 보안상 매우 취약합니다. 물론 .pem 키가 있어야 22번 포트를 통해 접속이 가능하지만, 원칙적으로 공격 경로가 열려 있기 때문에 악의적인 IP가 수천&amp;middot;수만 번의 접속 시도를 할 수 있고, 이는 서버에 심각한 부하를 유발할 수 있습니다. 더 큰 문제는, SSH, OpenSSL, 혹은 서버 OS에 취약점이 발견되는 순간입니다. 이 경우 Public Subnet에 위치한 EC2는 .pem 키가 없어도 &lt;b&gt;패치 전까지 무방비 상태&lt;/b&gt;가 될 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;765&quot; data-start=&quot;625&quot; data-ke-size=&quot;size16&quot;&gt;즉, 현재 구조는 &lt;b&gt;자격 증명&lt;/b&gt;을 통해 리소스를 보호하고 있으나, 그 이전 단계에서 &lt;b&gt;공격이 가능한 진입점을 원천적으로 제거&lt;/b&gt;해야 합니다. 따라서 서비스 출시 전, 이러한 위험을 최소화하기 위한 &lt;b&gt;보안 강화 전략&lt;/b&gt;이 반드시 필요했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 보안 강화를 위한 안전한 이관 계획&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안을 강화하기 위해, Spring API Service와 MySQL Service가 기동 중인 Prod/Dev 인스턴스를 &lt;b&gt;Private Subnet&lt;/b&gt;에 배치하는 것이 합리적이라고 판단했습니다. 하지만, Private Subnet에 위치한 리소스는 Public IP가 할당되지 않으므로 인터넷으로 직접 트래픽을 보낼 수 없고, 외부에서도 곧바로 접근할 수 없습니다. 이를 위해서는 해결하기 위해 두 가지 구성이 필요했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;407&quot; data-start=&quot;283&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;351&quot; data-start=&quot;283&quot;&gt;&lt;b&gt;Bastion Host&lt;/b&gt;: 개발자가 Prod/Dev 인스턴스에 접속하기 위해 Public Subnet에 배치&lt;/li&gt;
&lt;li data-end=&quot;407&quot; data-start=&quot;352&quot;&gt;&lt;b&gt;NAT Gateway&lt;/b&gt;: Prod/Dev 인스턴스에서 외부로 트래픽을 전송할 때 사용&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;588&quot; data-start=&quot;409&quot; data-ke-size=&quot;size16&quot;&gt;그러나 한정된 예산 안에서 이 모든 리소스를 추가하는 것은 부담이 컸습니다. 이에 따라, 상대적으로 중요도가 낮은 데이터를 보유한 &lt;b&gt;Monitoring Instance&lt;/b&gt;를 Public Subnet에 남기고, 해당 인스턴스가 &lt;b&gt;Bastion Host&lt;/b&gt;와 &lt;b&gt;NAT Gateway&lt;/b&gt; 역할을 겸하도록 결정했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;2160&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3ZFg9/btsPVHvjedx/vQeZaKdcfvTJZs8AeLaJU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3ZFg9/btsPVHvjedx/vQeZaKdcfvTJZs8AeLaJU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3ZFg9/btsPVHvjedx/vQeZaKdcfvTJZs8AeLaJU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3ZFg9%2FbtsPVHvjedx%2FvQeZaKdcfvTJZs8AeLaJU1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3840&quot; height=&quot;2160&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;2160&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;Monitoring Instance가 Nat Instance처럼 동작하도록 설정&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Monitoring Instance를 &lt;b&gt;Proxy 서버&lt;/b&gt;로 구성할지, 아니면 &lt;b&gt;NAT Instance&lt;/b&gt;로 구성할지에 대한 고민이 있었습니다. Proxy 서버로 동작시키려면 Squid와 같은 애플리케이션을 설치해야 하고, NAT Instance로 동작시키려면 인스턴스의 &lt;b&gt;iptables 설정&lt;/b&gt;을 변경해야 했습니다.&lt;/p&gt;
&lt;p data-end=&quot;365&quot; data-start=&quot;234&quot; data-ke-size=&quot;size16&quot;&gt;두 방식의 가장 큰 차이는 동작 계층에 있습니다. Proxy 서버는 L7(Application Layer)에서 트래픽을 처리하는 반면, NAT Instance는 L3(Network Layer)에서 트래픽을 처리합니다.&lt;/p&gt;
&lt;p data-end=&quot;688&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 Monitoring Instance에 Squid를 설치해 Proxy 서버로 구성했습니다. 그 결과, HTTP/HTTPS 트래픽은 정상적으로 인터넷에 도달할 수 있었습니다. 그러나 이메일 전송처럼 &lt;b&gt;SMTP 프로토콜&lt;/b&gt;을 사용하는 트래픽은 Squid를 통과할 수 없어, 이를 처리하기 위해서는 별도로 MTA(Mail Transfer Agent)를 추가 설치해야 했습니다. Proxy 서버는 애플리케이션 계층에서 트래픽을 제어하기 때문에 세밀한 라우팅과 로깅이 가능하다는 장점이 있었지만, 제 상황에서는 단순히 트래픽을 외부로 전달하기만 하면 충분했습니다.&lt;/p&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;따라서 Monitoring Instance에서 기존에 설정한 프록시 설정을 모두 제거하고, iptables 설정을 변경해 Monitoring Instance가 &lt;b&gt;NAT Instance&lt;/b&gt;처럼 동작하도록 구성했습니다.&lt;/p&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;&amp;lt;프록시 설정 제거&amp;gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존에는 Monitoring Instance를 Proxy 서버로 구성하고, Spring Application에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;HTTP_PROXY, HTTPS_PROXY, NO_PROXY&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;환경 변수를 설정하여 외부와 통신하도록 했습니다. 하지만 NAT Instance로 전환하면서 애플리케이션 레벨에서 프록시를 지정할 필요가 없어졌습니다. 따라서 Task Definition에서 관련 환경 변수를 삭제하고, Spring 내부 설정에서도 불필요한 Proxy 설정 코드를 제거했습니다. 이로 인해 애플리케이션은 NAT Instance를 통해 자연스럽게 외부와 통신할 수 있으며, Proxy 서버를 따로 유지할 필요가 없어져서 코드가 한결 간결해질 수 있었습니다.&lt;/p&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;&amp;lt;Private Route Table 생성 및 보안 그룹 설정&amp;gt;&lt;/p&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;Private Subnet에 위치한 인스턴스는 기본적으로 인터넷에 직접 접근할 수 없기 때문에, NAT Instance를 통해서만 외부와 통신할 수 있도록 Private Route Table을 새로 생성했습니다. Private Route Table에는 0.0.0.0/0 대상이 NAT Instance의 ENI를 향하도록 설정하여, Private Subnet의 인스턴스가 외부 인터넷과 통신할 수 있도록 구성했습니다.&lt;/p&gt;
&lt;p data-end=&quot;783&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;보안 그룹 측면에서는 NAT Instance의 SG는 Private Subnet에서 들어오는 트래픽을 허용하고, 외부로 나가는 트래픽은 모두 허용하도록 설정했습니다. 반대로 Private Subnet 인스턴스의 SG는 NAT Instance로 나가는 트래픽만 허용하고, 인바운드 규칙은 최소한의 포트만 열어 내부 통신에 필요한 트래픽만 허용하도록 하였습니다. 이 구조를 통해 Private Subnet 인스턴스는 NAT Instance를 경유하여 안전하게 외부와 통신할 수 있게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;Public Subnet에 위치한 Prod/Dev 인스턴스를 Private Subnet을 이관&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안 강화를 위해 Public Subnet에 위치한 운영 및 개발 인스턴스를 Private Subnet으로 옮겼습니다. 이전 과정에서는 인스턴스를 잠시 중단해야 했기 때문에, 중요한 데이터인 MySQL 데이터와 Redis 데이터를 모두 백업했습니다. 이후 Private Subnet에 동일한 환경으로 새로운 인스턴스를 생성하고, 기존 데이터는 복구했습니다. 이 과정에서 보안 그룹을 재구성하여 외부에서는 ALB나 Bastion Host를 통해서만 접근 가능하도록 제한하고, DB 접근은 Private Subnet 내부에서만 허용했습니다. 결과적으로 인스턴스가 인터넷에 직접 노출되지 않고, NAT Instance를 통해 외부와 통신하면서도 내부 보안이 강화된 구조로 전환할 수 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트러블 슈팅 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이관 계획은 나름 단순했지만, 의도한대로 바로 Private Subnet에 위치한 인스턴스에서 Private Route Table의 규칙을 적용받아 Public Subnet에 위치한 NAT Instance로 트래픽이 전달되지 않는 문제를 겪었습니다. 이 문제를 해결하기 위해 다음 2가지 트러블 슈팅 과정을 겪었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Private Subnet에 위치한 인스턴스에서 외부로 트래픽이 안나가는 문제 (호스트 레벨)&lt;/h3&gt;
&lt;pre id=&quot;code_1755260534957&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;iptables -t nat -A POSTROUTING -s 10.0.11.0/24 -o eth0 -j MASQUERADE
iptables -t nat -A POSTROUTING -s 10.0.12.0/24 -o eth0 -j MASQUERADE&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 위와 같은 설정이 Monitoring Instance에 적용되어 있었습니다. 패킷 처리 과정은 다음과 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;패킷 도착 -&amp;gt; 라우팅 결정 -&amp;gt; 출력 인터페이스 결정 -&amp;gt; POSTROUTING 체인 진입 -&amp;gt; 규칙 매칭 확인&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;패킷 도착&lt;/b&gt;: private subnet에서 패킷 도착 (&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;출발지 IP가 10.0.11.0/24, 1.0.12.0/24 대역&lt;/span&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;라우팅 결정&lt;/b&gt;: 커널이 라우팅 테이블을 확인&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;출력 인터페이스 결정&lt;/b&gt;: 커널이 출력 인터페이스 선택 (&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;ens5를 출력 인터페이스로 선택&lt;/b&gt;&lt;/span&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;POSTROUTING 체인 진입&lt;/b&gt;: 패킷이 POSTROUTING에서 iptables 규칙 검사 (&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;eth0 출력 인터페이스로만 나가는 패킷에 적용&lt;/span&gt;&lt;/b&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;규칙 매칭 확인&lt;/b&gt;: 조건 불일치로 MASQUERADE 적용 실패&amp;nbsp;(소스 IP를 해당 인터페이스의 IP로 변경)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;즉, &lt;b&gt;POSTROUTING&lt;/b&gt; 체인 진입 단계에서 iptables NAT 규칙 검사 결과 &lt;/span&gt;&lt;/span&gt;&lt;span&gt;eth0으로 나가는 패킷에 대해서만 MASQUERADE를 적용하도록 설정되어 있지만, &lt;/span&gt;&lt;span&gt;실제 패킷의 출력 인터페이스는 ens5였기 때문에 규칙 매칭에 실패하여 &lt;/span&gt;&lt;span&gt;NAT 변환이 적용되지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;참고&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거 Linux 시스템에서는 eth0(eth0, eth1, eth2... )로 커널이 네트워크 카드를 발견하는 순서에 따라 명명을 했지만, 이렇게 되면 하드웨어 변경이나 부팅 순서에 따라 인터페이스 이름이 바뀔 수 있습니다. 따라서, 예측 가능한 명명 규칙으로 ens5(en: Ethernet + s5: 슬롯 번호 5)가 등장했으며 AWS에서는 일관되게 가상 네트워크 인터페이스를 PCI 슬롯 5에 배치하므로 대부분의 경우 ens5가 사용됩니다&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1755261292045&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;PRIMARY_INTERFACE=$(ip route | grep default | awk '{print $5}' | head -n1)

iptables -t nat -A POSTROUTING -s 10.0.11.0/24 -o $PRIMARY_INTERFACE -j MASQUERADE
iptables -t nat -A POSTROUTING -s 10.0.12.0/24 -o $PRIMARY_INTERFACE -j MASQUERADE&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Monitoring Instance에 설정을 변경하여 위와 같이 네트워크 인터페이스를 하드코딩하지 않고 동적으로 출력 인터페이스를 감지하도록 설정했습니다. 그 결과 아래와 같이 Private Subnet에 위치한 인스턴스에서 외부 인터넷 공간으로 패킷이 전달되는 것을 확인할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cF5G3s/btsPSNEcuWM/tmpovehe9URtvaNKzTJjW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cF5G3s/btsPSNEcuWM/tmpovehe9URtvaNKzTJjW0/img.png&quot; data-alt=&quot;Private Subnet에 위치한 EC2에서 인터넷으로 접근이 가능&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cF5G3s/btsPSNEcuWM/tmpovehe9URtvaNKzTJjW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcF5G3s%2FbtsPSNEcuWM%2Ftmpovehe9URtvaNKzTJjW0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1212&quot; height=&quot;196&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;196&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Private Subnet에 위치한 EC2에서 인터넷으로 접근이 가능&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. ECS Service에서 외부로 트래픽이 안나가는 문제 (컨테이너 레벨)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 해결 후, EC2 인스턴스 자체에서는 정상적으로 외부로 트래픽이 나가는 것을 확인했습니다. 그러나 실제로 Spring API Server가 실행 중인 컨테이너에 접속해 외부 요청을 보내보니 요청이 실패했습니다.&lt;/p&gt;
&lt;p data-end=&quot;338&quot; data-start=&quot;169&quot; data-ke-size=&quot;size16&quot;&gt;원인을 조사해 보니, Spring API Server는 &lt;b&gt;awsvpc&lt;/b&gt; 네트워크 모드로 실행되고 있었습니다. CodeDeploy의 Blue/Green 배포를 사용하려면 서비스가 awsvpc 모드로 실행되어야 하므로, 컨테이너는 호스트 EC2와 독립적인 &lt;b&gt;별도의 ENI&lt;/b&gt;를 사용하고 있었습니다.&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;340&quot; data-ke-size=&quot;size16&quot;&gt;해당 ENI를 확인한 결과, Public Subnet의 IP를 할당받고 있었습니다. 이는 Private Subnet으로 이관하는 과정에서 미처 고려하지 못한 부분이었습니다. Public Subnet의 IP를 사용한다는 것은 &lt;b&gt;Internet Gateway와 연결된 Public Route Table 규칙&lt;/b&gt;을 따른다는 의미이며, 이 때문에 Spring API Server는 NAT Instance를 거치지 않고 직접 외부로 트래픽을 전송하려고 했습니다. 그러나 VPC 내부 전용 IP만 보유한 상태에서는 직접 인터넷에 접근할 수 없어 요청이 실패했던 것입니다.&lt;/p&gt;
&lt;p data-end=&quot;838&quot; data-start=&quot;658&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해, Spring API Server가 할당받는 IP를 &lt;b&gt;Private Subnet 대역&lt;/b&gt;으로 변경했습니다. 그 결과, 서비스는 NAT Instance로 트래픽을 전달하는 &lt;b&gt;Private Route Table 규칙&lt;/b&gt;을 따르게 되었고, 인터넷 트래픽이 정상적으로 외부로 전달될 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;938&quot; data-origin-height=&quot;112&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qHyTs/btsPUWT5GaG/PDGqB22IkGYMwoxC7gy5XK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qHyTs/btsPUWT5GaG/PDGqB22IkGYMwoxC7gy5XK/img.png&quot; data-alt=&quot;컨테이너 내부에서도 인터넷으로 접근이 가능&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qHyTs/btsPUWT5GaG/PDGqB22IkGYMwoxC7gy5XK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqHyTs%2FbtsPUWT5GaG%2FPDGqB22IkGYMwoxC7gy5XK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;938&quot; height=&quot;112&quot; data-origin-width=&quot;938&quot; data-origin-height=&quot;112&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;컨테이너 내부에서도 인터넷으로 접근이 가능&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Before: Spring Container &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;➡️ &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;Public Route Table &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;➡️ &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;Internet Gateway &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;➡️&amp;nbsp; &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;Internet (&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;❌ 실패!&lt;/span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;After: &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Spring Container&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;➡️&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;Private Route Table&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;➡️ &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;NAT Instance&lt;/span&gt;&lt;span&gt; &lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;➡️&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;Internet Gateway&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;➡️&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;Internet (&lt;span style=&quot;background-color: #ffffff; color: #383a42; text-align: left;&quot;&gt;✅ 성공!&lt;/span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Public Subnet에 있던 인스턴스를 Private Subnet으로 이전함으로써, 인터넷상의 악성 트래픽으로부터 중요한 자원들(특히 MySQL 컨테이너)을 1차적으로 보호할 수 있었습니다. 이제 개발자가 Prod/Dev 인스턴스에 접속하기 위해서는 반드시 Bastion Host를 거쳐야 하며, 예를 들어 MySQL에 접근하려면 Bastion Host를 통해 SSH 터널링을 해야 합니다.&lt;/p&gt;
&lt;p data-end=&quot;485&quot; data-start=&quot;272&quot; data-ke-size=&quot;size16&quot;&gt;다만, NAT Instance이자 Bastion Host 역할을 하는 Monitoring Instance는 여전히 Public Subnet에 위치해 있습니다. 특히 이 인스턴스의 22번 포트가 인터넷에 직접 노출되어 있어 보안 위험이 존재합니다. 또한, Subnet 앞단에 위치한 로드밸런서는 80 포트와 443 포트를 개방한 상태로 모든 외부 트래픽을 수신할 수 있는 상황입니다.&lt;/p&gt;
&lt;p data-end=&quot;688&quot; data-start=&quot;487&quot; data-ke-size=&quot;size16&quot;&gt;이러한 보안 취약점을 개선하기 위해, 앞으로 Monitoring Instance에 VPN을 구성하여 &lt;b&gt;인증된 사용자만&lt;/b&gt; 접근할 수 있도록 할 예정입니다. 더불어, 로드밸런서 앞단에는 AWS WAF(Web Application Firewall)를 도입해 ALB 단계에서 웹 공격, 봇, 스팸 트래픽 등 다양한 위협으로부터 애플리케이션을 보호할 계획입니다.&lt;/p&gt;</description>
      <category>Cloud</category>
      <category>AWS</category>
      <category>AWSVPC</category>
      <category>nat</category>
      <category>Private Subnet</category>
      <category>terraform</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/11</guid>
      <comments>https://immersive.tistory.com/11#entry11comment</comments>
      <pubDate>Sat, 16 Aug 2025 16:23:07 +0900</pubDate>
    </item>
    <item>
      <title>[Infra] 실무에서 사용해본 유용한 Docker Swarm 기능들</title>
      <link>https://immersive.tistory.com/10</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;426&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSZ6A1/btsOwJg4lpf/ivFoV9vSrugLJdULGLhtRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSZ6A1/btsOwJg4lpf/ivFoV9vSrugLJdULGLhtRK/img.png&quot; data-alt=&quot;Docker Swarm&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSZ6A1/btsOwJg4lpf/ivFoV9vSrugLJdULGLhtRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSZ6A1%2FbtsOwJg4lpf%2FivFoV9vSrugLJdULGLhtRK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;512&quot; height=&quot;426&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;426&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Docker Swarm&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Docker Swarm이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 컨테이너로 시작한 애플리케이션도 비즈니스 요구사항이 증가함에 따라 점차 마이크로서비스 아키텍처(MSA)를 도입하게 되고, 이에 따라 컨테이너 수도 함께 증가하게 됩니다. 컨테이너 내부에서 실행되는 애플리케이션의 특성에 따라 각기 다른 관리 전략이 필요해지고, 이는 운영 복잡도를 높입니다. 컨테이너 오케스트레이션 도구가 등장하기 전에는 인프라 관리자가 모든 컨테이너를 직접 배포하고 모니터링하며 운영을 책임져야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;566&quot; data-start=&quot;322&quot; data-ke-size=&quot;size16&quot;&gt;하지만, Docker Swarm을 비롯한 다양한 오케스트레이션 도구의 등장으로 이러한 작업들이 자동화되었고, 인프라를 코드로 체계적으로 관리할 수 있는 기반이 마련되었습니다. Docker의 공식 컨테이너 오케스트레이션 도구인 Docker Swarm을 한 문장으로 정리하자면 &quot;&lt;b&gt;여러 노드에서 실행 중인 Docker 호스트들을 하나의 가상 호스트처럼 묶어 관리할 수 있도록 해주는 클러스터링 솔루션&lt;/b&gt;&quot;입니다. 이번 블로그에서는 Docker Swarm 기반의 프로젝트를 구축 및 운영하면서 파악한 Docker Swarm의 작동 원리와 유용한 기능들을 소개해드리겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;굳이 Kubernetes 대신 Docker Swarm을 사용하는 이유&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;537&quot; data-origin-height=&quot;317&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b02tQs/btsOusBcRii/rWpNdQhallceR8IqPhC8Ck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b02tQs/btsOusBcRii/rWpNdQhallceR8IqPhC8Ck/img.png&quot; data-alt=&quot;Docker Swarm과 쿠버네티스 keyword 분석량&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b02tQs/btsOusBcRii/rWpNdQhallceR8IqPhC8Ck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb02tQs%2FbtsOusBcRii%2FrWpNdQhallceR8IqPhC8Ck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;537&quot; height=&quot;317&quot; data-origin-width=&quot;537&quot; data-origin-height=&quot;317&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Docker Swarm과 쿠버네티스 keyword 분석량&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Docker Swarm은 현재 활발히 개발되고 있는 오픈 소스 프로젝트는 아닙니다. 위 그래프에서 보이는 것처럼 컨테이너 오케스트레이션 툴에서의 사용량은 Kubernetes가 압도적입니다. 저도 이러한 사실을 알고 현재 회사에서 담당하고 있는 프로젝트에 Kubernetes를 적용하는 것을 건의하는 생각도 했었습니다. 하지만, 실제로 Docker Swarm 기반으로 시스템을 운영해보니 Docker Swarm이 Kubernetes에 비해 다음과 같은 이점들이 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;첫 번째로 Swarm 모드는 Docker Engine에 내장된 기능으로 별도 패키지나 외부 도구 설치 없이 Docker만 설치되어 있다면 &quot;&lt;b&gt;docker swarm init&lt;/b&gt;&quot; 명령어로 Swarm을 활성화하여 클러스터를 구성할 수 있습니다. 또한 Worker Node를 추가할 때도 &quot;&lt;b&gt;docker swarm join&lt;/b&gt;&quot; 명령어로 손쉽게 클러스터에 포함시킬 수 있습니다. 이는 Kubernetes의 kubeadm, kubectl, kubelet, CNI 설정 등 복잡한 초기 구성 과정에 비해 훨씬 빠르고 직관적입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;두 번째로 Docker Swarm은 익숙한 Docker Compose 형식으로 컨테이너를 배포할 수 있습니다. 즉, 기존에 사용하던 Docker Compose 파일을 그래도 활용할 수 있습니다. docker-compose.yml 파일을 &quot;&lt;b&gt;docker stack deploy&lt;/b&gt;&quot; 명령어로 Swarm 클러스터 환경에서 Service를 구성할 수 있습니다. 기존에 Docker를 사용하는 조직의 경우 적은 학습 비용으로도 클러스터 환경으로 자연스럽게 확장할 수 있다는 장점이 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로, Docker Swarm은 &lt;b&gt;네트워킹 전략&lt;/b&gt;, &lt;b&gt;서비스 운영 전략&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;배포 전략&lt;/b&gt; 측면에서 실무에 바로 적용할 수 있는 유용한 기능들을 기본으로 제공합니다. 먼저 &lt;b&gt;네트워킹 전략&lt;/b&gt; 측면에서 보면, Swarm은 별도의 설정 없이도 클러스터 내의 컨테이너들은 &lt;b&gt;서비스 이름을 통해 서로를 인식하고 통신&lt;/b&gt;할 수 있습니다.&amp;nbsp;&lt;b&gt;서비스 운영 전략&lt;/b&gt; 측면에서는, 동일한 Service에 속한 여러 컨테이너 간에 &lt;b&gt;로드밸런싱이 자동으로 적용&lt;/b&gt;되어 트래픽이 균등하게 분산됩니다.&amp;nbsp;마지막으로 &lt;b&gt;배포 전략&lt;/b&gt; 측면에서도 Swarm은 별도 구성 없이도 &lt;b&gt;Rolling Update&lt;/b&gt;, &lt;b&gt;헬스 체크 기반 재기동&lt;/b&gt;, &lt;b&gt;노드 라벨 기반 배치 전략&lt;/b&gt; 등을 지원하므로, 복잡한 배포 파이프라인 없이도 실무 환경에서 안정적으로 서비스를 운영할 수 있습니다. Kubernetes에서는 이와 같은 기능들을 활용하기 위해 ClusterIP 타입의 Service 객체, Ingress 설정, CNI 플러그인 구성, Deployment 오브젝트 세팅 등 복잡한 설정을 수반해야 하는 반면, Docker Swarm은 이러한 기능들을 &lt;b&gt;기본값으로 제공&lt;/b&gt;함으로써 보다 빠르고 단순하게 마이크로서비스 환경을 구성할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 블로그에서는 실무에서 시스템을 운영하면서 유용하다고 느낀 &lt;b&gt;Docker Swarm의 세 가지 핵심 전략&lt;/b&gt;&amp;mdash;&amp;nbsp;&lt;b&gt;서비스 운영 전략&lt;/b&gt;, &lt;b&gt;네트워킹 전략&lt;/b&gt; 그리고 &lt;b&gt;배포 전략&lt;/b&gt;&amp;mdash;에 대해 자세히 소개하고자 합니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Docker Swarm의 서비스 (Replicas, Restart Policy)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Docker Swarm에서의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;최소 배포 단위&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;는 Service입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이는 Kubernetes의 Pod와 유사한 개념으로 이해할 수 있습니다.&lt;span&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Docker Swarm은 개별 컨테이너를 직접 다루지 않고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Service라는 추상화된 객체를 통해 컨테이너를 관리&lt;/b&gt;합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1750257147938&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;web-service:
    image: nginx:alpine
    deploy:
      replicas: 2
      placement:
        constraints:
          - node.labels.zone == frontend
    ports:
      - &quot;8080:80&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Swarm에는 &lt;b&gt;기본 타입인 Replicated Service&lt;/b&gt;와 &lt;b&gt;Global Service&lt;/b&gt;라는 두 가지 서비스 타입이 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Replicated 타입의 Service는 사용자가 설정한 replicas 수만큼 컨테이너를 실행하며, Swarm은 이 수를 항상 유지하도록 관리합니다.&lt;br /&gt;예를 들어, 위의 예시에서는 &lt;b&gt;replicas: 2&lt;/b&gt;로 설정되어 있어 nginx 컨테이너가 총 2개 실행되며, Swarm은 이 컨테이너들이 항상 살아 있도록 클러스터 내에서 적절히 분산 배포하고 장애 시에는 자동으로 복구합니다. 보통 일반적인 애플리케이션을 Replicated 서비스 타입으로 배포합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;720&quot; data-start=&quot;556&quot; data-ke-size=&quot;size16&quot;&gt;또한 placement 설정을 통해 특정 조건을 만족하는 노드에만 컨테이너를 배포할 수도 있습니다. 예시에서는 &quot;node.labels.zone == frontend&quot; 라는 제약 조건을 통해, &lt;b&gt;zone 라벨이 frontend인 노드에만&lt;/b&gt; 컨테이너가 배포되도록 하는 설정입니다.&lt;/p&gt;
&lt;p data-end=&quot;720&quot; data-start=&quot;556&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;922&quot; data-start=&quot;722&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Global 타입을 설정하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;클러스터의 모든 노드에 정확히 하나의 컨테이너가 배포&lt;/b&gt;되며, 노드가 추가되거나 제거될 때도 자동으로 컨테이너 수가 조정됩니다. node-exporter나 cAdvisor처럼 &lt;b&gt;모든 노드에서 실행되어야 하는 모니터링용 애플리케이션&lt;/b&gt;의 경우에는 Global 타입의 Service를 사용하는 것이 적절합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1750647720194&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;web-service:
    image: nginx:alpine
    deploy:
      replicas: 2
      placement:
        constraints:
          - node.labels.zone == frontend
      
      restart_policy:
        condition: on-failure
        max_attempts: 3
        delays: 10s
        window: 30s
      
    ports:
      - &quot;8080:80&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Swarm에서 서비스를 배포할 때 restart_policy를 설정하면, 컨테이너의 상태에 따라 Swarm이 &lt;b&gt;컨테이너를 자동으로 재기동할지 여부&lt;/b&gt;를 제어할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;517&quot; data-start=&quot;315&quot; data-ke-size=&quot;size16&quot;&gt;condition: on-failure 옵션은 컨테이너가 &lt;b&gt;비정상 종료(exit code 1 이상)&lt;/b&gt; 했을 때만 재시작하도록 지정합니다.&lt;br /&gt;예를 들어, 배치 작업을 처리하는 컨테이너가 작업을 완료하고 &lt;b&gt;정상 종료(exit code 0)&lt;/b&gt; 하는 경우에는 Swarm이 컨테이너를 다시 시작하지 않습니다. 이를 통해 정상 종료된 컨테이너들의 불필요한 재시작을 방지할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;517&quot; data-start=&quot;315&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;630&quot; data-start=&quot;519&quot; data-ke-size=&quot;size16&quot;&gt;max_attempts: 3은 Swarm이 &lt;b&gt;최대 3회까지&lt;/b&gt; 재시작을 시도한다는 의미이며,delay: 10s는 각 재시작 시도 간에 &lt;b&gt;10초 간격을 두고&lt;/b&gt; 수행하겠다는 설정입니다.&lt;/p&gt;
&lt;p data-end=&quot;630&quot; data-start=&quot;519&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;728&quot; data-start=&quot;632&quot; data-ke-size=&quot;size16&quot;&gt;여기서 특히 중요한 개념이 &lt;b&gt;window&lt;/b&gt;입니다. window: 30s는 Swarm이 재시작 시도 횟수를 &lt;b&gt;30초라는 시간 범위 내에서 측정&lt;/b&gt;하겠다는 의미입니다. 예를 들어, 컨테이너가 일시적인 장애로 한두 번 실패한 뒤 &lt;b&gt;30초 이상 정상적으로 동작&lt;/b&gt;한다면, Swarm은 이를 &quot;정상 상태로 복구되었다&quot;고 판단하고 &lt;b&gt;재시작 시도 횟수를 0으로 초기화&lt;/b&gt;합니다. 이를 통해 일시적인 장애 이후에도 max_attempts에 걸려 재기동이 멈추는 일을 방지할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;728&quot; data-start=&quot;632&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;728&quot; data-start=&quot;632&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이 window라는 개념이 다소 낯설었지만, 이 기능을 통해 인프라의 안정성을 더 높여 결과적으로 &lt;b&gt;더 견고하고 자가 복구 가능한 서비스 환경&lt;/b&gt;을 구축할 수 있음을 알게 되었습니다. 현재 모든 서비스에 windows 옵션을 추가해서 Service를 운영하고 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Docker Swarm의 네트워킹 전략 (Service Discovery, Overlay 네트워크)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Swarm 환경에서 여러 Service들이 존재하며, Service가 관리하는 컨테이너들이 서로 통신해야 되는 상황이 생기기 마련입니다. 이때, 컨테이너 끼리 통신할 수 있는 수단이 필요합니다. Docker Swarm에서는 서비스에 속한 컨테이너들은 서비스명으로 도메인이름을 가지며, &lt;b&gt;클러스터 내부의 컨테이너들은 서비스명으로 통신&lt;/b&gt;할 수 있습니다. 그러면 어떻게, 서로 다른 서비스가 통신할 수 있는지 알아보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1750733398033&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;web-service:
 image: nginx:alpine
 deploy:
   replicas: 2
   placement:
     constraints:
       - node.labels.zone == frontend
   
   restart_policy:
     condition: on-failure
     max_attempts: 3
     delay: 10s
     window: 30s
   
 ports:
   - &quot;8080:80&quot;
 networks:
   - frontend-network

networks:
 frontend-network:
   driver: overlay
   attachable: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 서비스를 위의 예시처럼 &quot;frontend-network&quot;와 같은 &lt;b&gt;사용자 정의 오버레이 네트워크&lt;/b&gt;에 배포하면, 서비스들끼리는 동일한 오버레이 네트워크 상에서 &lt;b&gt;서로 통신할 수 있는 구조&lt;/b&gt;가 됩니다. 이때, 각 서비스는 추상화된 객체이기 때문에 &lt;b&gt;사용자 정의 네트워크 대역 내의 VIP(가상 IP)&lt;/b&gt;를 부여받게 됩니다. 또한, &lt;b&gt;사용자 정의 오버레이 네트워크에서는 서비스 이름을 통한 DNS 기반 서비스 디스커버리&lt;/b&gt;가 기본으로 제공됩니다. 오버레이 네트워크의 개념과 구현 원리에 대해서는 다음 블로그에서 좀 더 자세히 다룰 예정입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;5572&quot; data-origin-height=&quot;3252&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oFpv9/btsOQvwmHEh/lmoCvaFv4b3inL8eWlxVX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oFpv9/btsOQvwmHEh/lmoCvaFv4b3inL8eWlxVX1/img.png&quot; data-alt=&quot;컨테이너 통신 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oFpv9/btsOQvwmHEh/lmoCvaFv4b3inL8eWlxVX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoFpv9%2FbtsOQvwmHEh%2FlmoCvaFv4b3inL8eWlxVX1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;5572&quot; height=&quot;3252&quot; data-origin-width=&quot;5572&quot; data-origin-height=&quot;3252&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;컨테이너 통신 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구체적인 동작 원리는 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #f0eee6; color: #141413; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;컨테이너 A에서, 컨테이너 B가 속한 &lt;b&gt;서비스 이름(예: api-service)&lt;/b&gt;으로 요청을 보냅니다.&lt;/li&gt;
&lt;li&gt;컨테이너 A 내부의 &lt;b&gt;Docker 내장 DNS 서버&lt;/b&gt;(127.0.0.11:53)가, 목적지 서비스명을&amp;nbsp;&lt;b&gt;VIP(예: 172.20.0.166)로&lt;/b&gt;&amp;nbsp;변환합니다.&lt;/li&gt;
&lt;li&gt;Swarm의 로드밸런서는 이 VIP에 연결된 실제 컨테이너들의 IP 정보를 알고 있습니다.&lt;/li&gt;
&lt;li&gt;Swarm은 요청을 VIP를 통해 수신한 뒤, 내부적으로 &lt;b&gt;해당 서비스에 속한 실제 컨테이너 중 하나로 트래픽을 전달&lt;/b&gt;합니다.&lt;br /&gt;(여러 컨테이너가 존재할 경우, 로드밸런싱이 적용됩니다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 컨테이너는 Docker의 내장 DNS 서버(127.0.0.11)를 통해, 같은 오버레이 네트워크 내에 있는 다른 서비스의 이름을 IP로 해석할 수 있습니다. 또한, Swarm Manager는 각 서비스의 VIP와 실제 컨테이너 IP 간의 매핑 정보를 유지합니다. 서비스명은 항상 고정된 VIP로 해석되며, Swarm의 내장 로드밸런서가 해당 VIP로 전달된 트래픽을 적절한 컨테이너로 라우팅합니다. 추가적으로&amp;nbsp;Swarm은 &lt;b&gt;헬스체크 상태를 기반으로&lt;/b&gt;, 비정상 상태의 컨테이너에는 트래픽을 전달하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 구조는 &lt;b&gt;Docker Swarm의 서비스 메시(Service Mesh) 구조의 핵심&lt;/b&gt;입니다. 컨테이너 A는 실제 컨테이너 B의 IP를 몰라도 되고, 컨테이너 B가 종료되더라도 트래픽이 &lt;b&gt;다른 replica로 자동 라우팅&lt;/b&gt;됩니다. 또한, 서비스가 스케일링되면 새로운 컨테이너가 로드밸런싱 풀에 자동으로 추가되므로, &lt;b&gt;개발자는 서비스명만 알고 있으면 되고&lt;/b&gt;, &lt;b&gt;복잡한 인프라 구성과 네트워크 처리 로직은 Swarm이 추상화하여 자동으로 처리&lt;/b&gt;해줍니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Docker Swarm의 서비스 배포 전략 (Rolling Update, Rollback)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 버전의 서비스로 업데이트할 때, Docker Swarm은 기본적으로 롤링 업데이트로 서비스를 배포 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 설정 옵션은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt;parallelism&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 동시에 업데이트할 컨테이너 수를 지정합니다. 기본값은 1이며, 이는 한 번에 하나씩 업데이트함을 의미합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;delay&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 각 업데이트 그룹 간의 대기 시간을 설정합니다. 예를 들어 10s는 10초 대기를 의미합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;failure_action&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 업데이트 실패 시 취할 행동을 정의합니다. pause(일시정지), continue(계속), rollback(롤백) 중 선택할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;monitor&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 업데이트된 태스크가 성공적으로 실행 중인지 모니터링할 시간을 설정합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;max_failure_ratio&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 실패를 허용할 최대 비율을 설정합니다. 0.1은 10% 실패까지 허용함을 의미합니다. 기본값은 0입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;order&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 업데이트 순서를 지정합니다. stop-first(기존 컨테이너 종료 후 새 컨테이너 시작) 또는 start-first(새 컨테이너 시작 후 기존 컨테이너 종료) 중 선택합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1750819685023&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;web-service:
 image: nginx:alpine
 deploy:
   replicas: 10
   placement:
     constraints:
       - node.labels.zone == frontend
   
   restart_policy:
     condition: on-failure
     max_attempts: 3
     delay: 10s
     window: 30s
   
   update_config:
     parallelism: 1
     delay: 15s
     failure_action: rollback
     monitor: 30s
     max_failure_ratio: 0.2
     order: start-first
   
   rollback_config:
     parallelism: 1
     delay: 10s
     failure_action: pause
     monitor: 20s
     max_failure_ratio: 0.1
     order: stop-first
     
 ports:
   - &quot;8080:80&quot;
 networks:
   - frontend-network

networks:
 frontend-network:
   driver: overlay
   attachable: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;롤링 업데이트를 수행할 때는 한 번에 하나의 컨테이너만 업데이트하며&lt;b&gt;(parallelism: 1)&lt;/b&gt;, 새 버전의 컨테이너를 먼저 시작한 후 기존 컨테이너를 종료합니다&lt;b&gt;(order: start-first)&lt;/b&gt;. 컨테이너 간에는 15초의 간격을 두고 순차적으로 업데이트가 진행됩니다&lt;b&gt;(delay: 15s)&lt;/b&gt;. 이를 통해 새 버전의 컨테이너가 정상적으로 기동되는지 확인할 시간을 확보하여, 전체 서비스의 가용성을 유지할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;758&quot; data-start=&quot;480&quot; data-ke-size=&quot;size16&quot;&gt;업데이트된 컨테이너는 30초 동안 모니터링되며&lt;b&gt;(monitor: 30s)&lt;/b&gt;, 이 기간 동안 비정상적인 동작이 감지되면 실패로 간주됩니다. 만약 업데이트 중 실패 비율이 20%를 초과할 경우&lt;b&gt;(max_failure_ratio: 0.2)&lt;/b&gt;, 서비스의 모든 컨테이너를 이전 버전으로 롤백합니다&lt;b&gt;(failure_action: rollback)&lt;/b&gt;. 예를 들어 10개의 컨테이너 중 3개 이상이 실패하면 롤백이 수행되지만, 1~2개 정도만 실패한 경우에는 롤백되지 않고 그 컨테이너만 기존 버전(V1)으로 남게 됩니다.&lt;/p&gt;
&lt;p data-end=&quot;758&quot; data-start=&quot;480&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1134&quot; data-start=&quot;760&quot; data-ke-size=&quot;size16&quot;&gt;롤백 시에도 한 번에 하나의 컨테이너만 되돌리며&lt;b&gt;(rollback_config.parallelism: 1)&lt;/b&gt;, 10초 간격으로 순차적으로 롤백이 이루어집니다&lt;b&gt;(rollback_config.delay: 10s)&lt;/b&gt;. 이때는 기존 컨테이너를 먼저 중지한 후 이전 버전 컨테이너를 기동합니다&lt;b&gt;(rollback_config.order: stop-first)&lt;/b&gt;. 롤백 중에도 컨테이너는 20초 동안 모니터링되며&lt;b&gt;(rollback_config.monitor: 20s)&lt;/b&gt;, 실패 비율이 10%를 넘는 경우 롤백 작업은 일시 중지됩니다&lt;b&gt;(rollback_config.failure_action: pause, rollback_config.max_failure_ratio: 0.1)&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 설정을 통해 서비스 중단 없이 안전하고 점진적인 업데이트를 수행할 수 있으며, 문제 발생 시 자동으로 이전 버전으로 롤백하는 등의 보호 메커니즘을 구현할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;918&quot; data-origin-height=&quot;440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckHvSY/btsOCgUWJry/7rWTre7B5CdAKFwC17i15K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckHvSY/btsOCgUWJry/7rWTre7B5CdAKFwC17i15K/img.png&quot; data-alt=&quot;롤링 업데이트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckHvSY/btsOCgUWJry/7rWTre7B5CdAKFwC17i15K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckHvSY%2FbtsOCgUWJry%2F7rWTre7B5CdAKFwC17i15K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;706&quot; height=&quot;338&quot; data-origin-width=&quot;918&quot; data-origin-height=&quot;440&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;롤링 업데이트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Swarm이 기본적으로 제공하는 롤링 업데이트를 사용하면 배포하는 도중에 동시에 두 개 버전의 컨테이너가 가동된다는 단점이 있습니다. 필요에 따라서 모든 배포가 완료되면 트래픽 흐름을 바꿔주는 Blue-Green 배포나, 테스트 목적으로 트래픽을 점진적으로 늘려주는 Canary 배포를 사용해야 하는 일이 있을 수 있습니다. Docker Swarm이 Kubernetes와 다르게 이러한 고급 배포는 지원하지 않습니다. 이러한 고급 배포 설정을 해야하는 경우, Nginx나 Traefik과 같은 외부 로드밸런서를 도입하여 트래픽 흐름을 조절해줘야 하는 한계가 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;마무리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서 Docker Swarm 기반으로 시스템을 운영한다고 했을 때, 처음에는 &amp;ldquo;지금 트렌드에 맞지 않는 기술을 사용하는 건 아닐까?&amp;rdquo; 하는 의문이 들기도 했습니다. 하지만, 직접 Docker Swarm을 활용해 컨테이너들을 오케스트레이션해보면서, 인프라 관점에서 많은 것을 배울 수 있었습니다.&lt;/p&gt;
&lt;p data-end=&quot;465&quot; data-start=&quot;293&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 다중 컨테이너 환경에서 &lt;b&gt;컨테이너를 어떤 노드에 배치하는 것이 가장 효율적일지 고민하게 되었고&lt;/b&gt;, 고정된 노드에 컨테이너가 상주하지 않기 때문에 &lt;b&gt;노드 위치와 무관하게 데이터를 영구 저장하고 공유할 수 있는 볼륨 스토리지는 어떻게 구성해야 할까&lt;/b&gt;에 대한 고민도 자연스럽게 따라왔습니다.&lt;/p&gt;
&lt;p data-end=&quot;618&quot; data-start=&quot;467&quot; data-ke-size=&quot;size16&quot;&gt;결국 이러한 경험 덕분에 인프라에 대한 이해도가 높아졌고, 이후 Kubernetes를 학습할 때도 &quot;이 문제를 Kubernetes는 어떻게 풀고 있을까?&quot;라는 관점으로 접근할 수 있었습니다. 덕분에 단순한 기능 학습을 넘어, 더 깊이 있는 학습 경험을 할 수 있었습니다.&lt;/p&gt;</description>
      <category>Infra</category>
      <category>Container Orchestration</category>
      <category>Docker</category>
      <category>docker swarm</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/10</guid>
      <comments>https://immersive.tistory.com/10#entry10comment</comments>
      <pubDate>Thu, 26 Jun 2025 12:17:29 +0900</pubDate>
    </item>
    <item>
      <title>[Infra] 고가용성 Redis HA 클러스터 구축 (Redis Sentinel)</title>
      <link>https://immersive.tistory.com/9</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;930&quot; data-origin-height=&quot;310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9FgSe/btsNBagI6Wx/BfyDKCWalHvnJcaCkSsxhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9FgSe/btsNBagI6Wx/BfyDKCWalHvnJcaCkSsxhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9FgSe/btsNBagI6Wx/BfyDKCWalHvnJcaCkSsxhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9FgSe%2FbtsNBagI6Wx%2FBfyDKCWalHvnJcaCkSsxhk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;659&quot; height=&quot;220&quot; data-origin-width=&quot;930&quot; data-origin-height=&quot;310&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis HA란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis HA(High Availability)란 시스템에 장애가 발생하는 상황에서도 Redis 서비스의 지속적인 가용성을 보장하는 구성입니다. Redis는 일반적인 DBMS와 다르게 디스크가 아닌 메모리에 데이터를 저장하기 때문에 조회 성능에서 매우 빠른 성능을 보이지만, 메모리에 데이터를 저장되기에 데이터가 휘발될 수 있는 단점이 있습니다. 이 단점을 보완하고자 Redis HA는 다수의 Redis 서버들을 배포하여 장애 상황을 극복합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사내에서 빠른 데이터 송수신 구조와 안정성을 확보한 데이터 처리를 목표로 하는 요구 사항이 있었습니다. 빠른 데이터 송수신을 위해 Redis의 Pub/Sub 기능을 사용하고 안정성을 위해서 Redis HA를 구성했습니다. 이번 블로그에서는 Redis HA를 도입한 과정에 대해서 설명하겠습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Redis HA의 구성 요소&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Redis HA는 크게 다음 3가지 구성 요소로 구성됩니다:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Replication&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Failover&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Persistence&amp;nbsp;&lt;/b&gt;이 3가지 요소로 Redis HA는 고가용성을 확보합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 복제 (Replication)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Master-Slave 구조&lt;/b&gt;: 하나의 Master 노드와 하나 이상의 Slave(Replica) 노드로 구성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 동기화&lt;/b&gt;: Master에서 발생한 쓰기 작업이 모든 Slave에 실시간으로 비동기적으로 복제됨&lt;/li&gt;
&lt;li&gt;&lt;b&gt;읽기 부하 분산&lt;/b&gt;: Slave 노드들을 통해 읽기 작업을 분산 처리하여 성능 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 자동 장애 복구 (Failover): 장애 복구를 지원하는 매케니즘&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Sentinel&lt;/b&gt;: Redis의 장애 감지 및 자동 복구 시스템
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Master 노드의 상태를 지속적으로 모니터링&lt;/li&gt;
&lt;li&gt;Master 장애 감지 시 자동으로 Slave 중 하나를 새로운 Master로 승격&lt;/li&gt;
&lt;li&gt;클라이언트에게 새로운 Master 정보 제공&lt;/li&gt;
&lt;li&gt;보통 최소 3개의 Sentinel 인스턴스를 권장 -&amp;gt; 쿼럼 기반 의사결정을 하기 위함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Redis Cluster&lt;/b&gt;: 샤딩과 HA를 동시에 제공하는 솔루션
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터를 여러 노드에 분산 저장(샤딩)&lt;/li&gt;
&lt;li&gt;각 샤드마다 Master-Slave 구조로 복제 제공&lt;/li&gt;
&lt;li&gt;자체적인 장애 감지 및 자동 복구 메커니즘 내장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 데이터 지속성(Persistence)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RDB&lt;/b&gt;: 특정 시점의 스냅샷을 저장 (더 빠르지만 일부 데이터 손실 가능)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AOF&lt;/b&gt;: 모든 쓰기 명령을 로그에 기록 (더 안전하지만 파일 크기가 크고 느림)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;혼합 모드&lt;/b&gt;: RDB와 AOF를 함께 사용하여 장점 결합&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Redis HA 작동 원리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis HA를 구성하는 방식은 크게 두 가지가 있습니다: &lt;b&gt;Redis Sentinel&lt;/b&gt;, &lt;b&gt;Redis Cluster&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 사용한 방식은 Redis Sentinel을 이용한 방식이기에 Redis Sentinel을 기준으로 설명드리겠습니다. &lt;b&gt;Redis Sentinel(보초)&lt;/b&gt;은 &lt;b&gt;마스터 노드와 슬레이브 노드를 실시간으로 모니터링&lt;/b&gt;하며 &lt;b&gt;장애 상황 발생 시 Slave 노드를 실시간으로 모니터링하며, &lt;/b&gt;슬레이브 노드를 마스터로 승격시키는 &lt;b&gt;자동 failover 작업&lt;/b&gt;을 수행하는 역할을 합니다. 따라서, 아래와 같이 애플리케이션은 마스터 노드를 직접 바라보는 것이 아닌, Sentinel을 통해 Redis 클러스터를 바라봅니다. 애플리케이션은 Sentinel을 통해 마스터 노드의 IP와 Port를 획득해 마스터 노드를 접근하는 형태입니다. 즉, &lt;b&gt;Sentinel이 마스터 노드로 접근하기 위한 일종의 프록시 역할&lt;/b&gt;을 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2534&quot; data-origin-height=&quot;1264&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvR6eO/btsNF0jRy9x/gMyZ2YPcJ1tKcqvXBKWJcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvR6eO/btsNF0jRy9x/gMyZ2YPcJ1tKcqvXBKWJcK/img.png&quot; data-alt=&quot;Redis Sentinel&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvR6eO/btsNF0jRy9x/gMyZ2YPcJ1tKcqvXBKWJcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcvR6eO%2FbtsNF0jRy9x%2FgMyZ2YPcJ1tKcqvXBKWJcK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2534&quot; height=&quot;1264&quot; data-origin-width=&quot;2534&quot; data-origin-height=&quot;1264&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis Sentinel&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Sentinel을 통한 failover 처리단계&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Sentinel 중 하나가 Master 노드의 장애를 감지&lt;/li&gt;
&lt;li&gt;다른 Sentinel들에게 failover 진행 여부를 투표&lt;/li&gt;
&lt;li&gt;정족수 이상의 Sentinel이 failover에 동의한 경우, failover를 진행 -&amp;gt; 3개 이상 홀수 개의 Sentinel이 필요
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Master 노드에 대한 연결을 끊음&lt;/li&gt;
&lt;li&gt;Slave 중 하나를 Master로 승격&lt;/li&gt;
&lt;li&gt;승격된 Slave 노드 외의 다른 모든 Slave 노드를 새로 승격된 Master 노드에 연결&lt;/li&gt;
&lt;li&gt;애플리케이션의 요청에 대해 새로 승격된 마스터 노드의 IP, PORT 를 반환&lt;/li&gt;
&lt;li&gt;이후 (전)Master 노드가 복구가 되면 Slave로 Redis HA에 접속함&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 블로그에서 데이터베이스를 다중화했을 때, 트래픽은 더 받을 수 있지만, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;하나의 데이터베이스 장애 상황에 대해서 failover 기능이 없어서 &lt;/span&gt;오히려 장애 상황에 더 취약한 구조가 아닌가에 대한 고민이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, Redis HA에서는 다중화된 Redis 서버에 대한 failover를 기본적으로 지원하기 때문에, 하나의 노드에 장애가 발생하더라도 자동으로 마스터를 전환하고 클라이언트가 새로운 마스터에 연결할 수 있도록 해줍니다. 따라서 별도의 failover 로직을 애플리케이션 차원에서 구현하지 않아도 고가용성을 확보할 수 있다는 점에서, Redis는 장애 상황에 더 유연하게 대응할 수 있는 구조를 제공합니다. Failover가 진행되는 실제 과정은 아래에서 살펴보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Redis HA 도입기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 Redis HA의 전체적인 구성은 다음과 같습니다. 일반적으로 하나의 노드에 장애가 발생할 경우를 대비해, 최소 3개의 노드를 구성하고 각 노드에 Redis Server 1개와 Redis Sentinel 1개씩을 배치하는 것이 권장됩니다. 하지만 현재는 사내에서 단일 노드만 제공받을 수 있었기 때문에, Docker Swarm 클러스터가 구축된 하나의 노드 위에 Redis HA를 다음과 같은 방식으로 구성해보았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2876&quot; data-origin-height=&quot;1874&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfgC83/btsNED4uzul/viKbUWXKwVNfVKWCnkZym1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfgC83/btsNED4uzul/viKbUWXKwVNfVKWCnkZym1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfgC83/btsNED4uzul/viKbUWXKwVNfVKWCnkZym1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfgC83%2FbtsNED4uzul%2FviKbUWXKwVNfVKWCnkZym1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2876&quot; height=&quot;1874&quot; data-origin-width=&quot;2876&quot; data-origin-height=&quot;1874&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis HA를 구축하는 흐름은 다음과 같습니다. 이중에서 3번, 6번, 7번 과정에 대해서 설명드리겠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;Redis Master Server 6379 포트에 배포&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;Redis Slave Server 2개를 각각 6380 포트와 6381 포트에 배포함&lt;/li&gt;
&lt;li&gt;Redis Master &amp;lt;-&amp;gt; Redis Slave 간의 복제 여부를 확인&lt;/li&gt;
&lt;li&gt;Redis Sentinel Server 3개를 각각 26379, 26380, 26381 포트에 배포&lt;/li&gt;
&lt;li&gt;Redis Sentinel Server들끼리 서로를 잘 인식하는지 확인&lt;/li&gt;
&lt;li&gt;Redis Sentinel이 Redis Server를 잘 모니터링하는지 확인&lt;/li&gt;
&lt;li&gt;Failover가 잘 되는지 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;3. Redis Master &amp;lt;-&amp;gt; Redis Slave 간의 복제 여부를 확인&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3516&quot; data-origin-height=&quot;970&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dw4JEU/btsNEb1sYhw/hVcD5HbJsgpZiLPBYJtpP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dw4JEU/btsNEb1sYhw/hVcD5HbJsgpZiLPBYJtpP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dw4JEU/btsNEb1sYhw/hVcD5HbJsgpZiLPBYJtpP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdw4JEU%2FbtsNEb1sYhw%2FhVcD5HbJsgpZiLPBYJtpP0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3516&quot; height=&quot;970&quot; data-origin-width=&quot;3516&quot; data-origin-height=&quot;970&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선, Sentinels를 구성하기에 앞서 Redis Master 1개와 Redis Slave 2개를 구성해보았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3332&quot; data-origin-height=&quot;198&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/00GWd/btsNF9vXQef/ZFK2UBmZDWeUgHQzfSkeM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/00GWd/btsNF9vXQef/ZFK2UBmZDWeUgHQzfSkeM1/img.png&quot; data-alt=&quot;Redis Slave에 6379 포트가 추가로 열림&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/00GWd/btsNF9vXQef/ZFK2UBmZDWeUgHQzfSkeM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F00GWd%2FbtsNF9vXQef%2FZFK2UBmZDWeUgHQzfSkeM1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3332&quot; height=&quot;198&quot; data-origin-width=&quot;3332&quot; data-origin-height=&quot;198&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis Slave에 6379 포트가 추가로 열림&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;위에서 배포된 Redis Server들을 확인해볼 수 있습니다. 제가 Redis Server들을 배포하면서 들었던 의문점은 Redis Slave 배포 스크립트에, 6380 포트와 6381 포트만 열어줬지만, &lt;b&gt;6379/tcp가 추가적으로 열려 있다는 사실&lt;/b&gt;이었습니다. 그 이유에 대해서 찾아본 결과 다음과 같았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Redis Master-Slave 복제 환경에서는 모든 Redis 인스턴스(Master와 Slave들)가 컨테이너 내부에서 기본 포트인 6379를 사용합니다.&lt;br /&gt;컨테이너들은 Overlay 네트워크 상에서 각각 고유한 IP 주소(예: 172.20.20.89, 172.20.20.93)를 가지기 때문에, 동일한 포트를 사용하더라도 충돌이 발생하지 않습니다. 따라서, Slave들은 각자의 IP 주소를 통해 6379 포트로 Master에 연결되어 복제를 수행합니다.&lt;br /&gt;&lt;br /&gt;위에서 명시된 6380 포트와 6381 포트는 외부에서 각 Redis 인스턴스에 접근할 때 사용되는 포트 매핑입니다. Master는 6379 포트를 그대로 사용하고, Slave 인스턴스들은 각각 6380과 6381 포트로 매핑되어 외부에서 독립적으로 접근할 수 있습니다. 이 포트 매핑은 Docker Swarm의 포트 퍼블리싱(port publishing) 기능을 통해 설정되며, 내부 통신은 모두 6379 포트를 기반으로 하고 외부 통신은 서로 다른 포트를 통해 이루어지는 구조입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1943&quot; data-origin-height=&quot;450&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/T8TzH/btsNF7dS5SD/nJDSpBBQkQMrWw3juOgwwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/T8TzH/btsNF7dS5SD/nJDSpBBQkQMrWw3juOgwwk/img.png&quot; data-alt=&quot;Redis Master 정보 조회&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/T8TzH/btsNF7dS5SD/nJDSpBBQkQMrWw3juOgwwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FT8TzH%2FbtsNF7dS5SD%2FnJDSpBBQkQMrWw3juOgwwk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;648&quot; height=&quot;150&quot; data-origin-width=&quot;1943&quot; data-origin-height=&quot;450&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis Master 정보 조회&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;다음과 같이 Redis Master를 조회했을 때, Redis Slave가 잘 연결되어 있으면 6379 포트를 통해 복제가 이뤄지고 있음을 볼 수 있습니다. &lt;s&gt;추후에 이 설정 때문에 Redis Sentinel의 모니터링 과정에서 문제가 생깁니다.&lt;/s&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1762&quot; data-origin-height=&quot;326&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTN1XM/btsNGgazTvI/oa0KuUwTK321hjiBY3gik0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTN1XM/btsNGgazTvI/oa0KuUwTK321hjiBY3gik0/img.png&quot; data-alt=&quot;Redis Master와 Slave 복제 확인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTN1XM/btsNGgazTvI/oa0KuUwTK321hjiBY3gik0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTN1XM%2FbtsNGgazTvI%2Foa0KuUwTK321hjiBY3gik0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;666&quot; height=&quot;123&quot; data-origin-width=&quot;1762&quot; data-origin-height=&quot;326&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis Master와 Slave 복제 확인&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;위에서 보이는 것처럼 Master에 입력한 값 &lt;b&gt;(key: test_key, value: success)&lt;/b&gt; 이 Slave에도 잘 나타나 복제가 성공적으로 이뤄지고 있는 것을 볼 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;6. Redis Sentinel이 Redis Server를 잘 모니터링하는지 확인&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3004&quot; data-origin-height=&quot;1499&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0g1xI/btsNGUrk58U/gROZ10KareL0oxUdZAyrC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0g1xI/btsNGUrk58U/gROZ10KareL0oxUdZAyrC0/img.png&quot; data-alt=&quot;Redis Sentinel 모니터링 동작 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0g1xI/btsNGUrk58U/gROZ10KareL0oxUdZAyrC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0g1xI%2FbtsNGUrk58U%2FgROZ10KareL0oxUdZAyrC0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3004&quot; height=&quot;1499&quot; data-origin-width=&quot;3004&quot; data-origin-height=&quot;1499&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis Sentinel 모니터링 동작 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis Master와 Redis Slave가 성공적으로 배포된 후에 Redis Sentinel 3개를 각각 26379 포트, 26380 포트, 26381 포트에 각각 배포했습니다. 하지만, 아래와 같이 Redis Sentinel이 Redis Master는 잘 모니터링하고 있지만, Redis Slave와 연결이 되고 1분 후에 &lt;b&gt;Redis Slave와 연결이 끊기는 것(+sdown)&lt;/b&gt;을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2136&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0LFqX/btsNGUkxynJ/UkXTlDwJdTUwFcBVpr0KeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0LFqX/btsNGUkxynJ/UkXTlDwJdTUwFcBVpr0KeK/img.png&quot; data-alt=&quot;Redis Sentinel 로그 확인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0LFqX/btsNGUkxynJ/UkXTlDwJdTUwFcBVpr0KeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0LFqX%2FbtsNGUkxynJ%2FUkXTlDwJdTUwFcBVpr0KeK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;722&quot; height=&quot;142&quot; data-origin-width=&quot;2136&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis Sentinel 로그 확인&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 &lt;b&gt;문제의 원인은 Sentinel이 Slave 정보를 직접 확인하는 것이 아니라 Master를 통해 가져오기 때문&lt;/b&gt;입니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1943&quot; data-origin-height=&quot;450&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rXYPY/btsNGwqXeop/qcFUgNPkrxU8kpV8MPRqZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rXYPY/btsNGwqXeop/qcFUgNPkrxU8kpV8MPRqZ1/img.png&quot; data-alt=&quot;Master에게 보고된 Slave의 정보&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rXYPY/btsNGwqXeop/qcFUgNPkrxU8kpV8MPRqZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrXYPY%2FbtsNGwqXeop%2FqcFUgNPkrxU8kpV8MPRqZ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;713&quot; height=&quot;165&quot; data-origin-width=&quot;1943&quot; data-origin-height=&quot;450&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Master에게 보고된 Slave의 정보&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 설정에서는 Redis Slave 서버에 별도의 포트 설정을 하지 않았기 때문에, &lt;b&gt;Master는 Slave들이 기본 포트인 6379로 운영되고 있다고 인식&lt;/b&gt;합니다. 따라서, &lt;b&gt;Sentinel도 Slave 서버들이 172.20.20.127:6379, 172.20.20.129:6379에 있다고 잘못 판단&lt;/b&gt;하게 됩니다. 하지만, Redis Slave가 실제로는 각각 6380, 6381 포트를 사용해 외부와 통신하고 있었기 때문에, Sentinel이 올바르게 Slave 상태를 확인하지 못하고 다운된 것으로 판단한 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;br /&gt;이 문제를 해결하기 위해, Slave 인스턴스가 Master에게 자신이 사용하는 외부 통신 포트를 명시적으로 알리도록 설정을 추가했습니다. 각각의 Docker Stack 파일(docker-stack.server2.yml, docker-stack.server3.yml)에 다음과 같은 설정을 넣었습니다:&lt;br /&gt;&lt;br /&gt;REDIS_EXTRA_FLAGS=--port 6380 --maxmemory 200mb --slave-announce-port 6380 &lt;br /&gt;REDIS_EXTRA_FLAGS=--port 6381 --maxmemory 200mb --slave-announce-port 6381&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1406&quot; data-origin-height=&quot;296&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dlxsyR/btsNGL2d35e/yY26A0MdRvU7smHiOwjNH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dlxsyR/btsNGL2d35e/yY26A0MdRvU7smHiOwjNH1/img.png&quot; data-alt=&quot;Master에게 보고된 Slave의 정보&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dlxsyR/btsNGL2d35e/yY26A0MdRvU7smHiOwjNH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdlxsyR%2FbtsNGL2d35e%2FyY26A0MdRvU7smHiOwjNH1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;675&quot; height=&quot;142&quot; data-origin-width=&quot;1406&quot; data-origin-height=&quot;296&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Master에게 보고된 Slave의 정보&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이를 통해 Slave가 Master에게 정확한 포트 정보를 전달할 수 있게 되었고, Sentinel도 올바르게 Slave를 모니터링할 수 있게 되었습니다. 저는 Redis Sentinel도 Redis Slave와 내부 포트인 6379 포트를 사용하여 통신한다고 생각했지만, 실제로는 외부 포트 매핑을 통해 통신한다는 사실을 알게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;lt;7. Failover가 잘 되는지 확인&amp;gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis HA를 Sentinels를 사용하여 구성한 후에 실제로 Redis Sentinel이 장애 상황을 감지하고 Slave를 Master로 승격시키는 Failover이 동작하는지 확인하기 위해 Redis Master가 실행되고 있는 컨테이너를 다운시켰습니다. Master를 다운시킨 후에 확인한 Sentinel들의 로그는 다음과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2386&quot; data-origin-height=&quot;238&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cm20Xz/btsNHRNUOND/TLGXdtMGigk2OUr1la3YT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cm20Xz/btsNHRNUOND/TLGXdtMGigk2OUr1la3YT0/img.png&quot; data-alt=&quot;Sentinel1의 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cm20Xz/btsNHRNUOND/TLGXdtMGigk2OUr1la3YT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcm20Xz%2FbtsNHRNUOND%2FTLGXdtMGigk2OUr1la3YT0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2386&quot; height=&quot;238&quot; data-origin-width=&quot;2386&quot; data-origin-height=&quot;238&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Sentinel1의 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Sentinel 1이 마스터(172.20.20.124:6379)가 응답하지 않는다고 판단해 +sdown(subjectively down) 상태로 전환&lt;/li&gt;
&lt;li&gt;Sentinel들이 새로운 에포크(epoch)를 시작 -&amp;gt; 이는 Failover 프로세스가 시작되었음을 나타냄&lt;/li&gt;
&lt;li&gt;Sentinel 1이 리더 선출을 위해 특정 Sentinel에게 투표 -&amp;gt; Failover 과정에서 리더 역할을 하는 Sentinel 선정&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;3개 Sentinel 중 2개 이상(quorum=2)이 마스터 다운에 동의해 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;+odown&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;(objectively down) 상태로 전환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;마스터가 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.124:6379&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;에서 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.2:6380&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;(기존 슬레이브)로 전환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;기존 슬레이브(&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.6:6381&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;)가 새 마스터(&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.2:6380&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;)의 슬레이브로 연결.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2368&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ciEBGy/btsNFZGTP8q/Rt12VokQr3I46Efv6cOwck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ciEBGy/btsNFZGTP8q/Rt12VokQr3I46Efv6cOwck/img.png&quot; data-alt=&quot;Sentinel2의 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ciEBGy/btsNFZGTP8q/Rt12VokQr3I46Efv6cOwck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FciEBGy%2FbtsNFZGTP8q%2FRt12VokQr3I46Efv6cOwck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2368&quot; height=&quot;270&quot; data-origin-width=&quot;2368&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Sentinel2의 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Sentinel 2가 마스터(172.20.20.124:6379)가 응답하지 않는다고 판단해 +sdown(subjectively down) 상태로 전환&lt;/li&gt;
&lt;li&gt;Sentinel 2가 리더 선출을 위해 특정 Sentinel에게 투표&lt;/li&gt;
&lt;li&gt;&lt;span&gt;마스터가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;172.20.20.124:6379&lt;/span&gt;&lt;span&gt;에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;172.20.20.2:6380&lt;/span&gt;&lt;span&gt;(기존 슬레이브)로 전환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;기존 슬레이브(&lt;/span&gt;&lt;span&gt;172.20.20.6:6381&lt;/span&gt;&lt;span&gt;)가 새 마스터(&lt;/span&gt;&lt;span&gt;172.20.20.2:6380&lt;/span&gt;&lt;span&gt;)의 슬레이브로 연결.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2174&quot; data-origin-height=&quot;692&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blA6Yu/btsNHatCrrE/T2gpGKiQ4X2N2eIIR9WZo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blA6Yu/btsNHatCrrE/T2gpGKiQ4X2N2eIIR9WZo0/img.png&quot; data-alt=&quot;Sentinel3의 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blA6Yu/btsNHatCrrE/T2gpGKiQ4X2N2eIIR9WZo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblA6Yu%2FbtsNHatCrrE%2FT2gpGKiQ4X2N2eIIR9WZo0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2174&quot; height=&quot;692&quot; data-origin-width=&quot;2174&quot; data-origin-height=&quot;692&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Sentinel3의 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Sentinel 3이 마스터(&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.124:6379&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;)가 응답하지 않는다고 판단해 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;+sdown&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;(subjectively down) 상태로 전환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;2개 이상의 Sentinel이 마스터 다운에 동의해 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;+odown&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;(objectively down) 상태로 전환. &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;2/2&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;는 quorum(2)을 충족했음을 나타냄&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Sentinel들이 새로운 에포크(epoch)를 시작. Failover 프로세스가 본격적으로 진행됨&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Sentinel 3이 Failover를 시도 시작.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Sentinel 3&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;이 자신을 리더로 투표. -&amp;gt; &lt;/span&gt;Sentinel 3이 리더로 선출됨(&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;+elected-leader&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;).&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;리더(Sentinel 3)가 새 마스터로 승격할 슬레이브를 선택 -&amp;gt; &lt;/span&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.2:6380&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 선택&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;SLAVEOF NO ONE&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 명령을 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.2:6380&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;에 전송해 마스터로 승격 요청.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;나머지 슬레이브(&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.6:6381&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;)를 새 마스터(&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.2:6380&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;)에 연결하도록 재구성.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Failover 프로세스 종료. 마스터가 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.124:6379&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;에서 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;172.20.20.2:6380&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;으로 전환.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1634&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTThb8/btsNGgIo6GC/4PDwue9swv8Y8yiaQsiZGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTThb8/btsNGgIo6GC/4PDwue9swv8Y8yiaQsiZGk/img.png&quot; data-alt=&quot;Failover 진행 후 Master 상태&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTThb8/btsNGgIo6GC/4PDwue9swv8Y8yiaQsiZGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTThb8%2FbtsNGgIo6GC%2F4PDwue9swv8Y8yiaQsiZGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;767&quot; height=&quot;157&quot; data-origin-width=&quot;1634&quot; data-origin-height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Failover 진행 후 Master 상태&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Failover 진행 후에 6380 포트에서 실행 중인 Slave가 Master로 승격되었음을 확인 할 수 있습니다. 또한, 이전에 Master인 6379 포트에서 실행 중이던 Redis 컨테이너를 재기동 시키니 Slave로 다시 접속되었음을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Redis HA 개선점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 하나의 노드에서 모든 Redis 컨테이너들이 실행되고 있습니다. 이는 컨테이너 실패 상황에 대해서 Failover가 실행될 수 있으나, 노드가 실패한 경우에는 Redis HA 전체가 다운되는 Single Point of Failure가 될 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3004&quot; data-origin-height=&quot;1499&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckHZXR/btsNHtT01zN/ofeNSHnz07kZh4hKN3Ntlk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckHZXR/btsNHtT01zN/ofeNSHnz07kZh4hKN3Ntlk/img.png&quot; data-alt=&quot;개선된 Redis HA&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckHZXR/btsNHtT01zN/ofeNSHnz07kZh4hKN3Ntlk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckHZXR%2FbtsNHtT01zN%2FofeNSHnz07kZh4hKN3Ntlk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3004&quot; height=&quot;1499&quot; data-origin-width=&quot;3004&quot; data-origin-height=&quot;1499&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;개선된 Redis HA&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 개선하기 위해서 위와 같이 3개의 노드로 구성된 클러스터를 구축하여, 각각의 노드에 Redis Server 1개와 Redis Sentinel 1개를 배치하는 전략을 취할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;참고자료&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://seongonion.tistory.com/168&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://seongonion.tistory.com/168&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1746061835601&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Redis의 HA(High Availability) 전략 알아보기!&quot; data-og-description=&quot;Redis(이하 레디스)는 Key-Value 형태의 데이터를 저장할 수 있는 일종의 NoSQL 데이터베이스 중 하나이다.&amp;nbsp;레디스는 특히 일반적인 DBMS 시스템과 다르게 디스크가 아닌 메모리에 데이터를 저장하기 &quot; data-og-host=&quot;seongonion.tistory.com&quot; data-og-source-url=&quot;https://seongonion.tistory.com/168&quot; data-og-url=&quot;https://seongonion.tistory.com/168&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bf8npE/hyYM16bIeK/uKVKehEPjeFM2w7tMggD70/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bdFypt/hyYL9jEImk/DIcQHYrPkkDkewUn5n7XAK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/beBT41/hyYMeSOg3b/SgXuamSKqOaRYTw2kynGjk/img.png?width=2094&amp;amp;height=1046&amp;amp;face=0_0_2094_1046&quot;&gt;&lt;a href=&quot;https://seongonion.tistory.com/168&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://seongonion.tistory.com/168&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bf8npE/hyYM16bIeK/uKVKehEPjeFM2w7tMggD70/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bdFypt/hyYL9jEImk/DIcQHYrPkkDkewUn5n7XAK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/beBT41/hyYMeSOg3b/SgXuamSKqOaRYTw2kynGjk/img.png?width=2094&amp;amp;height=1046&amp;amp;face=0_0_2094_1046');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Redis의 HA(High Availability) 전략 알아보기!&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Redis(이하 레디스)는 Key-Value 형태의 데이터를 저장할 수 있는 일종의 NoSQL 데이터베이스 중 하나이다.&amp;nbsp;레디스는 특히 일반적인 DBMS 시스템과 다르게 디스크가 아닌 메모리에 데이터를 저장하기&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;seongonion.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Infra</category>
      <category>redis</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/9</guid>
      <comments>https://immersive.tistory.com/9#entry9comment</comments>
      <pubDate>Thu, 1 May 2025 11:22:19 +0900</pubDate>
    </item>
    <item>
      <title>[CI/CD] 배포에 관한 생각</title>
      <link>https://immersive.tistory.com/8</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;450&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1Rz41/btsM1OylYUC/cUBorKn8jp8KuOdw3OyYRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1Rz41/btsM1OylYUC/cUBorKn8jp8KuOdw3OyYRk/img.png&quot; data-alt=&quot;CI/CD 흐름도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1Rz41/btsM1OylYUC/cUBorKn8jp8KuOdw3OyYRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1Rz41%2FbtsM1OylYUC%2FcUBorKn8jp8KuOdw3OyYRk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;576&quot; height=&quot;324&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;450&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;CI/CD 흐름도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배포 방법론에 대한 생각&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자는 본인이 개발한 작업물을 불특정 다수에게 공개하기 위해 필연적으로 &lt;b&gt;배포&lt;/b&gt;라는 과정을 거쳐야 합니다. 저 또한 처음으로 팀 프로젝트를 진행하면서 로컬에서 개발한 후, 클라우드 서비스에 배포하여 모든 팀원들이 작업물을 공유할 수 있었을 때 신기하면서도 뿌듯했던 기억이 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;254&quot; data-start=&quot;182&quot; data-ke-size=&quot;size16&quot;&gt;우연히 유튜브를 보다가 6가지 배포 전략에 대해서 소개하는 유튜브 영상을 봤습니다. (영상은 하단에 첨부했습니다) 영상에 소개된 배포 전략 중 제가 실제로 적용했던 배포 방식도 있어서 이 글에서는 제가 학부 시절 프로젝트에서 사용했던 배포 방식부터, 현재 회사에서 사용하는 배포 방식까지 소개해 보려고 합니다.&lt;/p&gt;
&lt;p data-end=&quot;352&quot; data-start=&quot;256&quot; data-ke-size=&quot;size16&quot;&gt;배포 과정에서 자주 언급되는 개념 중 하나가 CI/CD(Continuous Integration &amp;amp; Continuous Deployment)입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;514&quot; data-start=&quot;353&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;433&quot; data-start=&quot;353&quot;&gt;&lt;b&gt;CI(Continuous Integration)&lt;/b&gt;: 코드를 병합하고 빌드한 뒤, 테스트를 거쳐 배포 직전 상태를 만드는 과정입니다.&lt;/li&gt;
&lt;li data-end=&quot;514&quot; data-start=&quot;434&quot;&gt;&lt;b&gt;CD(Continuous Deployment)&lt;/b&gt;: 코드 변경 사항이 테스트를 통과하면 자동으로 프로덕션 환경에 배포되는 과정입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;664&quot; data-start=&quot;516&quot; data-ke-size=&quot;size16&quot;&gt;즉, 코드를 빌드하고 배포하는 작업을 자동화하는 방법론이라고 볼 수 있습니다. 프로젝트의 규모와 요구 사항에 따라 배포 방식은 달라질 수 있으며, 이를 수행하기 위한 도구로는 대표적으로 &lt;b&gt;GitHub Actions, Jenkins, Travis CI&lt;/b&gt; 등이 있습니다. 이 글에서는 이러한 툴의 사용법보다는, 제가 직접 적용해본 &lt;b&gt;배포 방법론&lt;/b&gt;에 대해 이야기해보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Big Bang Deployment&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Big Bang Deployment&lt;/b&gt;란, 모든 시스템 구성 요소를 한 번에 교체하는 배포 방식입니다. 즉, 배포할 때마다 서버를 일시적으로 중지한 후, 새로운 버전을 배포하고 다시 서버를 가동하는 방식입니다. 이 방식의 장점은 &lt;b&gt;배포 프로세스가 단 한 번만 실행되므로 절차가 단순&lt;/b&gt;하다는 점입니다. 또한, 모든 구성 요소가 동시에 업데이트되기 때문에 &lt;b&gt;버전 불일치와 같은 문제가 발생하지 않습니다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-end=&quot;265&quot; data-start=&quot;153&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;329&quot; data-start=&quot;267&quot; data-ke-size=&quot;size16&quot;&gt;제가 프로젝트에서 처음 적용해본 배포 방식도 이와 유사했습니다. 단일 환경에서 컨테이너를 교체하는 방식으로,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;441&quot; data-start=&quot;330&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;369&quot; data-start=&quot;330&quot;&gt;기존 컨테이너(aurora-app)를 중지하고 제거한 뒤,&lt;/li&gt;
&lt;li data-end=&quot;441&quot; data-start=&quot;370&quot;&gt;새로 빌드된 Docker 이미지(aurora-dev:latest)를 &lt;b&gt;pull&lt;/b&gt;하여 바로 실행하는 구조였습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;499&quot; data-start=&quot;443&quot; data-ke-size=&quot;size16&quot;&gt;즉, 기존 것을 내리고 새로운 것을 올리는 &lt;b&gt;Big Bang Deployment 방식&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1743334849312&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;name: CI/CD using GitHub Actions &amp;amp; Docker

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

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout source
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Build Project with Gradle
        run: |
          echo ${{ secrets.APPLICATION_SECRET_DEV }} | base64 --decode &amp;gt; ./src/main/resources/application-secret-dev.yml
          ./gradlew bootJar

      - name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}

      - name: Build Docker image
        run: docker build --platform linux/amd64 -t ${{ secrets.DOCKER_USERNAME }}/aurora-dev .

      - name: Publish image to Docker Hub
        run: docker push ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Server
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            # 기존 컨테이너 중지 및 제거
            sudo docker stop aurora-app || true
            sudo docker rm aurora-app || true
            
            # 최신 이미지 풀
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest
            
            # 새로운 컨테이너 실행
            sudo docker run -d --name aurora-app -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest

      - name: Check server URL
        uses: jtalk/url-health-check-action@v3
        with:
          url: http://${{ secrets.AURORA_IP_DEV }}:8080/env
          max-attempts: 5
          retry-delay: 10s&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;663&quot; data-start=&quot;501&quot; data-ke-size=&quot;size16&quot;&gt;눈치챘겠지만, 이 방식에는 치명적인 단점이 있는데요. 그것은 바로 배포할 때 마다 &lt;b&gt;서버 다운타임이 발생&lt;/b&gt;한다는 점입니다. 아무리 빠르게 배포하더라도 일시적으로 서비스가 중단될 수밖에 없습니다. 실 서비스 운영 시, Big Bang Deployment 방법을 적용하면 업데이트를 할 때마다 점검 시간을 둬야 합니다.&amp;nbsp;&lt;br /&gt;현대적인 소프트웨어 개발 환경은 여러 서비스로 이루어져 있으며 변경 사항이 빈번히 일어나기 때문에 이와 같은 배포 방식을 사용하기보다 점진적으로 배포하는 &lt;b&gt;CI/CD 방식이 더 선호&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;p data-end=&quot;764&quot; data-start=&quot;665&quot; data-ke-size=&quot;size16&quot;&gt;그럼에도 불구하고, 저는 &lt;b&gt;서비스&lt;/b&gt;&amp;nbsp;&lt;b&gt;초기 개발 단계이거나 다운타임이 허용되는 비즈니스 환경&lt;/b&gt;에서는 Big Bang Deployment가 여전히 매력적인 배포 방식이라고 생각합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Blue Green Deployment&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Big Bang Deployment의 단점인 서비스 다운 타임이 발생하는 단점을 개선하기 위해 저는 다음 프로젝트에서 Blue/Green Deployment을 적용했습니다. Blue/Green Deployment란 &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;두 개의 동일한 프로덕션 환경(Blue와 Green)을 유지하며, 한 환경에서 현재 서비스를 실행하는 동안 다른 환경에 새 버전을 배포하는 방식입니다. 배포가 완료되고 새 환경이 안정적으로 작동하는 것이 확인되면 트래픽을 새 환경으로 전환합니다. 즉, Blue/Green 전략을 사용하면 서비스 중단 없이 새로운 버전을 배포하고 안정적으로 새로운 버전으로 전환할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Blue&lt;/b&gt;: 현재 서비스가 실행 중인 환경&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Green&lt;/b&gt;: 새 버전이 배포될 준비 환경&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1743476693134&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 빌드 부분은 Big Bang Deployment 방식과 동일

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Set Target IP
        run: |
          STATUS=$(curl -o /dev/null -w &quot;%{http_code}&quot; &quot;http://${{ secrets.AURORA_IP_DEV }}/env&quot;)
          echo $STATUS
          if [ $STATUS = 200 ]; then
            CURRENT_UPSTREAM=$(curl -s &quot;http://${{ secrets.AURORA_IP_DEV }}/env&quot;)
          else
            CURRENT_UPSTREAM=green
          fi
          echo CURRENT_UPSTREAM=$CURRENT_UPSTREAM &amp;gt;&amp;gt; $GITHUB_ENV
          if [ $CURRENT_UPSTREAM = blue ]; then
            echo &quot;CURRENT_PORT=8080&quot; &amp;gt;&amp;gt; $GITHUB_ENV
            echo &quot;STOPPED_PORT=8081&quot; &amp;gt;&amp;gt; $GITHUB_ENV
            echo &quot;TARGET_UPSTREAM=green&quot; &amp;gt;&amp;gt; $GITHUB_ENV
          else
            echo &quot;CURRENT_PORT=8081&quot; &amp;gt;&amp;gt; $GITHUB_ENV
            echo &quot;STOPPED_PORT=8080&quot; &amp;gt;&amp;gt; $GITHUB_ENV
            echo &quot;TARGET_UPSTREAM=blue&quot; &amp;gt;&amp;gt; $GITHUB_ENV
          fi

      - name: Docker Compose
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/aurora-dev:latest
            sudo docker-compose -f docker-compose-${{env.TARGET_UPSTREAM}}.yml up -d

      - name: Check deploy server URL
        uses: jtalk/url-health-check-action@v3
        with:
          url: http://${{ secrets.AURORA_IP_DEV }}:${{ env.STOPPED_PORT }}/env
          max-attempts: 5
          retry-delay: 10s

      - name: Change nginx upstream
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            sudo docker exec -i nginxserver bash -c 'echo &quot;set \$service_url ${{env.TARGET_UPSTREAM}};&quot; &amp;gt; etc/nginx/conf.d/service-env.inc &amp;amp;&amp;amp; nginx -s reload'

      - name: Stop current server
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.AURORA_IP_DEV }}
          key: ${{ secrets.EC2_SSH_KEY_DEV }}
          script_stop: true
          script: |
            sudo docker stop ${{ env.CURRENT_UPSTREAM }}
            sudo docker rm ${{ env.CURRENT_UPSTREAM }}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;배포 단계 (deploy) 단계는 아래와 같습니다.&lt;/div&gt;
&lt;div&gt;&lt;b&gt;1. 환경 결정 (Set Target IP)&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;:&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 실행 중인 환경(&lt;span&gt;CURRENT_UPSTREAM&lt;/span&gt;)을 확인합니다. &lt;span&gt;/env&lt;/span&gt; 엔드포인트로 HTTP 상태 코드를 확인하고, 200이면 현재 환경(blue 또는 green)을 가져옵니다. 실패 시 기본값으로 &lt;span&gt;green&lt;/span&gt;을 설정&lt;/li&gt;
&lt;li&gt;Blue(8080 포트)가 실행 중이면 Green(8081 포트)에 배포하고, Green이 실행 중이면 Blue에 배포하도록 포트와 타겟 환경을 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 새 환경 배포 (Docker Compose)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새 이미지를 풀(pull)하고, &lt;span&gt;docker-compose-${{env.TARGET_UPSTREAM}}.yml&lt;/span&gt; 파일로 타겟 환경(Green 또는 Blue)에 새 컨테이너를 띄움
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ex) Green에 배포 시 &lt;span&gt;docker-compose-green.yml&lt;/span&gt;을 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 헬스 체크 (&lt;span&gt;Check deploy server URL&lt;/span&gt;)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새로 띄운 환경의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;/env&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;엔드포인트를 확인해 정상 작동 여부를 점검 (포트는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;STOPPED_PORT&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;사용)&lt;/li&gt;
&lt;li&gt;최대 5번 시도하며, 실패 시 배포가 중단&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 트래픽 전환&lt;/b&gt; &lt;b&gt;(&lt;span&gt;Change nginx upstream&lt;/span&gt;)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Nginx의 upstream 설정을 변경해 트래픽을 새 환경으로 라우팅
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ex) Green으로 전환 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;service_url&lt;/span&gt;을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;green&lt;/span&gt;으로 설정하고 Nginx를 리로드&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 기존 환경 종료&lt;/b&gt; &lt;b&gt;(&lt;span&gt;Stop current server&lt;/span&gt;)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이전 환경(Blue 또는 Green)의 컨테이너를 중지하고 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Frame 1.png&quot; data-origin-width=&quot;723&quot; data-origin-height=&quot;627&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHpol0/btsM4qJ5jF6/EUglE784OPe1gjkuEiBfb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHpol0/btsM4qJ5jF6/EUglE784OPe1gjkuEiBfb0/img.png&quot; data-alt=&quot;Blue/Green 배포 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHpol0/btsM4qJ5jF6/EUglE784OPe1gjkuEiBfb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHpol0%2FbtsM4qJ5jF6%2FEUglE784OPe1gjkuEiBfb0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;572&quot; height=&quot;496&quot; data-filename=&quot;Frame 1.png&quot; data-origin-width=&quot;723&quot; data-origin-height=&quot;627&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Blue/Green 배포 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만, 모든 소프트웨어 방법론이 그렇듯 &lt;b&gt;Blue/Green 배포 전략도 완벽한 해결책은 아닙니다&lt;/b&gt;. 이 방식의 가장 큰 단점은 &lt;b&gt;리소스 낭비가 발생할 수 있다는 점&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-end=&quot;334&quot; data-start=&quot;144&quot; data-ke-size=&quot;size16&quot;&gt;무중단 배포를 실현하기 위해 &lt;b&gt;Blue &amp;rarr; Green&lt;/b&gt; 혹은 &lt;b&gt;Green &amp;rarr; Blue&lt;/b&gt;로 전환할 때, &lt;b&gt;일시적으로 두 개의 서버가 동시에 떠 있어야 하는 시점&lt;/b&gt;이 존재합니다. 이 때문에 EC2를 사용할 경우, 평소에는 서버 한 대만 운영하더라도 &lt;b&gt;배포 시에는 두 대를 안정적으로 실행할 수 있는 리소스를 확보해야 합니다&lt;/b&gt;. (즉 리소스의 200%를 사용하는 배포 방식입니다.)&lt;/p&gt;
&lt;p data-end=&quot;495&quot; data-start=&quot;336&quot; data-ke-size=&quot;size16&quot;&gt;실제로 이 방법을 적용했을 때, 처음에는 &lt;b&gt;t2.micro&lt;/b&gt; 사양의 EC2를 사용했지만, 배포 시 &lt;b&gt;메모리 부족으로 서버가 다운되는 문제&lt;/b&gt;가 발생했습니다. 이를 해결하기 위해 &lt;b&gt;메모리 2GB를 제공하는 t2.small 사양으로 Scale-Up&lt;/b&gt;하자 문제가 해결되었습니다.&lt;/p&gt;
&lt;p data-end=&quot;570&quot; data-start=&quot;497&quot; data-ke-size=&quot;size16&quot;&gt;이처럼 &lt;b&gt;Blue/Green 배포는 무중단 배포라는 강력한 장점이 있지만, 리소스 관리 측면에서 신중한 고려가 필요&lt;/b&gt;합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Rolling Deployment&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 진행해온 프로젝트는 &lt;b&gt;모놀리식 구조&lt;/b&gt;였기 때문에, 하나의 서버를 어떻게 배포할지, 더 나아가 &lt;b&gt;어떻게 하면 중단 시간 없이 배포할 수 있을지&lt;/b&gt;에 대한 고민을 주로 해왔습니다. 하지만 현업에서는 &lt;b&gt;MSA(Microservices Architecture)&lt;/b&gt; 구조로 서비스가 운영되면서, &lt;b&gt;다중화된 서버를 어떻게 안정적으로 배포할 것인지&lt;/b&gt;에 대한 고민이 필요해졌습니다.&lt;b&gt;&amp;nbsp;Docker Swarm과 같은 컨테이너 오케스트레이션 환경에서 &lt;/b&gt;제가 경험한 &lt;b&gt;Rolling 배포 전략&lt;/b&gt;에 대해 소개해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Rolling 배포는 &lt;b&gt;Blue/Green와 같이 서비스를 중단하지 않으면서 애플리케이션의 새 버전으로 교체해 나가는 방식&lt;/b&gt;으로 동작합니다. Rolling 배포는 전체 시스템을 한 번에 업데이트 하는 대신, 애플리케이션을 순차적으로 업데이트 합니다. Docker Swarm 환경에서는 서비스에 속한 여러 레플리카(replica)를 관리하며, 일부 컨테이너를 중지하고 새 버전으로 교체 한 후 정상 작동을 확인한 뒤 다음 컨테이너로 넘어가는 방식으로 진행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Rolling 배포의 구체적인 배포 방식은 다음과 같습니다: &lt;b&gt;현재 상태&lt;/b&gt; -&amp;gt; &lt;b&gt;업데이트 시작&lt;/b&gt; -&amp;gt; &lt;b&gt;점진적 교체&lt;/b&gt; -&amp;gt; &lt;b&gt;완료&lt;/b&gt; -&amp;gt; &lt;b&gt;롤백&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;현재 상태&lt;/b&gt;: aurora-service&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;라는 서비스가 5개의 컨테이너(레플리카)로 실행 중이라고 가정 (각 컨테이너는 버전 1.0 실행)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;업데이트 시작&lt;/b&gt;: 새로운 Docker 이미지(버전 2.0)를 Swarm에 배포 명령으로 업데이트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;점진적 교체&lt;/b&gt;: Swarm은 설정에 따라 한 번에 1~2개의 컨테이너를 중지하고, 새 이미지(버전 2.0)로 교체&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;ex) 5개 중 1개를 중지 -&amp;gt; 새 버전 실행 -&amp;gt; 헬스 체크 통과 -&amp;gt; 다음 컨테이너로 진행&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;완료&lt;/b&gt;: 모든 컨테이너가 새 버전으로 교체될 때 까지 이 과정이 반복되며 결과적으로 서비스 중단 없이 컨테이너 5개 모두 버전 2.0으로 업데이트됨&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;롤백&lt;/b&gt;: 새 버전에 문제가 생기면 Swarm은 이전 상태로 롤백하거나 업데이트를 중단함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1743822153841&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker service update \
  --update-parallelism 2 \
  --update-delay 10s \
  --image &amp;lt;username&amp;gt;/&amp;lt;image-name&amp;gt;:2.0 \
  aurora-service&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 위 설정은 한 번에 2개의 컨테이너를 업데이트하고, 각 단계마다 10초를 대기합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743822310156&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# docker-stack.base.yml 파일의 일부

version: &quot;3.8&quot;

services:
  python-test:
    image: ${CU_DOCKER_STACK_IMAGE}

    ports:
      - &quot;8000:8000&quot;

    deploy:
      mode: replicated
      replicas: 5

      placement:
        constraints:
          - &quot;node.labels.service-api-node==yes&quot;

      resources:
        limits:
          cpus: &quot;1.00&quot;
          memory: 500M
        reservations:
          cpus: &quot;0.01&quot;
          memory: 150M

      update_config:
        delay: 30s
        order: start-first
        monitor: 10s

      restart_policy:
        condition: on-failure
        delay: 100s
        max_attempts: 3
        window: 30s&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 파일은 회사에서 진행 중인 MSA 환경의 마이크로서비스의 docker-stack.base.yml 파일 중 일부입니다. 위 파일을 기반으로 Docker Swarm 클러스터에 배포하면 Rolling 배포 방식으로 동작합니다. Docker Swarm에서 Rolling 배포는 기본적으로 서비스 업데이트 시 적용되며, &lt;span&gt;deploy&lt;/span&gt; 섹션의 &lt;span&gt;update_config&lt;/span&gt; 설정을 통해 제어됩니다&lt;/p&gt;
&lt;div&gt;&lt;b&gt;&lt;span&gt;deploy&lt;/span&gt; 섹션&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;mode: replicated -&amp;gt; &lt;/span&gt;서비스가 여러 레플리카(컨테이너)로 실행됨을 의미&lt;/li&gt;
&lt;li&gt;&lt;span&gt;replicas: 5 -&amp;gt; &lt;/span&gt;5개의 컨테이너가 배포&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;update_config&lt;/span&gt; 설정&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;delay: 30s&lt;/span&gt;: 각 컨테이너 업데이트 사이에 30초 지연&lt;/li&gt;
&lt;li&gt;&lt;span&gt;order: start-first&lt;/span&gt;: 새 컨테이너를 먼저 시작한 후 기존 컨테이너를 중지&lt;/li&gt;
&lt;li&gt;&lt;span&gt;monitor: 10s&lt;/span&gt;: 업데이트 후 새 컨테이너가 정상적으로 실행되는지 10초 동안 모니터링&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, docker stack deploy 명령으로 이 파일을 배포하거나, 이미 배포된 상태에서 이미지 버전을 변경하고 업데이트(docker service update)를 실행하면, Swarm은 update_config에 따라 5개의 컨테이너를 순적으로 교체합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 롤백 배포 방법은 운영 환경에서 두 개의 버전이 동시에 실행될 수 있는 단점이 존재합니다. 예를 들어, API 변경 시에 신/구 버전이 잠시 공존하므로 호환성 문제가 생길 가능성이 있습니다. 이 문제는 API에 버전 번호를 명시적으로 추가해서 신/구 버전 간 요청을 분리 한 후에 Nginx와 같은 API Gateway에서 버전에 따라 트래픽을 분배하는 방식으로 문제를 예방할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;그 외 배포 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 외에도 제가 직접 적용해보지는 안않지만, 유튜브에서 소개된 배포 방법에 대해서 간략하게 설명하도록 하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Canary Deployment&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카나리 배포는 새 버전을 소규모 사용자 그룹(예: 5% 또는 특정 지역)에게 먼저 배포한 후, 문제가 없으면 점차 전체 사용자에게 확대 적용하는 배포 방식입니다. 카나리 배포를 적용했을 때, 새 버전에 문제 발생 시 소수 사용자만 영향을 받아&amp;nbsp; 리스크를 최소화할 수 있는 장점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- AB Test Deployment&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AB Test 배포는 두 개 이상의 버전(A와 B)을 동시에 배포해 사용자 반응을 비교하고, 더 나은 버전을 선택하는 방식입니다. 주로 기능이나, UI , 성능 테스르할 때 사용되는 방식입니다. 이 배포 방식은 안정성보단 사용자의 선호도를 파악하며 비즈니스 목표를 달성하기 위한 배포 방식입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Shadow Deployment&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Shadow 배포 방식은 새 버전을 실제 트래픽에 노출시키지 않고, 기존 버전의 트래픽을 복제(mirroring)해 새 버전에서 병렬로 실행하며 테스트하는 방식입니다. 기존 버전(프로덕션)이 트래픽을 처리하며 정상적으로 서비스를 제공하는 동시에 동일한 요청을 새 버전(Shadow)으로 복제해 실행하여 새 버전의 성능과 오류 등을 모니터링하는 방식입니다. 이 방식은 리스크 없이 새 버전을 테스트할 수 있는 장점이 있지만, 트래픽 복제와 모니터링을 위한 추가 인프라가 필요하다는 단점이 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;참고 문헌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://www.youtube.com/watch?v=eyzzwHcAZSY&amp;amp;t=29s&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=eyzzwHcAZSY&amp;amp;t=29s&lt;/a&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;youtube&quot; data-video-url=&quot;https://www.youtube.com/watch?v=eyzzwHcAZSY&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/0NYsq/hyYyPFq5IK/Nc7eWlPM1Qcz6S955Fg06K/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720,https://scrap.kakaocdn.net/dn/lhtS9/hyYxO1tkjI/UKZxbN3AHwQvU1kZiyinvK/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720&quot; data-video-width=&quot;860&quot; data-video-height=&quot;484&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;484&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-title=&quot;6가지 배포전략 8분컷&quot; data-original-url=&quot;&quot;&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/eyzzwHcAZSY&quot; width=&quot;860&quot; height=&quot;484&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;</description>
      <category>Infra</category>
      <category>ci/cd</category>
      <category>무중단 배포</category>
      <category>배포 전략</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/8</guid>
      <comments>https://immersive.tistory.com/8#entry8comment</comments>
      <pubDate>Sat, 5 Apr 2025 12:42:33 +0900</pubDate>
    </item>
    <item>
      <title>[Test] 부하 테스트 (feat. JMeter)</title>
      <link>https://immersive.tistory.com/7</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;523&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SA2HA/btsLsB0VmYw/5EBxh5tUkNZg8H395sCWL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SA2HA/btsLsB0VmYw/5EBxh5tUkNZg8H395sCWL1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SA2HA/btsLsB0VmYw/5EBxh5tUkNZg8H395sCWL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSA2HA%2FbtsLsB0VmYw%2F5EBxh5tUkNZg8H395sCWL1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;611&quot; height=&quot;312&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;523&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 성능 테스트란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;프로젝트가 마무리되는 시점에서 다양한 성능 테스트를 통해 서버의 내구도를 확인하고 싶었습니다. 우선, 성능 테스트란 API 요청이 많은 상황에서 서버가 어떻게 동작하는지 확인하는 테스트 입니다.&amp;nbsp; 시스템이 서비스가 정상적으로 제공할 수 있는 상태인 가용성을 높이기 위해 성능 테스트를 실시합니다. 또한, &quot;초당 1000건의 요청 처리 + 모든 조회 요청을 1초 이내로 응답&quot;과 같은 목표치를 달성하기 위해서도 성능 테스트를 진행할 수 있습니다. 이번 블로그에서는&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 성능 테스트 지표&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능 테스트에서 주의 깊게 봐야 할 지표는 크게 &quot;처리량&quot;과 &quot;응답 시간&quot;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/06zx9/btsLtaaSzBf/U1KuunCvpwenxx4nHLP780/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/06zx9/btsLtaaSzBf/U1KuunCvpwenxx4nHLP780/img.jpg&quot; data-alt=&quot;처리량과 응답시간&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/06zx9/btsLtaaSzBf/U1KuunCvpwenxx4nHLP780/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F06zx9%2FbtsLtaaSzBf%2FU1KuunCvpwenxx4nHLP780%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;558&quot; height=&quot;419&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;처리량과 응답시간&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;처리량&quot;은 초당 처리하는 작업의 수 (TPS: Transactions Per Second) 입니다. 처리량은 &lt;b&gt;서브시스템 중 가장 처리량이 낮은 부분&lt;/b&gt;으로 계산될 수 있습니다. 예를 들어, 클라이언트에서 서버로의 요청이 &lt;b&gt;500TPS&lt;/b&gt; 이고&amp;nbsp; 서버에서 데이터베이스로의 요청이 &lt;b&gt;300TPS&lt;/b&gt;이면 백엔드 서버의 전체 처리량은 &lt;b&gt;300TPS&lt;/b&gt; 입니다. 즉, 서버의 처리량을 개선하기 위해서는 병목 구간을의 성능을 개선할 필요가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;응답 시간&quot;은 시스템이 요청을 받고 응답할 때 까지의 시간을 의미합니다. 응답 시간은 시스템이 요청을 처리할 때까지 대기하는 시간도 포함됩니다. 응답 시간은 각 서브시스템 응답 시간의 총합으로 계산할 수 있습니다. 즉, 응답 시간의 경우 어떤 부분을 개선하더라도 총 시스템의 응답 시간에 영향을 줄 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. 성능 테스트 종류&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;스모크 테스트
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;최소한의 부하를 주어 시스템이 정상적으로 동작하는지 확인하는 테스트&lt;/li&gt;
&lt;li&gt;부하 테스트를 공격적으로 실시하기 전에 테스트 스크립트가 정상적으로 동작하는지 확인하기 위함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;스파이크 테스트
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;사용량이 짧은 시간에 급증하는 상황에서 시스템이 견디고 성능에 문제가 없는지 확인하는 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;부하 테스트
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;시스템이 운영 환경에서 예상되는 사용자의 부하를 견딜 수 있는지&lt;/li&gt;
&lt;li&gt;부하 테스트에서 응답 시간, 처리량, 에러율을 측정하여 소프트웨어의 성능 목표치에 도달할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;스트레스 테스트
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;과부하 상태에서 어떻게 동작하는지 확인하고 개선하는 것이 목적&lt;/li&gt;
&lt;li&gt;시스템에 최대치에 해당하는 부하를 받았을 때 시스템이 어떻게 동작하는지 확인&lt;/li&gt;
&lt;li&gt;부하 테스트 대비 150% 아니면 그 이상의 부하를 주어 테스트를 진행&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;내구 테스트
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;평균 사용률로 일정 부하를 지속적으로 주며 시스템이 문제되는 지정을 확인하는 테스트&lt;/li&gt;
&lt;li&gt;메모리 누수 문제와 같이 시스템을 장기간 켜두었을 때 발생하는 문제를 내구 테스트를 통해 확인할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;중단점 테스트
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;임계 지점을 찾기 위해 부하를 점진적으로 증가시키며 진행하는 테스트&lt;/li&gt;
&lt;li&gt;시스템 한계 지점을 파악할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. 성능 테스트 실습&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 성능 테스트를 실행하기 위해서 JMeter를 사용했습니다. JMeter는 Apache에서 Java로 만든 웹 어플리케이션 성능 테스트 오픈 소스로 서버나 네트워크 또는 개체에 대해 과부하를 시뮬레이션하여 강도를 테스트라거나 다양한 부하 유형에서 전체 성능을 분석하는 데 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. &lt;b&gt;쓰레드 그룹 생성&lt;/b&gt;: &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;테스트 계획 우클릭 -&amp;gt; 추가 -&amp;gt; 쓰레드들 -&amp;gt; 쓰레드 그룹&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1750&quot; data-origin-height=&quot;954&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwSH6n/btsLrwlIGgK/9yYJJ2jZNb7oYsm8NfkstK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwSH6n/btsLrwlIGgK/9yYJJ2jZNb7oYsm8NfkstK/img.png&quot; data-alt=&quot;Thread Group&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwSH6n/btsLrwlIGgK/9yYJJ2jZNb7oYsm8NfkstK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbwSH6n%2FbtsLrwlIGgK%2F9yYJJ2jZNb7oYsm8NfkstK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;667&quot; height=&quot;364&quot; data-origin-width=&quot;1750&quot; data-origin-height=&quot;954&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Thread Group&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Number of Threads: 가상 사용자를 몇 명으로 설정할지&lt;/li&gt;
&lt;li&gt;Ramp-up 시간: 쓰레드 수들을 얼마 시간 동안 테스트할지 ex) 쓰레드 수: 100, Ramp-up:10인 경우, 1초에 10번씩 요청이 전송&lt;/li&gt;
&lt;li&gt;Loop Count: 사용자들이 몇번 요청하는지 ex) 쓰레드: 100, 루프 카운트: 10인 경우, 100명이 10번씩 요청해서 총 1000번의 요청&lt;/li&gt;
&lt;li&gt;Duration: 몇 초 동안 테스트를 실행할 지, Duration에 설정한 시간이 끝나면 테스트가 자동 종료됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;HTTP Request 생성&lt;/b&gt;: &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;쓰레드 그룹 우클릭 -&amp;gt; 추가 -&amp;gt; 표본추출기(Sampler) -&amp;gt; HTTP Request을 선택&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1762&quot; data-origin-height=&quot;718&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/colmil/btsLqyLgJuN/wfWaY9OCP1LvK2GQECkjqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/colmil/btsLqyLgJuN/wfWaY9OCP1LvK2GQECkjqk/img.png&quot; data-alt=&quot;HTTP Request 생성&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/colmil/btsLqyLgJuN/wfWaY9OCP1LvK2GQECkjqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcolmil%2FbtsLqyLgJuN%2FwfWaY9OCP1LvK2GQECkjqk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;686&quot; height=&quot;280&quot; data-origin-width=&quot;1762&quot; data-origin-height=&quot;718&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;HTTP Request 생성&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;현재 개발중인 프로젝트 개발 서버에 있는 백엔드 API(인기 프로젝트 3개를 응답하는 API)를 호출&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;인증이 필요한 API인 경우: &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;쓰레드 그룹 우클릭 -&amp;gt; 추가 -&amp;gt; Config Element -&amp;gt; HTTP Header Manager을 선택하여 추가&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;3. &lt;b&gt;리스너 추가&lt;/b&gt;: &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;쓰레드 그룹 우클릭 -&amp;gt; 추가 -&amp;gt; 리스너 -&amp;gt; 결과들의 트리 보기, 요약 보고서, 결과 그래프&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;1100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lsROl/btsLrM9KDWB/45FxwYsTftqcgTlbnWJgOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lsROl/btsLrM9KDWB/45FxwYsTftqcgTlbnWJgOK/img.png&quot; data-alt=&quot;Listner&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lsROl/btsLrM9KDWB/45FxwYsTftqcgTlbnWJgOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlsROl%2FbtsLrM9KDWB%2F45FxwYsTftqcgTlbnWJgOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;514&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;1100&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Listner&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트 결과를 시각화하기 위해서 위와 같은 리스너를 추가합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 테스트 리포트 생성&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1750&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8kAjL/btsLrmcB7Dk/qevsg5UfAEe7dgVqC5kc50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8kAjL/btsLrmcB7Dk/qevsg5UfAEe7dgVqC5kc50/img.png&quot; data-alt=&quot;테스트 결과를 저장하기 위한 파일 선택&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8kAjL/btsLrmcB7Dk/qevsg5UfAEe7dgVqC5kc50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8kAjL%2FbtsLrmcB7Dk%2Fqevsg5UfAEe7dgVqC5kc50%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;715&quot; height=&quot;182&quot; data-origin-width=&quot;1750&quot; data-origin-height=&quot;446&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트 결과를 저장하기 위한 파일 선택&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앞서 리스너로 만들어 두었던 View Result Tree에서 결과들을 저장할 파일을 선택합니다. &lt;b&gt;(여기서 파일의 확장자를 .csv로 변경해야 합니다!!)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;이전 테스트 이력을 제거한 후에 다시 테스트를 실행합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;856&quot; data-origin-height=&quot;576&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HrLjJ/btsLrAn4K7d/ZmYM39VfKZBHH7tmDW6Tzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HrLjJ/btsLrAn4K7d/ZmYM39VfKZBHH7tmDW6Tzk/img.png&quot; data-alt=&quot;HTML 보고서 생성 팝업&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HrLjJ/btsLrAn4K7d/ZmYM39VfKZBHH7tmDW6Tzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHrLjJ%2FbtsLrAn4K7d%2FZmYM39VfKZBHH7tmDW6Tzk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;323&quot; data-origin-width=&quot;856&quot; data-origin-height=&quot;576&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;HTML 보고서 생성 팝업&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;도구 &amp;gt; HTML 보고서 생성 메뉴를 클릭&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;앞서 생성해둔 csv파일 선택&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;bin폴더 내에 있는 jmeter.properties 파일을 선택&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;출력 디렉토리는 반드시 빈 디렉토리를 선택!!&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. 성능 테스트 결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 읽기 성능을 향상 시키기 위해서 RDS를 하나 사용하는 단일 데이터베이스 구조에서 EC2를 두 개 사용하여 데이터베이스를 다중화했습니다. 아래 결과는 데이터베이스를 다중화하기 전과 후를 비교한 결과입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. &lt;b&gt;부하 테스트&lt;/b&gt;: 동시 접속 500명, 1시간 동안 지속 부하, 응답 시간 및 처리량 측정&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;786&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biKRJU/btsLrQK745C/D1B6wvtjJNEpOIclVEjvkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biKRJU/btsLrQK745C/D1B6wvtjJNEpOIclVEjvkK/img.png&quot; data-alt=&quot;&amp;amp;lt;Before&amp;amp;gt; RDS를 하나 사용하는 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biKRJU/btsLrQK745C/D1B6wvtjJNEpOIclVEjvkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiKRJU%2FbtsLrQK745C%2FD1B6wvtjJNEpOIclVEjvkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;619&quot; height=&quot;257&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;786&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;Before&amp;gt; RDS를 하나 사용하는 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;790&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dICfhA/btsLrLQziHV/XsQg7W7RpLHwkkSddTdEoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dICfhA/btsLrLQziHV/XsQg7W7RpLHwkkSddTdEoK/img.png&quot; data-alt=&quot;&amp;amp;lt;After&amp;amp;gt; 데이터베이스를 다중화한 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dICfhA/btsLrLQziHV/XsQg7W7RpLHwkkSddTdEoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdICfhA%2FbtsLrLQziHV%2FXsQg7W7RpLHwkkSddTdEoK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;601&quot; height=&quot;250&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;790&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;After&amp;gt; 데이터베이스를 다중화한 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Response Time 약 12.5% 감소&amp;nbsp;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;평균: 360ms &amp;rarr; 314ms&lt;/li&gt;
&lt;li&gt;최댓값: 3992ms &amp;rarr; 3400ms&lt;/li&gt;
&lt;li&gt;최솟값: 34ms &amp;rarr; 29ms&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;0% 달성: RDS를 하나 사용했을 때는, 부하 테스트에서 21개의 요청이 Fail 했지만 데이터베이스 다중화 후에는 모든 요청이 성공했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;스트레스 테스트&lt;/b&gt;: 5분 동안 동시 접속자 1500명으로 점진적 증가&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1938&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cFWHcY/btsLtahFaqP/n59gH57UAgkRAizWEWAoKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cFWHcY/btsLtahFaqP/n59gH57UAgkRAizWEWAoKK/img.png&quot; data-alt=&quot;&amp;amp;lt;Before&amp;amp;gt; RDS를 하나 사용하는 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cFWHcY/btsLtahFaqP/n59gH57UAgkRAizWEWAoKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcFWHcY%2FbtsLtahFaqP%2Fn59gH57UAgkRAizWEWAoKK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;676&quot; height=&quot;282&quot; data-origin-width=&quot;1938&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;Before&amp;gt; RDS를 하나 사용하는 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1912&quot; data-origin-height=&quot;798&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UyB77/btsLsrRKmPb/6tPATxrzPzRZK5gC5fHFUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UyB77/btsLsrRKmPb/6tPATxrzPzRZK5gC5fHFUk/img.png&quot; data-alt=&quot;&amp;amp;lt;After&amp;amp;gt; 데이터베이스를 다중화한 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UyB77/btsLsrRKmPb/6tPATxrzPzRZK5gC5fHFUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUyB77%2FbtsLsrRKmPb%2F6tPATxrzPzRZK5gC5fHFUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;657&quot; height=&quot;274&quot; data-origin-width=&quot;1912&quot; data-origin-height=&quot;798&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;After&amp;gt; 데이터베이스를 다중화한 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Response Time 약 10% 감소
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;평균: 70ms &amp;rarr; 63 ms&lt;/li&gt;
&lt;li&gt;최댓값: 394ms &amp;rarr; 246ms&lt;/li&gt;
&lt;li&gt;최솟값: 53ms &amp;rarr; 49ms&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. &lt;b&gt;스파이크 테스트&lt;/b&gt;: 3초 동안 동시접속자 1000명 즉시 증가&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HJUP8/btsLrITMaNa/1QD7k6dGkPkZFZD8MnwZs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HJUP8/btsLrITMaNa/1QD7k6dGkPkZFZD8MnwZs0/img.png&quot; data-alt=&quot;&amp;amp;lt;Before&amp;amp;gt; RDS를 하나 사용하는 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HJUP8/btsLrITMaNa/1QD7k6dGkPkZFZD8MnwZs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHJUP8%2FbtsLrITMaNa%2F1QD7k6dGkPkZFZD8MnwZs0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;448&quot; height=&quot;209&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;560&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;Before&amp;gt; RDS를 하나 사용하는 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NvM7r/btsLstPuzpD/PaRIVk8k1FST4CXjze3Okk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NvM7r/btsLstPuzpD/PaRIVk8k1FST4CXjze3Okk/img.png&quot; data-alt=&quot;&amp;amp;lt;After&amp;amp;gt; 데이터베이스를 다중화한 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NvM7r/btsLstPuzpD/PaRIVk8k1FST4CXjze3Okk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNvM7r%2FbtsLstPuzpD%2FPaRIVk8k1FST4CXjze3Okk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;447&quot; height=&quot;207&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;556&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;After&amp;gt; 데이터베이스를 다중화한 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;3초 동안 1000개의 요청에 대해 오류율 약 11% 감소: 34.8% &amp;rarr; 23.2% (Error Rate)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://creampuffy.tistory.com/209&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://creampuffy.tistory.com/209&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1734747479402&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Apache JMeter를 이용한 부하 테스트 및 리포트 생성&quot; data-og-description=&quot;서버의 성능을 최적화하기 위해선 어떤 작업이 필요할까요? 어떤 지표를 기준으로 성능을 측정할 것인지, 정의된 지표에 영향을 미치는 변수에는 무엇이 있는지, 해당 변수들의 변화가 성능에 &quot; data-og-host=&quot;creampuffy.tistory.com&quot; data-og-source-url=&quot;https://creampuffy.tistory.com/209&quot; data-og-url=&quot;https://creampuffy.tistory.com/209&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/oCKfO/hyXOp8Qqa4/9GkvimwLx2D1rapxs8THsK/img.png?width=800&amp;amp;height=652&amp;amp;face=0_0_800_652,https://scrap.kakaocdn.net/dn/bsPF0S/hyXOp17Pos/ueZ3Vsu3BSrSxcF0Dxmn8k/img.png?width=800&amp;amp;height=652&amp;amp;face=0_0_800_652,https://scrap.kakaocdn.net/dn/bUE08o/hyXOc2KeWG/NFedcZXjoSDhUcb442eqF1/img.png?width=1454&amp;amp;height=1214&amp;amp;face=0_0_1454_1214&quot;&gt;&lt;a href=&quot;https://creampuffy.tistory.com/209&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://creampuffy.tistory.com/209&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/oCKfO/hyXOp8Qqa4/9GkvimwLx2D1rapxs8THsK/img.png?width=800&amp;amp;height=652&amp;amp;face=0_0_800_652,https://scrap.kakaocdn.net/dn/bsPF0S/hyXOp17Pos/ueZ3Vsu3BSrSxcF0Dxmn8k/img.png?width=800&amp;amp;height=652&amp;amp;face=0_0_800_652,https://scrap.kakaocdn.net/dn/bUE08o/hyXOc2KeWG/NFedcZXjoSDhUcb442eqF1/img.png?width=1454&amp;amp;height=1214&amp;amp;face=0_0_1454_1214');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Apache JMeter를 이용한 부하 테스트 및 리포트 생성&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;서버의 성능을 최적화하기 위해선 어떤 작업이 필요할까요? 어떤 지표를 기준으로 성능을 측정할 것인지, 정의된 지표에 영향을 미치는 변수에는 무엇이 있는지, 해당 변수들의 변화가 성능에&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;creampuffy.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>Test</category>
      <category>JMeter</category>
      <category>부하테스트</category>
      <category>성능테스트</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/7</guid>
      <comments>https://immersive.tistory.com/7#entry7comment</comments>
      <pubDate>Sat, 21 Dec 2024 12:14:29 +0900</pubDate>
    </item>
    <item>
      <title>[Infra] 모니터링 서버 구축 (feat. Prometheus, Grafana)</title>
      <link>https://immersive.tistory.com/6</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;788&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZbR3k/btsLp5CtFE1/18ZfrMDS72fUMI83EuQ8E0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZbR3k/btsLp5CtFE1/18ZfrMDS72fUMI83EuQ8E0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZbR3k/btsLp5CtFE1/18ZfrMDS72fUMI83EuQ8E0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZbR3k%2FbtsLp5CtFE1%2F18ZfrMDS72fUMI83EuQ8E0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;652&quot; height=&quot;367&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;788&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.&amp;nbsp; 모니터링 서버 도입 목적&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 진행 중에는 대규모 트래픽이 발생하지 않아 모니터링의 필요성을 크게 느끼지 못했습니다. 그러나 최근 JMeter를 활용해 가상의 트래픽을 발생시켜 성능을 측정한 결과, 특정 지점 이후 요청이 실패하는 문제가 발생했습니다. 이때 실패 원인을 정확히 파악할 수 없었고, 향후 대량 트래픽 상황에 대비하기 위해 모니터링 서버의 필요성이 대두되었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존에는 요청 실패 시 로그를 수작업으로 분석하여 문제를 확인하고 대응했지만, 이 과정은 시간이 오래 걸리고 비효율적이었습니다. 이러한 한계를 극복하고 실시간으로 시스템 상태를 파악하기 위해 모니터링 서버를 구축하고자 합니다. 이번 블로그에서는 모니터링 서버를 구축하는 과정과 이를 통해 유의미한 지표를 분석하는 방법을 소개하겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;534&quot; data-origin-height=&quot;249&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdu7o2/btsLtv6WOwM/x9LzKaJ63TSekfyxI1KYGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdu7o2/btsLtv6WOwM/x9LzKaJ63TSekfyxI1KYGK/img.png&quot; data-alt=&quot;모니터링 개요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdu7o2/btsLtv6WOwM/x9LzKaJ63TSekfyxI1KYGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbdu7o2%2FbtsLtv6WOwM%2Fx9LzKaJ63TSekfyxI1KYGK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;534&quot; height=&quot;249&quot; data-origin-width=&quot;534&quot; data-origin-height=&quot;249&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;모니터링 개요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모니터링을 위해서 위 그림과 같이 별도의 서버에 Prometheus와 Grafana를 설치합니다. Prometheus는 모니터링 시스템으로, Spring Boot 애플리케이션의 매트릭을 Spring Actuator를 통해 9090포트로 데이터를 수집합니다. Grafana는 데이터 시각화 도구로, 3000 포트를 사용하며 Prometheus에서 수집한 데이터를 시각화합니다. 간단하게 Prometheus는 모니터링 서버의 백엔드 서비스, Grafana는 모니터링 서버의 프론트앤드 서비스로 생각하면 쉽습니다!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.&amp;nbsp; 도입 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Prometheus와 Grafana를 위한 EC2 인스턴스 준비&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EC2 인스턴스 생성
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Prometheus와 Grafana는 리소스 사용량이 적으므로 t2.micro 또는 t3.micro와 같은 작은 인스턴스 사용합니다.&lt;/li&gt;
&lt;li&gt;생성된 EC2 인스턴스에 SSH로 접속합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;보안 그룹 설정
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스프링 서버가 있는 EC2와 Prometheus EC2 간의 통신을 위해 Prometheus EC2의 9090 포트에 스프링 서버의 IP 또는 CIDR을 허용해줍니다.&lt;/li&gt;
&lt;li&gt;Prometheus는 9090포트를 사용하고 Grafana는 3000포트를 사용하므로 두 개의 포트를 보안 그룹에서 개방해줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Prometheus 설치&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Prometheus 다운로드&lt;/p&gt;
&lt;pre id=&quot;code_1734696403601&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo useradd --no-create-home --shell /bin/false prometheus
sudo mkdir /etc/prometheus
sudo mkdir /var/lib/prometheus
cd /tmp
curl -LO https://github.com/prometheus/prometheus/releases/download/v2.47.0/prometheus-2.47.0.linux-amd64.tar.gz
tar xvf prometheus-2.47.0.linux-amd64.tar.gz
cd prometheus-2.47.0.linux-amd64
sudo mv prometheus /usr/local/bin/
sudo mv promtool /usr/local/bin/
sudo mv consoles /etc/prometheus/
sudo mv console_libraries /etc/prometheus/
sudo mv prometheus.yml /etc/prometheus/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Prometheus 설정 파일 수정 : /etc/prometheus/prometheus.yml&lt;/p&gt;
&lt;pre id=&quot;code_1734696485516&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;scrape_configs:
  - job_name: 'spring_server'
    static_configs:
      - targets: ['&amp;lt;스프링 서버 IP&amp;gt;:8080']
      - targets: ['&amp;lt;스프링 서버 IP&amp;gt;:8081']&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 과정은 Prometheus가 데이터를 수집할 대상을 정의하는 설정입니다. 제 프로젝트는 Blue/Green 전략을 사용하기에 8080 포트와 8081 포트를 모두 대상에 등록해줍니다. 여기서 &amp;lt;스프링 서버 IP&amp;gt;는 모니터링 서버와 스프링 서버가 같은 VPC에 있는 경우 private IP를 사용하고 같은 VPC에 위치하지 않은 경우에는 public IP를 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Prometheus 서비스 등록&lt;/p&gt;
&lt;pre id=&quot;code_1734696516814&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo nano /etc/systemd/system/prometheus.service&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734696526962&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Unit]
Description=Prometheus
Wants=network-online.target
After=network-online.target

[Service]
User=prometheus
ExecStart=/usr/local/bin/prometheus --config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/var/lib/prometheus/
Restart=always

[Install]
WantedBy=multi-user.target&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Prometheus 실행&lt;/p&gt;
&lt;pre id=&quot;code_1734696554032&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl daemon-reload
sudo systemctl start prometheus
sudo systemctl enable prometheus&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Prometheus 확인: 브라우저에 &amp;lt;Prometheus EC2 Public IP&amp;gt;:9090 으로 접속해 상태를 확인합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;914&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ecEwXF/btsLs2DSdwT/a5NykcpOQTkMM1CZNkWNqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ecEwXF/btsLs2DSdwT/a5NykcpOQTkMM1CZNkWNqk/img.png&quot; data-alt=&quot;Prometheus 실행 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ecEwXF/btsLs2DSdwT/a5NykcpOQTkMM1CZNkWNqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FecEwXF%2FbtsLs2DSdwT%2Fa5NykcpOQTkMM1CZNkWNqk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;817&quot; height=&quot;247&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;914&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Prometheus 실행 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Grafana 설치&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Grafana 설치&lt;/p&gt;
&lt;pre id=&quot;code_1734696709180&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt-get update
sudo apt-get install -y grafana&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Grafana 서비스 시작&lt;/p&gt;
&lt;pre id=&quot;code_1734696734330&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl start grafana-server
sudo systemctl enable grafana-server&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Grafana 상태 확인: 브라우저에 &amp;lt;Grafana EC2 Public IP&amp;gt;:3000&amp;nbsp;으로 접속해 상태를 확인합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1554&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BpiQl/btsLsp0vIeB/N8c3ccsWPGdmd5oxBgPIv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BpiQl/btsLsp0vIeB/N8c3ccsWPGdmd5oxBgPIv1/img.png&quot; data-alt=&quot;Grafana 실행 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BpiQl/btsLsp0vIeB/N8c3ccsWPGdmd5oxBgPIv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBpiQl%2FbtsLsp0vIeB%2FN8c3ccsWPGdmd5oxBgPIv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;778&quot; height=&quot;400&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1554&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Grafana 실행 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그라파나를 처음 실행하면 ID와 PW 입력화면이 있는데, 기본 아이디와 비밀번호는 &quot;admin&quot;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. Prometheus를 Grafana에 데이터 소스로 추가&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Grafana에서 데이터 소스 설정
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Grafana 대시보드로 이동하여 &lt;b&gt;Configuration &amp;gt; Data Sources&lt;/b&gt;를 선택&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Add data source&lt;/b&gt; 버튼을 클릭하고 &lt;b&gt;Prometheus&lt;/b&gt;를 선택&lt;/li&gt;
&lt;li&gt;Prometheus URL에 http://&amp;lt;Prometheus EC2 Private IP&amp;gt;:9090를 입력&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Save &amp;amp; Test&lt;/b&gt;를 클릭하여 연결 상태를 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;대시보드 생성
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Create &amp;gt; Dashboard &amp;gt; Add New Panel&lt;/b&gt;을 클릭하여 대시보드를 생성&lt;/li&gt;
&lt;li&gt;spring_server 데이터를 선택하고 원하는 그래프를 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 스프링 서버의 메트릭스 활성화&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Spring Actuator과 Micrometer 의존성 추가&lt;/p&gt;
&lt;pre id=&quot;code_1734697129839&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- application.yml 설정&lt;/p&gt;
&lt;pre id=&quot;code_1734697316205&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;management:
  endpoints:
    web:
      exposure:
        include: '*'
  metrics:
    export:
      prometheus:
        enabled: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 매트릭스 엔드포인트 확인: http://&amp;lt;스프링 서버 IP&amp;gt;:8080/actuator/prometheus로 접속하여 메트릭스를 확인&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cj4cWD/btsLrbhCWsy/ggNNRovFCYpK6aAig27kzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cj4cWD/btsLrbhCWsy/ggNNRovFCYpK6aAig27kzk/img.png&quot; data-alt=&quot;매트릭스 확인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cj4cWD/btsLrbhCWsy/ggNNRovFCYpK6aAig27kzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcj4cWD%2FbtsLrbhCWsy%2FggNNRovFCYpK6aAig27kzk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;759&quot; height=&quot;329&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1310&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;매트릭스 확인&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.&amp;nbsp; 지표 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 좌측 하단의 Metric 탭에서 분석하고자 하는 지표를 선택하여 CPU 사용량, 메모리 사용량, HTTP 요청 수 등 다양한 지표를 분석할 수 있습니다. 또한, 우측 상단의 Time Series가 있는 탭에서 원하는 그래프 모양을 선택하여 지표들을 다양하게 시각화할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Spring Boot 애플리케이션에서 처리 중인 HTTP 요청 수에 대한 지표 분석&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1442&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KCTr1/btsLrzbzMGP/SipLMre2bKE55ZElSxoZK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KCTr1/btsLrzbzMGP/SipLMre2bKE55ZElSxoZK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KCTr1/btsLrzbzMGP/SipLMre2bKE55ZElSxoZK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKCTr1%2FbtsLrzbzMGP%2FSipLMre2bKE55ZElSxoZK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1442&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1442&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 시스템 CPU 사용률을 나타내는 지표&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1496&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byJMtJ/btsLsPxYAhO/oqPw1Xo2aSj86AkCfvDpKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byJMtJ/btsLsPxYAhO/oqPw1Xo2aSj86AkCfvDpKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byJMtJ/btsLsPxYAhO/oqPw1Xo2aSj86AkCfvDpKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyJMtJ%2FbtsLsPxYAhO%2FoqPw1Xo2aSj86AkCfvDpKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1496&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1496&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;</description>
      <category>Infra</category>
      <category>EC2</category>
      <category>Grafana</category>
      <category>Prometheus</category>
      <category>spring</category>
      <category>모니터링</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/6</guid>
      <comments>https://immersive.tistory.com/6#entry6comment</comments>
      <pubDate>Sat, 21 Dec 2024 01:53:51 +0900</pubDate>
    </item>
    <item>
      <title>[Infra] 데이터베이스 다중화</title>
      <link>https://immersive.tistory.com/5</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 데이터베이스 다중화의 개념&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 다중화는 여러 대의 데이터베이스 서버를 구성하여 시스템 전체의 성능 향상과 고가용성을 달성하는 기술입니다. 이는 MySQL Replication과 같은 &quot;&lt;b&gt;복제 기술&lt;/b&gt;&quot;을 사용해 데이터를 여러 서버로 동기화 하는 방식으로 구현됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;&lt;b&gt;복제&lt;/b&gt;&quot;는 한 서버의 데이터가 다른 서버로 동기화되는 것을 의미합니다. 특히, 소스 서버로부터 레플리카 서버로 데이터 복제가 이루어지는데, 소스 서버는 원본 데이터를 소유하고 있는 서버를 의미하며, 레플리카 서버는 복제된 데이터가 저장될 서버를 의미합니다. MySQL에서 데이터 복제가 일어나는 과정은 아래와 같습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 레플리케이션의 동작 원리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소스 서버에서 발생하는 모든 변경사항(이벤트)은 &quot;&lt;b&gt;바이너리 로그 파일&lt;/b&gt;&quot;에 순차적으로 기록됩니다. 바이너리 로그 파일에는 변경 내역, 테이블 구조 변경, 계정, 권한 변경 등의 정보가 저장됩니다. 또한, 바이너리 로그 안에 기록된 변경 기록 하나하나를 &quot;&lt;b&gt;바이너리 로그 이벤트&lt;/b&gt;&quot;라고 부릅니다. 레플리케이션(복제)는 레플리카 서버가 소스 서버의 바이너리 로그를 읽어와 서버에 순차적으로 적용하는 과정으로 이루어집니다. MySQL의 레플리케이션은 아래의 4개 의 쓰레드로 동작합니다. 트랜잭션 처리 쓰레드와 바이너리 로그 덤프 쓰레드는 소스 서버에서, 레플리케이션 I/O쓰레드와 레플리케이션 SQL 쓰레드는 레플리카 서버에서 실행됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;612&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baqpbW/btsLrmoI1n1/ksMi45syy3muAapfJaEck1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baqpbW/btsLrmoI1n1/ksMi45syy3muAapfJaEck1/img.png&quot; data-alt=&quot;MySQL의 레플리케이션 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baqpbW/btsLrmoI1n1/ksMi45syy3muAapfJaEck1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaqpbW%2FbtsLrmoI1n1%2FksMi45syy3muAapfJaEck1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;612&quot; height=&quot;333&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;612&quot; data-origin-height=&quot;333&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MySQL의 레플리케이션 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;트랙잭션 처리 쓰레드
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;SQL 쿼리를 실행에 소스 서버의 데이터에 적용하는 작업을 수행&lt;/li&gt;
&lt;li&gt;작업이 끝나면 작업한 내용을 &quot;&lt;b&gt;바이너리 로그&lt;/b&gt;&quot;에 기록&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;바이너리 로그 덤프 쓰레드
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;레플리케이션 작업이 레플리카 서버로부터 요청되었을 때, 소스 서버로부터 실행&lt;/li&gt;
&lt;li&gt;&quot;&lt;b&gt;바이너리 로그&lt;/b&gt;&quot;의 이벤트를 읽고, 레플리카 서버로 전송하는 역할을 수행&lt;/li&gt;
&lt;li&gt;이벤트를 읽을 때, 바이너리 로그 파일에 대해 잠금을 수행하고, 읽기 작업이 끝나면 잠금을 즉시 해제 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;레플리케이션 I/O 쓰레드
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;레플리카 서버에서 레플리케이션 작업이 시작되면, 레플리케이션 I/O 쓰레드가 생성됨&lt;/li&gt;
&lt;li&gt;레플리케이션 I/O 쓰레드는 소스 서버로부터 바이너리 로그를 가져옴&lt;/li&gt;
&lt;li&gt;소스 서버로부터 읽어온 바이너리 로그들은 레플리카 서버의 &quot;릴레이 로그&quot;에 저장됨&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;레플리케이션 SQL 쓰레드
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;릴레이 로그에 기록된 이벤트를 읽고 레플리카 서버의 데이터 파일에 반영하는 역할을 수행&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바이너리 로그를 저장하는 방식에는 변경이 일어난 데이터를 전부 바이너리 로그에 기록하는 Row 기반 바이너리 로그 포맷과, 데이터 자체를 로그에 저장하지 않고, 데이터 변경에 사용한 SQL 문을 바이너리 로그에 저장하는 Statement기반 바이너리 로그 포맷 등이 있습니다. 또한, 소스 서버로부터 레플리케이션 서버로 복제가 일어날 때, 비동기적으로 복제가 일어나는 방식과 반동기적으로 복제가 일어나는 방식이 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. 구현 개요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL의 레플리케이션 기술을 통해 현재 진행하는 프로젝트의 데이터베이스를 다중화하여 트래픽을 분산하고자 합니다. 하나의 RDS를 사용하는 기존 구조에서 2개의 EC2에 MySQL을 설치하여 데이터베이스를 다중화합니다. 쓰기 요청은 Source 서버로 요청이 되며 읽기 요청은 Replica 서버로 요청이 됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1132&quot; data-origin-height=&quot;738&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPTaMK/btsLozKjwZM/VfMNKjLCGOC8Re6y5KSuk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPTaMK/btsLozKjwZM/VfMNKjLCGOC8Re6y5KSuk0/img.png&quot; data-alt=&quot;구현 개요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPTaMK/btsLozKjwZM/VfMNKjLCGOC8Re6y5KSuk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPTaMK%2FbtsLozKjwZM%2FVfMNKjLCGOC8Re6y5KSuk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;535&quot; height=&quot;349&quot; data-origin-width=&quot;1132&quot; data-origin-height=&quot;738&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;구현 개요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션으로 요청되는 대부분의 쿼리가 읽기 요청이기에 이러한 구조로 Replica 서버를 추가로 증설하면 읽기 성능을 개선할 수 있을 것이라 예상됩니다. 또한, 향후에 레플리케이션 서버를 백업 목적으로 활용하여 소스 서버의 데이터가 유실된 상황에서도 손쉽게 복구할 수 있습니다. 백업용으로 레플케이션 서버를 사용한다면, 레플리케이션은 매우 빠른 속도로 진행되므로 백업용 서버에 스케줄러를 실행하여 간격을 두고 주기적으로 백업하는 구조 구축이 필요합니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. 구현 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. EC2에 MySQL 설치 (Source 서버, Replica 서버)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734670148957&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;apt-get update
apt-get install mysql-server
systemctl start mysql
systemctl enable mysql&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. MySQL 외부에서 접속 가능하도록 변경 (Source 서버, Replica 서버)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL은 기본적으로 127.0.0.1 즉, 로컬 호스트에서만 접속할 수 있습니다. 따라서,&lt;span style=&quot;background-color: #ffffff; color: #000000;&quot; data-token-index=&quot;0&quot;&gt; &quot;/etc/mysql/mysql.conf.d/mysqld.cnf&quot; &lt;/span&gt;에 들어가서&amp;nbsp;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;2&quot;&gt;bind-address&lt;/span&gt;와&amp;nbsp;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;4&quot;&gt;mysqlx-bind-address&lt;/span&gt; 설정을 주석처리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Source 서버&amp;nbsp; 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Source 서버 EC2의 MySQL 인스턴스에서 Replica 서버가 복제될 수 있도록 필요한 권한을 부여&lt;/p&gt;
&lt;pre id=&quot;code_1734670371134&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/usr/bin/mysql -u root -p   # MySQL 클라이언트 실행&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734670390229&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CREATE USER 'aurora-db-replica'@'&amp;lt;레플리카 서버 IP&amp;gt;' IDENTIFIED BY '&amp;lt;레플리카 서버 PW&amp;gt;';
GRANT REPLICATION SLAVE ON *.* TO 'aurora-db-replica'@'&amp;lt;레플리카 서버 IP&amp;gt;';
FLUSH PRIVILEGES;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Source 서버 변경 &quot;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;1&quot;&gt;/etc/mysql/mysql.conf.d/mysqld.cnf&quot;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734670500665&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server-id=1
log_bin = aurora-bin
sync_binlog = 1
binlog_format = MIXED
replicate-do-db = aurora&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 서버 재시작&lt;/p&gt;
&lt;pre id=&quot;code_1734670569510&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;systemctl restart mysql&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 레플리카 서버 변경&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Replica 서버 변경 &quot;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;1&quot;&gt;/etc/mysql/mysql.conf.d/mysqld.cnf&quot;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734670632396&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server-id= 2 # Source와 무조건 아이디가 달라야 한다.
relay_log=aurora-relay-bin
relay_log_purge=ON
read_only=1
replicate-do-db = aurora&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Source 서버와의 연결을 설정하고, 복제를 시작&lt;/p&gt;
&lt;pre id=&quot;code_1734670705159&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;STOP REPLICA;

CHANGE REPLICATION SOURCE TO 
SOURCE_HOST='&amp;lt;소스 서버 IP&amp;gt;', 
SOURCE_USER='aurora-db-replica', 
SOURCE_PASSWORD='&amp;lt;소스 서버 PW&amp;gt;', 
SOURCE_LOG_FILE='aurora-bin.000001',
SOURCE_LOG_POS=157, 
GET_SOURCE_PUBLIC_KEY=1;

START REPLICA;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Replica 상태 확인&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1734670782728&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SHOW REPLICA STATUS\G;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. application.yml 설정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734670865252&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  datasource:
    source:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://&amp;lt;소스 서버 IP&amp;gt;:3306/aurora
      username: trackers
      password: &amp;lt;소스 서버 PW&amp;gt;
      maximumPoolSize: 15
      poolName: HikariCP
      readOnly: false
    replica:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://&amp;lt;레플리카 서버 IP&amp;gt;:3306/aurora
      username: trackers
      password: &amp;lt;레플리카 서버 PW&amp;gt;
      poolName: HikariCP
      readOnly: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6. DataSourceConfig.java, RoutingDataSource.java&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 스프링 애플리케이션에서 다중 데이터 소스를 구성하는 파일 설정를 추가합니다&lt;/p&gt;
&lt;pre id=&quot;code_1734671020467&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Profile({&quot;dev&quot;, &quot;prod&quot;})
@Configuration
public class DataSourceConfig {

    private static final String SOURCE_SERVER = &quot;SOURCE&quot;;
    private static final String REPLICA_SERVER = &quot;REPLICA&quot;;

    @Bean
    @Qualifier(SOURCE_SERVER)
    @ConfigurationProperties(prefix = &quot;spring.datasource.source&quot;)
    public DataSource sourceDataSource() { return DataSourceBuilder.create().build(); }

    @Bean
    @Qualifier(REPLICA_SERVER)
    @ConfigurationProperties(prefix = &quot;spring.datasource.replica&quot;)
    public DataSource replicaDataSource() { return DataSourceBuilder.create().build(); }

    @Bean
    public DataSource routingDataSource(
            @Qualifier(SOURCE_SERVER) final DataSource sourceDataSource,
            @Qualifier(REPLICA_SERVER) final DataSource replicaDataSource
    ) {
        final RoutingDataSource routingDataSource = new RoutingDataSource();

        final HashMap&amp;lt;Object, Object&amp;gt; dataSourceMap = new HashMap&amp;lt;&amp;gt;();
        dataSourceMap.put(SOURCE, sourceDataSource);
        dataSourceMap.put(REPLICA, replicaDataSource);

        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(sourceDataSource);

        return routingDataSource;
    }

    @Bean
    @Primary
    public DataSource dataSource() {
        final DataSource determinedDataSource = routingDataSource(sourceDataSource(), replicaDataSource());
        return new LazyConnectionDataSourceProxy(determinedDataSource);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- 애플리케이션 레벨에서 소스 서버와 레플리카 서버에 대한 요청을 분기하는 코드를 작성합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1734671301969&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        final String currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName();
        final boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();

        if(isReadOnly){
            log.info(currentTransactionName + &quot; Transaction:&quot; + &quot;Replica 서버로 요청합니다.&quot;);
            return REPLICA;
        }

        log.info(currentTransactionName + &quot; Transaction:&quot; + &quot;Source 서버로 요청합니다.&quot;);
        return SOURCE;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. 트러블 슈팅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. DataGrip에서 source-db EC2의 mysql에 접속이 안되는 문제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 원인 : 권한 부여 문제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 해결 : MySQL 데이터베이스에서 &lt;b&gt;사용자를 생성&lt;/b&gt;하고, &lt;b&gt;권한을 부여&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734671563361&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CREATE USER 'trackers'@'%' IDENTIFIED BY '&amp;lt;데이터베이스 PW&amp;gt;';
GRANT ALL PRIVILEGES ON *.* TO 'trackers'@'%';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Source DB에는 테이블이 생겼지만 Replica DB에서는 테이블이 생기지 않는 문제&lt;/p&gt;
&lt;pre id=&quot;code_1734671713361&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;mysql&amp;gt; stop replica; 
mysql&amp;gt; set global sql_replica_skip_counter=1; 
mysql&amp;gt; start replica;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 중간에 복제가 중단되어 source db와 replica db 사이에 데이터 정합성이 생기는 문제&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;609&quot; data-origin-height=&quot;89&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/K4Ty8/btsLpchf7z2/eMzSBcVmZ4PKAlNYfOTkCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/K4Ty8/btsLpchf7z2/eMzSBcVmZ4PKAlNYfOTkCK/img.png&quot; data-alt=&quot;소스 DB의 바이너리 로그 상태&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/K4Ty8/btsLpchf7z2/eMzSBcVmZ4PKAlNYfOTkCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FK4Ty8%2FbtsLpchf7z2%2FeMzSBcVmZ4PKAlNYfOTkCK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;609&quot; height=&quot;89&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;609&quot; data-origin-height=&quot;89&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;소스 DB의 바이너리 로그 상태&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 원인: 현재 Source DB의 status는 aurora-bin.00007 이지만 Replica DB에서는 aurora-bin.00004에서 문제가 생겨 그 동안 복제가 이루어지지 않아 데이터 정합성 문제가 발생&lt;/p&gt;
&lt;pre id=&quot;code_1734671829773&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;STOP REPLICA;
CHANGE SOURCE TO MASTER_LOG_FILE='aurora-bin.000007', MASTER_LOG_POS=14038;
START replica;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 명령어를 통해 복제 지점을 강제로 변경하여 복제는 다시 재개됩니다. 하지만, 그 동안 있었던 트랜잭션이 반영 되지 않아 여전히 source db와 replica db 사이에 정합성 문제가 발생하고 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1734671897803&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Source DB에서 데이터 Dump한 백업 sql 생성
$ mysqldump -u [사용자 계정] -p [원본 데이터베이스명] &amp;gt; [생성할 백업 파일명].sql
$ mysqldump -u root -p aurora &amp;gt; 2024_9_13_aurora_backup.sql

# Source 서버에서 scp를 사용해 Replica 서버로 파일 이동
$ scp -i {db pem 키} {이동시킬 파일} ubuntu@{이동시킬 서버 IP}:/home/ubuntu
$ scp -i aurora-db.pem 2024_9_13_aurora_backup.sql ubuntu@{레플리카 서버 IP}:/home/ubuntu 

# 복원 전에 Replica 서버에서 DB 생성
mysql&amp;gt; CREATE DATABASE aurora;

# Replica 서버에서 이동 받은 sql 파일을 사용해 데이터 복원
$ mysql -u [사용자 계정] -p [복원할 DB] &amp;lt; [백업된 DB].sql 
$ mysql -u root -p aurora &amp;lt; 2024_9_13_aurora_backup.sql&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 과정을 통해 source db에 있는 데이터를 모두 백업하여 replica db에 적용하여 데이터 정합성 문제를 해결 할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고문헌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://velog.io/@backfox/무뇽이와-알아보는-대규모-데이터-관리-데이터베이스-복제하기리플리케이션-f4pota6h#싱글-리플리카-복제-구성&quot;&gt;https://velog.io/@backfox/무뇽이와-알아보는-대규모-데이터-관리-데이터베이스-복제하기리플리케이션-f4pota6h#싱글-리플리카-복제-구성&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1734671426013&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;대규모 데이터 관리 - 데이터베이스 복제하기(리플리케이션)&quot; data-og-description=&quot;이 블로그 보고 데이터베이스 설계했다면 얼마나 좋았을까?&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@backfox/무뇽이와-알아보는-대규모-데이터-관리-데이터베이스-복제하기리플리케이션-f4pota6h#싱글-리플리카-복제-구성&quot; data-og-url=&quot;https://velog.io/@backfox/무뇽이와-알아보는-대규모-데이터-관리-데이터베이스-복제하기리플리케이션-f4pota6h&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bmkhSh/hyXOfdTVu3/JdDSBzvBtQlsdZYToKUN9K/img.png?width=990&amp;amp;height=536&amp;amp;face=0_0_990_536,https://scrap.kakaocdn.net/dn/biIZqW/hyXOmD62WX/IJuZjPcuMC2zFPkLvVqCiK/img.png?width=990&amp;amp;height=536&amp;amp;face=0_0_990_536,https://scrap.kakaocdn.net/dn/EJPRU/hyXOcO0aSw/tNTeATyWRAFNEdGjUuSClK/img.png?width=960&amp;amp;height=1860&amp;amp;face=0_0_960_1860&quot;&gt;&lt;a href=&quot;https://velog.io/@backfox/무뇽이와-알아보는-대규모-데이터-관리-데이터베이스-복제하기리플리케이션-f4pota6h#싱글-리플리카-복제-구성&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@backfox/무뇽이와-알아보는-대규모-데이터-관리-데이터베이스-복제하기리플리케이션-f4pota6h#싱글-리플리카-복제-구성&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bmkhSh/hyXOfdTVu3/JdDSBzvBtQlsdZYToKUN9K/img.png?width=990&amp;amp;height=536&amp;amp;face=0_0_990_536,https://scrap.kakaocdn.net/dn/biIZqW/hyXOmD62WX/IJuZjPcuMC2zFPkLvVqCiK/img.png?width=990&amp;amp;height=536&amp;amp;face=0_0_990_536,https://scrap.kakaocdn.net/dn/EJPRU/hyXOcO0aSw/tNTeATyWRAFNEdGjUuSClK/img.png?width=960&amp;amp;height=1860&amp;amp;face=0_0_960_1860');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;대규모 데이터 관리 - 데이터베이스 복제하기(리플리케이션)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 블로그 보고 데이터베이스 설계했다면 얼마나 좋았을까?&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=NPVJQz_YF2A&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=NPVJQz_YF2A&lt;/a&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;youtube&quot; data-video-url=&quot;https://www.youtube.com/watch?v=NPVJQz_YF2A&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/bdG8yf/hyXOfybX4F/NhvTda9y5VjkROyuBWdPo0/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=968_186_1060_288,https://scrap.kakaocdn.net/dn/cze154/hyXOcnWDHb/VPZ42mp0ZCGL1g4PwfXBZ1/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=968_186_1060_288&quot; data-video-width=&quot;860&quot; data-video-height=&quot;484&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;484&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-title=&quot;[10분 테코톡] 앤지의 DB Replication&quot; data-original-url=&quot;&quot;&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/NPVJQz_YF2A&quot; width=&quot;860&quot; height=&quot;484&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Infra</category>
      <category>EC2</category>
      <category>mysql</category>
      <category>Replication</category>
      <category>다중화</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/5</guid>
      <comments>https://immersive.tistory.com/5#entry5comment</comments>
      <pubDate>Fri, 20 Dec 2024 14:22:57 +0900</pubDate>
    </item>
    <item>
      <title>[Spring/WebSocket] 실시간 음성 채팅 구현 (feat. Socket.IO)</title>
      <link>https://immersive.tistory.com/4</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;626&quot; data-origin-height=&quot;275&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ce5q6z/btsJF4x7H8e/n5vboPKCZc33aAVlEvrjL0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ce5q6z/btsJF4x7H8e/n5vboPKCZc33aAVlEvrjL0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ce5q6z/btsJF4x7H8e/n5vboPKCZc33aAVlEvrjL0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fce5q6z%2FbtsJF4x7H8e%2Fn5vboPKCZc33aAVlEvrjL0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;626&quot; height=&quot;275&quot; data-origin-width=&quot;626&quot; data-origin-height=&quot;275&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 요구 사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로젝트 목표: 청각 장애인과 일반 사용자가 음성으로 통화할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;들리담&quot;은 일반 사용자와 청각 장애인이 실시간으로 음성으로 채팅할 수 있는 플랫폼입니다. 우선, 최초 회원가입 시에 청각 장애인의 목소리를 학습합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에 청각 장애인이 일반 사용자와 음성 채팅을 할 때, 청각 장애인에게는 채팅 창이 표시됩니다. 청각 장애인에게 보내는 일반 사용자의 음성은 STT(Speech-To-Text) 과정을 거쳐 텍스트로 변환되어 청각 장애인의 채팅창에 표시됩니다. 청각 장애인은 발화 내용을 채팅창에 입력을 하면 발화 내용이 미리 학습된 본인의 목소리로 변조가 되어 상대방에게 전달됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 청각 장애인과 일반 사용자의 실시간 통신을 백엔드에서 어떻게 구축하였는지 알아보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 구현 개요&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2155&quot; data-origin-height=&quot;1158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pivah/btsJG4YQEAP/LOKJ8ThzEflQSMd1qKOQCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pivah/btsJG4YQEAP/LOKJ8ThzEflQSMd1qKOQCk/img.png&quot; data-alt=&quot;서비스 구조도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pivah/btsJG4YQEAP/LOKJ8ThzEflQSMd1qKOQCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fpivah%2FbtsJG4YQEAP%2FLOKJ8ThzEflQSMd1qKOQCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;756&quot; height=&quot;406&quot; data-origin-width=&quot;2155&quot; data-origin-height=&quot;1158&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;서비스 구조도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;들리담&quot;은 위와 같이 프론트는 React를, 백엔드는 스프링 프레임워크를, 사용자 음성 변조는 FastAPI를 사용하여 구현했습니다. 실시간 통신 파이프라인을 구축하기 위해 React와 Spring은 Socket.IO를 사용하여 연결되며, Spring과 FastAPI는 WebSocket을 통해 연결됩니다. 즉, Spring 서버는 React와 FastAPI 사이를 프록시해주는 역할을 하면서 필요한 데이터를 DB에 저장합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;일반 사용자&lt;br /&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;일반 사용자가 단말기에 발화&lt;/li&gt;
&lt;li&gt;프론트(리액트)에서 &quot;음성&quot;이 &quot;텍스트&quot;로 변환되어 서버(스프링)로 전달됨&lt;/li&gt;
&lt;li&gt;&quot;텍스트&quot;가 DB에 저장됨&lt;/li&gt;
&lt;li&gt;&quot;텍스트&quot;가 Socket.IO로 연결이 된 상대방(청각 장애인)에게 전달됨&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;청각 장애인 사용자
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;청각 장애인 사용자가 채팅방에 &amp;ldquo;텍스트&amp;rdquo; 입력&lt;/li&gt;
&lt;li&gt;&amp;ldquo;텍스트&amp;rdquo;가 서버(스프링)로 전달됨&lt;/li&gt;
&lt;li&gt;&quot;텍스트&quot;가 DB에 저장됨&lt;/li&gt;
&lt;li&gt;서버(Spring)에서 &amp;ldquo;텍스트&amp;rdquo;를 FastAPI로 전달&lt;/li&gt;
&lt;li&gt;FastAPI에서 &quot;텍스트&quot;를 사용자 목소리로 변조하여 &amp;ldquo;음성 파일&amp;rdquo;을 서버로 전달&lt;/li&gt;
&lt;li&gt;서버(Spring)에서 &amp;ldquo;음성 파일&amp;rdquo;을 프론트(React)로 전달&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트와 백엔드 사이에 웹소켓 연결이 생성되었다는 가정하에 일반 사용자와 청각 장애인이 음성 채팅 시, 위와 같은 플로우로 진행됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 기술 스택&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. WebSocket&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;677&quot; data-origin-height=&quot;145&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zDmV1/btsJGnSnzBB/LUqXpG3B8DdZJsfcokPuQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zDmV1/btsJGnSnzBB/LUqXpG3B8DdZJsfcokPuQ1/img.png&quot; data-alt=&quot;웹소켓&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zDmV1/btsJGnSnzBB/LUqXpG3B8DdZJsfcokPuQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzDmV1%2FbtsJGnSnzBB%2FLUqXpG3B8DdZJsfcokPuQ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;621&quot; height=&quot;133&quot; data-origin-width=&quot;677&quot; data-origin-height=&quot;145&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;웹소켓&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket은 클라이언트와 서버 간에 양방향 통신을 가능하게 해주는 &lt;b&gt;프로토콜&lt;/b&gt;입니다. WebSocket은 HTTP 프로토콜과 달리 실시간으로 데이터를 주고받을 수 있는 지속적인 연결을 제공해 줍니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에 HTTP Handshake 요청을 통해 연결을 설정하며, 연결이 설정되면 WebSocket 프로토콜로 업그레이드 됩니다.업그레이드됩니다. 구체적으로 서버가 WebSocket 연결을 수락하면 101 Switching Protocols 응답을 반환하고, 이후 HTTP 연결은 WebSocket으로 업그레이드됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket의 주요 특징으로는 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;양방향 통신 (Full-Duplex) :&amp;nbsp;&lt;/b&gt;클라이언트는 요청을 보낼 수 있고, 서버도 별도의 요청 없이 클라이언트에게 데이터를 푸시할 수 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지속적인 연결&lt;/b&gt;: HTTP처럼 요청-응답 주기를 반복하지 않아도 양측에서 언제든지 데이터를 주고받을 수 있음 (&lt;b&gt;실시간성&lt;/b&gt;)&lt;/li&gt;
&lt;li&gt;낮은 오버헤드: WebSocket은 연결이 수립된 이후에 HTTP와 달리 요청-응답마다 헤더와 기타 정보들이 포함되지 않음&lt;/li&gt;
&lt;li&gt;단일 TCP 연결: WebSocket은 단일 TCP 연결을 유지하며, 이를 통해 다중 메시지를 전달할 수 있음&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2.  Socket.IO&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Socket.IO 웹소켓을 지원하면서도 브라우저 호환성 및 다양한 네트워크 환경에 맞게 &lt;b&gt;폴백(fallback)&lt;/b&gt; 기술을 포함한 &lt;b&gt;JavaScript 라이브러리&lt;/b&gt;입니다. 보통 Spring에서는 STOMP와 SockJS를 사용하여 웹소켓 연결을 구현하는 것이 일반적이지만 다음과 같은 특징 때문에 Socket.IO를 채택했습니다. STOMP를 사용하여 실시간 일대일 채팅을 구현한 내용은 다음 글에서 다루도록 하겠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;자동 재연결: 네트워크가 불안정할 때 자동으로 재연결 시도&lt;/li&gt;
&lt;li&gt;풀백 지원: WebSocket이 지원되지 않는 환경(Safari, Firefox, Opera 등)에서는 &lt;b&gt;Long Polling&lt;/b&gt; 등의 폴백 메커니즘을 제공&lt;/li&gt;
&lt;li&gt;이벤트 기반 통신: 채팅과 같은 실시간 이벤트 처리에 유리&lt;/li&gt;
&lt;li&gt;네임스페이스 및 룸: 특정 사용자 그룹에게만 메시지를 전송하는 기능이 쉽게 구현됨&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서 Socket.IO를 사용하려면 Netty와 같은 Socket.IO 서버 라이브러리를 통합하는 방식으로 구현해야 합니다. Socket.IO 서버를 구현하기 위한 설정은 뒤에서 바로 알아보도록 하겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 실습 및 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 의존성 추가&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'com.corundumstudio.socketio:netty-socketio:2.0.3'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.java-websocket:Java-WebSocket:1.5.3'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Socket.IO와 WebSocket을 사용하기 위한 의존성 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- SocketIOServer 생성&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;996&quot; data-origin-height=&quot;1123&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPfJQA/btsJItwjHiM/m80DT0ry1Kdo8kFtwMAtDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPfJQA/btsJItwjHiM/m80DT0ry1Kdo8kFtwMAtDK/img.png&quot; data-alt=&quot;Socket.io 서버와 Spring Boot&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPfJQA/btsJItwjHiM/m80DT0ry1Kdo8kFtwMAtDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPfJQA%2FbtsJItwjHiM%2Fm80DT0ry1Kdo8kFtwMAtDK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;424&quot; height=&quot;478&quot; data-origin-width=&quot;996&quot; data-origin-height=&quot;1123&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Socket.io 서버와 Spring Boot&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Socket.IO 서버는 Netty 서버를 사용해서 만들어진 기술이기에 위와 같이 Socket.IO 서버를 별도로 띄워야 합니다. Spring Boot 내에서 HTTP 요청을 처리하는 8080 포트의 Tomcat 서버와 실시간 웹소켓 통신을 처리하는 9092 포트의 Netty Socket.io 서버가 나뉘어서 구동된 구조입니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Configuration
public class SocketIOConfig {

    @Bean
    public SocketIOServer socketIOServer(ConfigUtil configUtil) {
        com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();

        config.setHostname(configUtil.getHost());   // Socket.IO 서버의 호스트
        config.setPort(Integer.parseInt(configUtil.getSocketPort()));   // Socket.IO 서버가 사용할 포트를 설정
        config.setOrigin(&quot;*&quot;);  // 모든 도메인에서의 요청을 허용하도록 설정
        config.setAllowCustomRequests(true);    // 사용자 정의 요청을 허용
        config.setTransports(Transport.WEBSOCKET, Transport.POLLING);

        return new SocketIOServer(config);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드를 통해 SocketIOServer를 스프링 부트에서 별도의 빈으로 띄울 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- SocketIOCommandLineRunner.java&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class SocketIOCommandLineRunner implements CommandLineRunner {

    private final SocketIOServer server;

    @Autowired
    public SocketIOCommandLineRunner(SocketIOServer server) {
        log.info(&quot;[SocketIOCommandLineRunner] SocketIOServer Initialized&quot;);
        this.server = server;
    }

    @Override
    public void run(String... args) throws Exception {
        log.info(&quot;[SocketIOCommandLineRunner] SocketIOServer Running&quot;);
        server.start();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SocketIOCommandLineRunner은 스프링 부트 애플리케이션에서 &lt;a style=&quot;color: #000000;&quot; href=&quot;http://socket.io/&quot; data-token-index=&quot;1&quot;&gt;&lt;span&gt;Socket.IO&lt;/span&gt;&lt;/a&gt; 서버를 시작하기 위한 구현체입니다. 이 구현체를 통해 스프링 애플리케이션이 시작될 때, Socket.IO 서버가 자동으로 실행됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;322&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dEmX5l/btsJHCOztjr/iUAv4SNpQxwkZl3BBCdWxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dEmX5l/btsJHCOztjr/iUAv4SNpQxwkZl3BBCdWxk/img.png&quot; data-alt=&quot;터미널에서 Socket.IO 서버가 구동됨&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dEmX5l/btsJHCOztjr/iUAv4SNpQxwkZl3BBCdWxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdEmX5l%2FbtsJHCOztjr%2FiUAv4SNpQxwkZl3BBCdWxk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;716&quot; height=&quot;179&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;322&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;터미널에서 Socket.IO 서버가 구동됨&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- WebSocketUtil.java&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
public class WebSocketUtil extends WebSocketClient {

    private final SocketIOClient socketIOClient;

    public WebSocketUtil(URI serverUri, Draft protocolDraft, SocketIOClient socketIOClient) {
        super(serverUri, protocolDraft);
        this.socketIOClient = socketIOClient;
    }

    public interface OnMessageCallback {
        void onMessage(byte[] audioData);
    }

    public OnMessageCallback onMessageCallback;

    @Override
    public void onMessage(ByteBuffer bytes) {
        log.info(&quot;[WebSocketUtil]-[onMessage] Received audio data from FastAPI&quot;);

        // 콜백을 통해서 Spring 서버로 전송
        if (onMessageCallback != null) {
            onMessageCallback.onMessage(bytes.array());
        }
    }

    @Override
    public void onMessage(String message) {
        log.info(&quot;[WebSocketUtil]-[onMessage] Received message {}&quot;, message);
    }

    @Override
    public void onOpen(ServerHandshake handshake) {
        log.info(&quot;[WebSocketUtil] WebSocket connection opened&quot;);
    }

    @Override
    public void onClose(int code, String reason, boolean remote) {
        log.info(&quot;[WebSocketUtil] WebSocket connection closed: {}&quot;, reason);
    }

    @Override
    public void onError(Exception ex) {
        log.error(&quot;[WebSocketUtil] WebSocket error occurred&quot;, ex);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocketUtil 클래스는 &lt;b&gt;FastAPI 서버와의 WebSocket 연결을 관리&lt;/b&gt;하고, 수신된 메시지를 처리하는 기능을 제공합니다. WebSocket을 통해 수신된 바이너리 데이터와 문자열 메시지를 처리하고 수신된 데이터를 외부로 전달하기 위한 &lt;b&gt;콜백 메커니즘을 제공&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- WebSocketProxy.java&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class WebSocketProxy {

    @Autowired
    private FFmpegConfig ffmpegConfig;

    private final String fastApiEndpoint;
    private final SocketIOServer server;
    private final SocketIONamespace namespace;

    private WebSocketUtil fastAPIWebSocket;
    private Timer timer;

    private final AudioConverter audioConverter;
    private final ChatMessageService chatMessageService;
    private final UserService userService;

    @Autowired
    public WebSocketProxy(
            SocketIOServer server,
            ConfigUtil configUtil,
            ChatMessageService chatMessageService,
            UserService userService
    ) {
        this.server = server;
        this.fastApiEndpoint = configUtil.getFastApiEndpoint();
        this.namespace = server.addNamespace(&quot;/websocket&quot;);    
        this.namespace.addConnectListener(onConnected());
        this.namespace.addDisconnectListener(onDisconnected());
        this.namespace.addEventListener(&quot;textMessage&quot;, String.class, textMessageListener());    
        this.chatMessageService = chatMessageService;
        this.userService = userService;
        this.audioConverter = new AudioConverter(ffmpegConfig);
    }

    private void connectFastAPI(Timer timer, SocketIOClient client){
        timer.scheduleAtFixedRate(new TimerTask() {
            @Override
            public void run() {
                try {
                    if(fastAPIWebSocket == null || fastAPIWebSocket.isClosed()) {
                        fastAPIWebSocket = new WebSocketUtil(
                                new URI(fastApiEndpoint),
                                new Draft_6455(),
                                client
                        );
                        fastAPIWebSocket.connectBlocking();
                    }
                } catch (Exception e) {
                    log.error(&quot;[WebRTCProxy]-[connectFastAPI] WebSocket Connection Failed&quot;);
                }
            }
        }, 0, 60);
    }

    private ConnectListener onConnected() {
        return client -&amp;gt; {
            HandshakeData handshakeData = client.getHandshakeData();
            log.info(&quot;[WebRTCProxy]-[Socketio]-[{}] Connected to WebRTCProxy Socketio through '{}'&quot;,
                    client.getSessionId().toString(),
                    handshakeData.getUrl());

            // 클라이언트가 연결 시에 데이터베이스의 채팅방 ID로 WebSocket 룸에 참가
            String chatRoomId = client.getHandshakeData().getSingleUrlParam(&quot;chatRoomId&quot;);
            if(chatRoomId == null) {
                log.error(&quot;chatRoomId is null. Cannot join the room.&quot;);
                return;
            }
            log.info(&quot;String chatRoomId: {}&quot;, chatRoomId);
            client.joinRoom(chatRoomId);

            timer = new Timer();
            connectFastAPI(timer, client);
        };
    }

    private DisconnectListener onDisconnected() {
        return client -&amp;gt; {
            log.info(&quot;[WebRTCProxy]-[Socketio]-[{}] Disconnected from WebSocketProxy Socketio Module&quot;,
                    client.getSessionId().toString());

            if(timer != null) {
                timer.cancel();
                timer.purge();
            }

            if(fastAPIWebSocket != null) {
                fastAPIWebSocket.close();
            }
        };
    }


    private DataListener&amp;lt;String&amp;gt; textMessageListener() {
        return (client, messagePayload, ackSender) -&amp;gt; {
            try {
                ObjectMapper objectMapper = new ObjectMapper();
                ChatMessageRequestDTO chatMessageRequestDTO = objectMapper.readValue(messagePayload, ChatMessageRequestDTO.class);

                User sender = userService.findUserByCustomId(chatMessageRequestDTO.getSenderId());
                chatMessageService.save(chatMessageRequestDTO, sender.getName());

                if(!sender.isDisabled()){   // 비장애인 사용자
                    namespace.getRoomOperations(chatMessageRequestDTO.getChatRoomId().toString())
                            .sendEvent(&quot;messageData&quot;, chatMessageRequestDTO);
                }
                else {      // 청각 장애인 사용자
                    if (fastAPIWebSocket != null &amp;amp;&amp;amp; fastAPIWebSocket.isOpen()) {
                        // FastAPI 서버에 문자열 메시지 전송
                        fastAPIWebSocket.send(chatMessageRequestDTO.getMessage());
                        // FastAPI 서버로부터 응답 대기 및 오디오 데이터 수신
                        fastAPIWebSocket.onMessageCallback = audioData -&amp;gt; {
                            // 클라이언트로 오디오 데이터 전송
                            namespace.getRoomOperations(chatMessageRequestDTO.getChatRoomId().toString())
                                    .sendEvent(&quot;audioData&quot;, Base64.getEncoder().encodeToString(audioData));

                            log.info(&quot;[WebRTCProxy]-[Socketio] Sent audio data to client: {}&quot;, client.getSessionId().toString());
                        };
                    } else {
                        log.error(&quot;[WebRTCProxy]-[Socketio] FastAPI WebSocket is not connected&quot;);
                    }
                }
            } catch (Exception ex) {
                log.error(&quot;[WebRTCProxy]-[Socketio] Exception while processing text message&quot;, ex);
            }
        };
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 클래스는 &lt;b&gt;Socket.IO&lt;/b&gt;를 이용해 클라이언트와의 실시간 통신을 처리하고, &lt;b&gt;FastAPI&lt;/b&gt; 서버와 WebSocket을 통해 오디오 데이터를 주고받는 역할을 담당하는 &lt;b&gt;Spring Component&lt;/b&gt;입니다. 이 클래스는 클라이언트로부터 메시지를 수신하고, 이를 FastAPI 서버로 전달하며, 받은 오디오 데이터를 클라이언트에 다시 전송하는 과정을 담당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 &lt;b&gt;onConnected()&lt;/b&gt;에서 , 채팅방 개념을 적용해 메시지를 처리합니다. 클라이언트의 세션 정보를 로깅하고, chatRoomId를 받아와 해당 채팅방에 클라이언트를 추가합니다. 또한, &lt;b&gt;FastAPI 서버와의 WebSocket 연결&lt;/b&gt;을 설정하기 위한 타이머를 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;textMessageListener()&lt;/b&gt;는 &lt;b&gt;클라이언트로부터 메시지를 수신&lt;/b&gt;했을 때 호출됩니다. 수신된 메시지를 &lt;b&gt;DTO&lt;/b&gt;로 변환한 후, 유저 정보에 따라 적절한 로직을 수행합니다. &lt;b&gt;비장애인&lt;/b&gt; 사용자의 경우 메시지를 해당 채팅방에 전송하고, &lt;b&gt;청각 장애인&lt;/b&gt; 사용자의 경우 FastAPI 서버에 메시지를 전송하고, FastAPI로부터 받은 &lt;b&gt;오디오 데이터를 클라이언트에게 전달&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.&amp;nbsp; 트러블 슈팅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 로컬 환경에서는 웹소켓에 접속(onConnect)이 되지만 배포 환경에서는 접속이 안 되는 문제&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-08-24 오전 12.15.11.png&quot; data-origin-width=&quot;2062&quot; data-origin-height=&quot;1504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTPJ76/btsJHOOXsXn/lQinNhWPkKyX4CtQbfmdR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTPJ76/btsJHOOXsXn/lQinNhWPkKyX4CtQbfmdR1/img.png&quot; data-alt=&quot;EC2에 Connect 요청이 거부됨&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTPJ76/btsJHOOXsXn/lQinNhWPkKyX4CtQbfmdR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTPJ76%2FbtsJHOOXsXn%2FlQinNhWPkKyX4CtQbfmdR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;693&quot; height=&quot;505&quot; data-filename=&quot;스크린샷 2024-08-24 오전 12.15.11.png&quot; data-origin-width=&quot;2062&quot; data-origin-height=&quot;1504&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;EC2에 Connect 요청이 거부됨&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문제 상황: EC2에 웹소켓 연결 요청이 들어가지 않음&lt;/li&gt;
&lt;li&gt;원인: docker container 기동 시 포트를 열어주지 않아 요청이 컨테이너 내부로 들어가지 못함&lt;/li&gt;
&lt;li&gt;해결: docker-compose-blue.yml과 docker-compose-green.yml 파일에 9092 포트 명시하여 Spring Boot Application의 포트를 열어줌&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. blue, green 나누어서 배포하여 blue, green에서 모두 9092 사용 시 포트 충돌 문제&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1820&quot; data-origin-height=&quot;488&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dRDJsO/btsJIUAISxY/FRVuXK7DbAzHbOJBd4jueK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dRDJsO/btsJIUAISxY/FRVuXK7DbAzHbOJBd4jueK/img.png&quot; data-alt=&quot;포트 충돌&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dRDJsO/btsJIUAISxY/FRVuXK7DbAzHbOJBd4jueK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdRDJsO%2FbtsJIUAISxY%2FFRVuXK7DbAzHbOJBd4jueK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;733&quot; height=&quot;197&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1820&quot; data-origin-height=&quot;488&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;포트 충돌&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문제 상황: EC2에 웹소켓 연결 요청이 들어가지만 새로운 버전을 배포할 때, 포트 충돌이 생김&lt;/li&gt;
&lt;li&gt;원인: 새로운 버전으로 배포(blue -&amp;gt; green)할 때, 포트 충돌이 발생하여 정상적으로 배포 파이프라인이 돌아가지 않음&lt;/li&gt;
&lt;li&gt;해결:&lt;span&gt; blue는 9092 포트로 green은 9093 포트로 SocketIO 서버를 실행시키도록 명시&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;3. &quot;/websocket&quot;으로 들어오는 요청은 nginx를 거치지 않는 문제&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;1260&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nAcLL/btsJHUapF5n/TVmmvGV4fWqfcobTMwKF1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nAcLL/btsJHUapF5n/TVmmvGV4fWqfcobTMwKF1K/img.png&quot; data-alt=&quot;해결된 후 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nAcLL/btsJHUapF5n/TVmmvGV4fWqfcobTMwKF1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnAcLL%2FbtsJHUapF5n%2FTVmmvGV4fWqfcobTMwKF1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;667&quot; height=&quot;448&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;1260&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;해결된 후 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문제 상황:&lt;span&gt; spring이 실행되고 있는 컨테이너에 직접 접속(3.34.121.34:9092/websocket)하면 연결이 되지만, nginx를 거치도록(3.34.121.34/websocket) 요청을 보내면 &lt;b&gt;404 NOT FOUND ERROR&lt;/b&gt;가 발생하는 문제&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;원인:&lt;span&gt; &lt;/span&gt;&lt;span&gt;클라이언트가 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;ws://3.34.121.34/websocket?chatRoomId=1&lt;/span&gt; URL을 사용하여 서버에 연결을 시도하면, Socket.IO는 내부적으로 이 요청을 처리하여 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;3&quot;&gt;ws://3.34.121.34/socket.io/?EIO=4&amp;amp;transport=websocket&amp;amp;chatRoomId=1&lt;/span&gt;와 같은 URL로 요청을 변환합니다. &lt;span data-token-index=&quot;0&quot;&gt;즉, 실제로는 &lt;/span&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;/socket.io&lt;/span&gt;&lt;span data-token-index=&quot;2&quot;&gt; 경로를 통해 통신합니다. 클라이언트가&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span data-token-index=&quot;2&quot;&gt; 요청한 URL에서 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;/websocket&lt;/span&gt; 부분은 네임스페이스를 설정하기 위한 것이고, &lt;span data-token-index=&quot;3&quot;&gt;내부적으로 Socket.IO는 이를 &lt;/span&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;4&quot;&gt;/socket.io&lt;/span&gt;&lt;span data-token-index=&quot;5&quot;&gt; 경로와 연관 지어 처리&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;해결:&lt;span&gt;&lt;span&gt; nginx의 설정 파일을 아래와 같이 location을 &amp;ldquo;/websocket&amp;rdquo; 에서 &amp;ldquo;/socket.io&amp;rdquo;로 변경 후 해결됨&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;location /socket.io {
    proxy_pass http://{private-ip}:$websocket_port;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection &quot;upgrade&quot;;
    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;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로젝트 깃허브&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/DliDAM/backend-spring&quot;&gt;- https://github.com/DliDAM/backend-spring&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=SwLKZUj9urY&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;- https://www.youtube.com/watch?v=SwLKZUj9urY&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;a href=&quot;https://github.com/TEAM-Hearus/HEARUS-SPRING-BACKEND&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/TEAM-Hearus/HEARUS-SPRING-BACKEND&lt;/a&gt;&lt;/p&gt;</description>
      <category>WebSocket</category>
      <category>fastapi</category>
      <category>socket.io</category>
      <category>spring</category>
      <category>websocket</category>
      <author>immersive</author>
      <guid isPermaLink="true">https://immersive.tistory.com/4</guid>
      <comments>https://immersive.tistory.com/4#entry4comment</comments>
      <pubDate>Sun, 22 Sep 2024 15:05:23 +0900</pubDate>
    </item>
  </channel>
</rss>