<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Docker-Compose on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/docker-compose/</link>
    <description>Recent content in Docker-Compose on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Fri, 24 Apr 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://chanyeols.com/tags/docker-compose/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Docker Compose로 멀티 컨테이너 환경 구축하기: 단계별 튜토리얼</title>
      <link>https://chanyeols.com/posts/docker-compose-multi-container-deployment-guide/</link>
      <pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/docker-compose-multi-container-deployment-guide/</guid>
      <description>docker-compose로 멀티 컨테이너 환경을 구성하는 단계별 가이드. 네트워크, 볼륨, 의존성 관리부터 트러블슈팅까지 완벽 정리</description>
      <content:encoded><![CDATA[<h2 id="docker-compose로-멀티-컨테이너-환경-구축하기-단계별-튜토리얼">Docker Compose로 멀티 컨테이너 환경 구축하기: 단계별 튜토리얼</h2>
<blockquote>
<p><strong>주제 키워드</strong>: Docker Compose, 멀티 컨테이너, YAML 구성, 의존성 관리, CI/CD 통합</p>
</blockquote>
<p>현대 애플리케이션 개발에서 <strong>멀티 컨테이너 아키텍처</strong>는 마이크로서비스, 데이터 처리 파이프라인, 개발/운영 환경 통합에 필수적인 요소입니다. <strong>Docker Compose</strong>는 단일 YAML 파일로 여러 컨테이너의 생명주기, 네트워크, 볼륨, 환경 변수를 관리하는 도구로, 개발부터 프로덕션까지 효율성을 극대화합니다. 이 가이드에서는 DB-웹 애플리케이션 예제부터 고급 설정, 트러블슈팅까지 실제 동작 가능한 예제로 설명합니다.</p>
<h2 id="1-docker-compose의-핵심-개념과-장점">1. Docker Compose의 핵심 개념과 장점</h2>
<h3 id="11-멀티-컨테이너-아키텍처의-필요성">1.1 멀티 컨테이너 아키텍처의 필요성</h3>
<p>단일 컨테이너로 모든 기능을 구현하면 <strong>관심사 분리</strong>가 어렵고, <strong>확장성</strong>이 제한됩니다. 예를 들어, 웹 서버와 데이터베이스를 하나의 컨테이너에 묶으면:</p>
<ul>
<li>DB 업데이트 시 전체 애플리케이션 재빌드 필요</li>
<li>각 서비스의 독립적인 확장(Scale-out) 불가</li>
<li>로깅/모니터링의 복잡성 증가</li>
</ul>
<p>Docker Compose는 이런 문제를 해결하며, <strong>서비스 간 의존성</strong>을 명확히 정의하고 <strong>환경별 구성</strong>을 쉽게 전환할 수 있습니다. 공식 문서(<a href="https://docs.docker.com/compose/">Docker Compose Overview</a>)에서도 강조하는 핵심 이점은 다음과 같습니다:</p>
<ul>
<li><strong>선언적 구성</strong>: YAML 파일로 인프라 상태를 코드로 관리</li>
<li><strong>의존성 자동 처리</strong>: <code>depends_on</code>으로 컨테이너 시작 순서 제어</li>
<li><strong>환경 변수 분리</strong>: <code>.env</code> 파일로 민감 정보 관리</li>
</ul>
<h3 id="12-단일-yaml-파일의-효율성">1.2 단일 YAML 파일의 효율성</h3>
<p>예를 들어, 웹 애플리케이션과 PostgreSQL DB를 연결하는 구성은 다음과 같이 단순화됩니다:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># docker-compose.yml</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">version</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;3.8&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">webapp</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">my-webapp:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;8000:8000&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">DB_HOST=db</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">DB_USER=user</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">DB_PASSWORD=secret</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">db</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">db</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgres:15</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_USER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">user</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_PASSWORD</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">secret</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">postgres_data:/var/lib/postgresql/data</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">postgres_data</span>:<span style="color:#6e7681">
</span></span></span></code></pre></div><h2 id="2-사전-준비-개발-환경-설정">2. 사전 준비: 개발 환경 설정</h2>
<h3 id="21-docker-및-docker-compose-설치">2.1 Docker 및 Docker Compose 설치</h3>
<ul>
<li><strong>Linux (Ubuntu 기준)</strong>:</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt-get update
</span></span><span style="display:flex;"><span>sudo apt-get install docker.io docker-compose
</span></span></code></pre></div><ul>
<li><strong>macOS</strong>: <a href="https://www.docker.com/products/docker-desktop/">Docker Desktop</a> 설치 후 자동 포함</li>
<li><strong>Windows</strong>: Docker Desktop 설치 시 WSL2와 함께 Compose 활성화</li>
</ul>
<blockquote>
<p><strong>확인 명령</strong>:</p>
</blockquote>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker --version
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># Docker version 24.0.2, Build cb71016</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>docker-compose version
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># docker-compose version 1.29.2, build 5becea4c</span>
</span></span></code></pre></div><h3 id="22-네트워크-및-볼륨-개념">2.2 네트워크 및 볼륨 개념</h3>
<ul>
<li><strong>네트워크</strong>: 컨테이너 간 통신을 위한 가상 네트워크. 기본적으로 Compose는 프로젝트명_default 네트워크를 생성하지만, 커스텀 네트워크도 정의 가능합니다.</li>
<li><strong>볼륨</strong>: 호스트와 컨테이너 간 데이터 영구 저장. 위 예제의 <code>postgres_data</code>는 DB 데이터를 호스트에 보관하여 컨테이너 재생성 시에도 데이터 유지</li>
</ul>
<h2 id="3-단계별-본문-기본-구성-실습">3. 단계별 본문: 기본 구성 실습</h2>
<h3 id="31-간단한-웹-애플리케이션-예제">3.1 간단한 웹 애플리케이션 예제</h3>
<p>Python Flask 애플리케이션과 Redis 캐시를 연동하는 예제를 구현해 보겠습니다.</p>
<h4 id="311-애플리케이션-코드-작성">3.1.1 애플리케이션 코드 작성</h4>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># app.py</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">from</span> <span style="color:#ff7b72">flask</span> <span style="color:#ff7b72">import</span> Flask
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">redis</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">os</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#ff7b72;font-weight:bold">=</span> Flask(<span style="color:#79c0ff">__name__</span>)
</span></span><span style="display:flex;"><span>redis_host <span style="color:#ff7b72;font-weight:bold">=</span> os<span style="color:#ff7b72;font-weight:bold">.</span>getenv(<span style="color:#a5d6ff">&#39;REDIS_HOST&#39;</span>, <span style="color:#a5d6ff">&#39;localhost&#39;</span>)
</span></span><span style="display:flex;"><span>redis_client <span style="color:#ff7b72;font-weight:bold">=</span> redis<span style="color:#ff7b72;font-weight:bold">.</span>StrictRedis(host<span style="color:#ff7b72;font-weight:bold">=</span>redis_host, port<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">6379</span>, db<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">0</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@app.route</span>(<span style="color:#a5d6ff">&#39;/&#39;</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">def</span> <span style="color:#d2a8ff;font-weight:bold">hello</span>():
</span></span><span style="display:flex;"><span>    redis_client<span style="color:#ff7b72;font-weight:bold">.</span>incr(<span style="color:#a5d6ff">&#39;hits&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;Total hits: </span><span style="color:#a5d6ff">{</span>redis_client<span style="color:#ff7b72;font-weight:bold">.</span>get(<span style="color:#a5d6ff">&#39;hits&#39;</span>)<span style="color:#ff7b72;font-weight:bold">.</span>decode(<span style="color:#a5d6ff">&#39;utf-8&#39;</span>)<span style="color:#a5d6ff">&#34;</span><span style="color:#79c0ff">\n</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> <span style="color:#79c0ff">__name__</span> <span style="color:#ff7b72;font-weight:bold">==</span> <span style="color:#a5d6ff">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    app<span style="color:#ff7b72;font-weight:bold">.</span>run(host<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#39;0.0.0.0&#39;</span>, port<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">8000</span>)
</span></span></code></pre></div><h4 id="312-docker-이미지-빌드">3.1.2 Docker 이미지 빌드</h4>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-Dockerfile" data-lang="Dockerfile"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># Dockerfile</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">python:3.9-slim</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> requirements.txt ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> pip install --no-cache-dir -r requirements.txt<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> . ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">CMD</span> [<span style="color:#a5d6ff">&#34;python&#34;</span>, <span style="color:#a5d6ff">&#34;app.py&#34;</span>]<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># requirements.txt</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span>flask<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span>redis<span style="color:#f85149">
</span></span></span></code></pre></div><h4 id="313-docker-compose-파일-작성">3.1.3 Docker Compose 파일 작성</h4>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># docker-compose.yml</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">version</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;3.8&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">web</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">.</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;8000:8000&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">REDIS_HOST</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">redis</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">redis</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">redis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">redis:7.0</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;6379:6379&#34;</span><span style="color:#6e7681">
</span></span></span></code></pre></div><h4 id="314-실행-및-테스트">3.1.4 실행 및 테스트</h4>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 빌드 및 실행</span>
</span></span><span style="display:flex;"><span>$ docker-compose up --build
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 별도 터미널에서 테스트</span>
</span></span><span style="display:flex;"><span>$ curl http://localhost:8000
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 출력: Total hits: 1</span>
</span></span></code></pre></div><h3 id="32-의존성-관리의-중요성">3.2 의존성 관리의 중요성</h3>
<p><code>depends_on</code>은 컨테이너 시작 순서만 제어할 뿐, DB 준비 완료 상태를 보장하지 않습니다. 이를 해결하려면 <a href="https://github.com/vishnubob/wait-for-it">wait-for-it.sh</a> 같은 툴을 사용하거나, 애플리케이션 수준에서 재시도 로직을 구현해야 합니다.</p>
<h2 id="4-고급-설정-프로덕션-환경-최적화">4. 고급 설정: 프로덕션 환경 최적화</h2>
<h3 id="41-커스텀-네트워크와-환경-변수-분리">4.1 커스텀 네트워크와 환경 변수 분리</h3>
<h4 id="411-네트워크-정의-예제">4.1.1 네트워크 정의 예제</h4>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># docker-compose.yml</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">version</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;3.8&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">app-network</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">driver</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">bridge</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">web</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">app-network</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">db</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">app-network</span><span style="color:#6e7681">
</span></span></span></code></pre></div><h4 id="412-env-파일-활용">4.1.2 .env 파일 활용</h4>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-env" data-lang="env"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># .env</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">DB_USER</span><span style="color:#ff7b72;font-weight:bold">=</span>admin
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">DB_PASSWORD</span><span style="color:#ff7b72;font-weight:bold">=</span>strong_password
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># docker-compose.yml</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">env_file</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#a5d6ff">.env</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">web</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_USER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${DB_USER}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_PASSWORD</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${DB_PASSWORD}</span><span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="42-프로필-기반-구성-devprod">4.2 프로필 기반 구성 (dev/prod)</h3>
<p>Compose 1.27+부터 <code>profiles</code>를 사용해 환경별 서비스를 활성화할 수 있습니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># docker-compose.yml</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">version</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;3.8&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">web</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">my-webapp:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">db</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgres:15</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">devtools</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:18</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">command</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">npm run dev</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">profiles</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">dev</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">monitoring</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">prom/prometheus</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">profiles</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">prod</span><span style="color:#6e7681">
</span></span></span></code></pre></div><blockquote>
<p><strong>실행 방법</strong>:</p>
</blockquote>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 개발 환경</span>
</span></span><span style="display:flex;"><span>$ docker-compose --profile dev up
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 프로덕션 환경</span>
</span></span><span style="display:flex;"><span>$ docker-compose --profile prod up
</span></span></code></pre></div><h2 id="5-트러블슈팅-흔한-문제와-해결법">5. 트러블슈팅: 흔한 문제와 해결법</h2>
<h3 id="51-컨테이너-간-연결-실패">5.1 컨테이너 간 연결 실패</h3>
<ul>
<li><strong>증상</strong>: <code>Connection refused</code> 오류 발생</li>
<li><strong>원인</strong>: DB가 준비되기 전에 웹 애플리케이션이 연결을 시도</li>
<li><strong>해결</strong>:
<ol>
<li><code>depends_on</code>에 <code>condition</code> 추가</li>
<li><a href="https://github.com/avengerow/dockurize">Dockerize</a> 같은 툴로 헬스 체크</li>
</ol>
</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 조건 추가 예제</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">db</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="52-볼륨-마운트-권한-문제">5.2 볼륨 마운트 권한 문제</h3>
<ul>
<li><strong>증상</strong>: <code>Permission denied</code> 오류</li>
<li><strong>해결</strong>:
<ol>
<li>호스트 디렉터리 권한 변경</li>
<li>Docker Compose에서 <code>user</code> 지정</li>
</ol>
</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 권한 문제 해결 예제</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#7ee787">type</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">volume</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">source</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">myvol</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">target</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app/data</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volume</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">nocopy</span>:<span style="color:#6e7681"> </span><span style="color:#79c0ff">true</span><span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="53-로그-분석">5.3 로그 분석</h3>
<ul>
<li><strong>컨테이너 로그 확인</strong>:</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>$ docker-compose logs <span style="color:#ff7b72;font-weight:bold">[</span>서비스명<span style="color:#ff7b72;font-weight:bold">]</span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 예: docker-compose logs web</span>
</span></span></code></pre></div><ul>
<li><strong>실시간 모니터링</strong>:</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>$ docker-compose logs -f web
</span></span></code></pre></div><h2 id="6-마치며-docker-compose의-확장성">6. 마치며: Docker Compose의 확장성</h2>
<ol>
<li><strong>CI/CD 통합</strong>: GitHub Actions, GitLab CI에서 <code>docker-compose</code> 명령어로 테스트 환경 구축 가능</li>
<li><strong>확장성</strong>: Kubernetes로의 전환을 위해 Compose 파일을 Helm 차트로 변환 가능</li>
<li><strong>모니터링</strong>: Prometheus + Grafana와 연동해 컨테이너 메트릭 수집</li>
</ol>
<blockquote>
<p><strong>추가 학습 자료</strong>:</p>
</blockquote>
<ul>
<li><a href="https://docs.docker.com/compose/reference/">Docker Compose 공식 문서</a></li>
<li><a href="https://github.com/veggiemonk/awesome-docker">Awesome Docker</a> 커뮤니티 리소스</li>
</ul>
<p>이 가이드를 통해 멀티 컨테이너 환경의 설계부터 운영까지의 핵심 역량을 습득할 수 있습니다. 실제 프로덕션 환경에서는 보안 강화(예: TLS 암호화)와 리소스 제한(예: <code>--memory</code>, <code>--cpus</code>)을 추가로 적용해야 합니다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 VM IP 관리 시스템 만들기 - React 대시보드 &#43; Docker Compose 배포 (4편)</title>
      <link>https://chanyeols.com/posts/ipam-04-frontend-deploy/</link>
      <pubDate>Wed, 15 Apr 2026 15:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/ipam-04-frontend-deploy/</guid>
      <description>30초 폴링으로 VM 현황을 실시간 갱신하는 React 대시보드 구현과 Spring Boot &#43; PostgreSQL &#43; Redis &#43; React 4개 서비스를 Docker Compose로 배포하는 방법을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="react-대시보드">React 대시보드</h2>
<p>대시보드는 탭 2개로 구성된다.</p>
<ul>
<li><strong>VM 목록</strong> — 전체 VM 현황, 상태별 필터, ONLINE/OFFLINE/UNKNOWN 배지</li>
<li><strong>이벤트 로그</strong> — IP 충돌, IP 변경, 오프라인 이벤트 타임라인</li>
</ul>
<p>WebSocket이 아닌 30초 폴링으로 구현했다. VM 상태가 초 단위로 바뀌지 않고, 운영 대시보드 특성상 약간의 지연은 허용된다. 심플하게 가는 게 낫다고 판단했다.</p>
<hr>
<h2 id="30초-폴링-훅">30초 폴링 훅</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// usePolling.js
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { useEffect, useRef } from <span style="color:#a5d6ff">&#39;react&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> usePolling(callback, intervalMs <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">30_000</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> callbackRef <span style="color:#ff7b72;font-weight:bold">=</span> useRef(callback);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>    callbackRef.current <span style="color:#ff7b72;font-weight:bold">=</span> callback;
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>    callbackRef.current(); <span style="color:#8b949e;font-style:italic">// 마운트 시 즉시 1회 실행
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> id <span style="color:#ff7b72;font-weight:bold">=</span> setInterval(() =&gt; callbackRef.current(), intervalMs);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> () =&gt; clearInterval(id);
</span></span><span style="display:flex;"><span>  }, [intervalMs]);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// App.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [vms, setVms] <span style="color:#ff7b72;font-weight:bold">=</span> useState([]);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [loading, setLoading] <span style="color:#ff7b72;font-weight:bold">=</span> useState(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>usePolling(<span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> data <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetch(<span style="color:#a5d6ff">&#39;/api/vms&#39;</span>).then(r =&gt; r.json());
</span></span><span style="display:flex;"><span>  setVms(data);
</span></span><span style="display:flex;"><span>  setLoading(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span>}, <span style="color:#a5d6ff">30_000</span>);
</span></span></code></pre></div><p><code>callbackRef</code>를 쓰는 이유는 <code>setInterval</code> 클로저가 최초 등록 시점의 <code>callback</code>을 계속 참조하는 문제를 피하기 위해서다. <code>useRef</code>로 항상 최신 콜백을 가리키도록 한다.</p>
<hr>
<h2 id="vm-목록-테이블">VM 목록 테이블</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// VmTable.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> STATUS_BADGE <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  ONLINE<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;ONLINE&#39;</span>,  className<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;badge-green&#39;</span>  },
</span></span><span style="display:flex;"><span>  OFFLINE<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;OFFLINE&#39;</span>, className<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;badge-red&#39;</span>    },
</span></span><span style="display:flex;"><span>  UNKNOWN<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;UNKNOWN&#39;</span>, className<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;badge-gray&#39;</span>   },
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> VmTable({ vms, filter }) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> filtered <span style="color:#ff7b72;font-weight:bold">=</span> useMemo(() =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> vms.filter(vm =&gt; {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (filter.status <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> vm.status <span style="color:#ff7b72;font-weight:bold">!==</span> filter.status) <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (filter.team <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> vm.teamId <span style="color:#ff7b72;font-weight:bold">!==</span> filter.team) <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (filter.search) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">const</span> q <span style="color:#ff7b72;font-weight:bold">=</span> filter.search.toLowerCase();
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>          vm.hostname<span style="color:#ff7b72;font-weight:bold">?</span>.toLowerCase().includes(q) <span style="color:#ff7b72;font-weight:bold">||</span>
</span></span><span style="display:flex;"><span>          vm.currentIp<span style="color:#ff7b72;font-weight:bold">?</span>.includes(q)
</span></span><span style="display:flex;"><span>        );
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</span>;
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  }, [vms, filter]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#7ee787">table</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;vm-table&#34;</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">thead</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;<span style="color:#7ee787">tr</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;호스트명&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;IP&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;팀&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;상태&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;마지막 heartbeat&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;에이전트 버전&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;/<span style="color:#7ee787">tr</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;/<span style="color:#7ee787">thead</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">tbody</span>&gt;
</span></span><span style="display:flex;"><span>        {filtered.map(vm =&gt; {
</span></span><span style="display:flex;"><span>          <span style="color:#ff7b72">const</span> badge <span style="color:#ff7b72;font-weight:bold">=</span> STATUS_BADGE[vm.status] <span style="color:#ff7b72;font-weight:bold">??</span> STATUS_BADGE.UNKNOWN;
</span></span><span style="display:flex;"><span>          <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>            &lt;<span style="color:#7ee787">tr</span> key<span style="color:#ff7b72;font-weight:bold">=</span>{vm.id}&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{vm.hostname}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;&lt;<span style="color:#7ee787">code</span>&gt;{vm.currentIp}&lt;/<span style="color:#7ee787">code</span>&gt;&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{vm.teamName <span style="color:#ff7b72;font-weight:bold">??</span> <span style="color:#a5d6ff">&#39;-&#39;</span>}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;&lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#a5d6ff">`badge </span><span style="color:#a5d6ff">${</span>badge.className<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>}&gt;{badge.label}&lt;/<span style="color:#7ee787">span</span>&gt;&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{formatRelative(vm.lastSeenAt)}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{vm.agentVersion}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>            &lt;/<span style="color:#7ee787">tr</span>&gt;
</span></span><span style="display:flex;"><span>          );
</span></span><span style="display:flex;"><span>        })}
</span></span><span style="display:flex;"><span>      &lt;/<span style="color:#7ee787">tbody</span>&gt;
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#7ee787">table</span>&gt;
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="이벤트-로그-패널">이벤트 로그 패널</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// EventLogPanel.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> SEVERITY_CLASS <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  CRITICAL<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;event-critical&#39;</span>,
</span></span><span style="display:flex;"><span>  WARNING<span style="color:#ff7b72;font-weight:bold">:</span>  <span style="color:#a5d6ff">&#39;event-warning&#39;</span>,
</span></span><span style="display:flex;"><span>  INFO<span style="color:#ff7b72;font-weight:bold">:</span>     <span style="color:#a5d6ff">&#39;event-info&#39;</span>,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> EVENT_ICON <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  IP_CONFLICT<span style="color:#ff7b72;font-weight:bold">:</span>    <span style="color:#a5d6ff">&#39;🔴&#39;</span>,
</span></span><span style="display:flex;"><span>  IP_CHANGED<span style="color:#ff7b72;font-weight:bold">:</span>     <span style="color:#a5d6ff">&#39;🔄&#39;</span>,
</span></span><span style="display:flex;"><span>  IP_OUT_OF_RANGE<span style="color:#ff7b72;font-weight:bold">:</span><span style="color:#a5d6ff">&#39;⚠️&#39;</span>,
</span></span><span style="display:flex;"><span>  VM_OFFLINE<span style="color:#ff7b72;font-weight:bold">:</span>     <span style="color:#a5d6ff">&#39;🔴&#39;</span>,
</span></span><span style="display:flex;"><span>  VM_ONLINE<span style="color:#ff7b72;font-weight:bold">:</span>      <span style="color:#a5d6ff">&#39;🟢&#39;</span>,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> EventLogPanel({ events }) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#7ee787">div</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-log&#34;</span>&gt;
</span></span><span style="display:flex;"><span>      {events.map(evt =&gt; (
</span></span><span style="display:flex;"><span>        &lt;<span style="color:#7ee787">div</span> key<span style="color:#ff7b72;font-weight:bold">=</span>{evt.id} className<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#a5d6ff">`event-item </span><span style="color:#a5d6ff">${</span>SEVERITY_CLASS[evt.severity]<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>}&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-icon&#34;</span>&gt;{EVENT_ICON[evt.eventType]}&lt;/<span style="color:#7ee787">span</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-time&#34;</span>&gt;{formatDate(evt.createdAt)}&lt;/<span style="color:#7ee787">span</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-msg&#34;</span>&gt;{evt.message}&lt;/<span style="color:#7ee787">span</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;/<span style="color:#7ee787">div</span>&gt;
</span></span><span style="display:flex;"><span>      ))}
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#7ee787">div</span>&gt;
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="다크라이트-테마">다크/라이트 테마</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">/* App.css */</span>
</span></span><span style="display:flex;"><span>:<span style="color:#d2a8ff;font-weight:bold">root</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--bg</span>: <span style="color:#a5d6ff">#ffffff</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--surface</span>: <span style="color:#a5d6ff">#f8f9fa</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--text</span>: <span style="color:#a5d6ff">#1a1a1a</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--border</span>: <span style="color:#a5d6ff">#e0e0e0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--badge-green</span>: <span style="color:#a5d6ff">#22c55e</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--badge-red</span>: <span style="color:#a5d6ff">#ef4444</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--badge-gray</span>: <span style="color:#a5d6ff">#9ca3af</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72;font-weight:bold">[</span><span style="color:#7ee787">data-theme</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;dark&#34;</span><span style="color:#ff7b72;font-weight:bold">]</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--bg</span>: <span style="color:#a5d6ff">#0f172a</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--surface</span>: <span style="color:#a5d6ff">#1e293b</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--text</span>: <span style="color:#a5d6ff">#e2e8f0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--border</span>: <span style="color:#a5d6ff">#334155</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// App.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [theme, setTheme] <span style="color:#ff7b72;font-weight:bold">=</span> useState(
</span></span><span style="display:flex;"><span>  () =&gt; localStorage.getItem(<span style="color:#a5d6ff">&#39;ipam-theme&#39;</span>) <span style="color:#ff7b72;font-weight:bold">||</span> <span style="color:#a5d6ff">&#39;light&#39;</span>
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>  document.documentElement.setAttribute(<span style="color:#a5d6ff">&#39;data-theme&#39;</span>, theme);
</span></span><span style="display:flex;"><span>  localStorage.setItem(<span style="color:#a5d6ff">&#39;ipam-theme&#39;</span>, theme);
</span></span><span style="display:flex;"><span>}, [theme]);
</span></span></code></pre></div><p><img alt="IPAM 대시보드 - VM 목록" loading="lazy" src="/images/ipam-04-dashboard.png"></p>
<p><img alt="IPAM 이벤트 로그" loading="lazy" src="/images/ipam-04-events.png"></p>
<hr>
<h2 id="client-nginx-설정">client nginx 설정</h2>
<p>React SPA를 서빙하면서 <code>/api/</code> 요청을 Spring Boot로 프록시한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">root</span> <span style="color:#a5d6ff">/usr/share/nginx/html</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">index</span> <span style="color:#a5d6ff">index.html</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/api/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://app:8080</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Host</span> <span style="color:#79c0ff">$host</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Real-IP</span> <span style="color:#79c0ff">$remote_addr</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">try_files</span> <span style="color:#79c0ff">$uri</span> <span style="color:#79c0ff">$uri/</span> <span style="color:#a5d6ff">/index.html</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="docker-compose">Docker Compose</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">postgres</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgres:16-alpine</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-postgres</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_DB</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_USER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_PASSWORD</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam1234</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./data/postgres:/var/lib/postgresql/data</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./sql/init.sql:/docker-entrypoint-initdb.d/init.sql</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;pg_isready -U ipam -d ipam&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">redis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">redis:7-alpine</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-redis</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;redis-cli&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;ping&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">3s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">app</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">./backend</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-app</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;8080:8080&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_HOST</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgres</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_NAME</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_USER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_PASS</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam1234</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">REDIS_HOST</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">redis</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">SLACK_WEBHOOK_URL</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${SLACK_WEBHOOK_URL:-}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">postgres</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">redis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;curl -f http://localhost:8080/actuator/health || exit 1&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">12</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">frontend</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">./frontend</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-frontend</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;3000:80&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">app</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>init.sql</code>을 <code>/docker-entrypoint-initdb.d/</code>에 마운트하면 PostgreSQL 컨테이너 최초 실행 시 자동으로 실행된다. 스키마를 자동으로 생성해주므로 별도로 <code>CREATE TABLE</code>을 실행하지 않아도 된다.</p>
<hr>
<h2 id="backend-dockerfile">backend Dockerfile</h2>
<p>Spring Boot는 빌드가 필요하다. 멀티스테이지 빌드로 최종 이미지에 Maven/JDK 빌드 도구가 포함되지 않도록 한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 빌드 스테이지</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">maven:3.9-eclipse-temurin-21</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> pom.xml .<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> mvn dependency:go-offline -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> src ./src<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> mvn package -DskipTests -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 실행 스테이지</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">eclipse-temurin:21-jre-alpine</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>build /app/target/*.jar app.jar<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">8080</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">ENTRYPOINT</span> [<span style="color:#a5d6ff">&#34;java&#34;</span>, <span style="color:#a5d6ff">&#34;-jar&#34;</span>, <span style="color:#a5d6ff">&#34;app.jar&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><p><code>dependency:go-offline</code>을 먼저 실행해서 Maven 의존성을 레이어로 캐싱한다. 소스 변경 시 의존성 다운로드를 재실행하지 않아서 빌드가 빠르다.</p>
<hr>
<h2 id="frontend-dockerfile">frontend Dockerfile</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:20-alpine</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> package*.json ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm ci --silent<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> . .<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm run build<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">nginx:alpine</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>build /app/dist /usr/share/nginx/html<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> nginx.conf /etc/nginx/conf.d/default.conf<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">80</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">CMD</span> [<span style="color:#a5d6ff">&#34;nginx&#34;</span>, <span style="color:#a5d6ff">&#34;-g&#34;</span>, <span style="color:#a5d6ff">&#34;daemon off;&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><hr>
<h2 id="배포-명령어">배포 명령어</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 환경변수 파일 작성</span>
</span></span><span style="display:flex;"><span>cat &gt; .env <span style="color:#a5d6ff">&lt;&lt; EOF
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">EOF</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 최초 배포</span>
</span></span><span style="display:flex;"><span>cd ipam-system
</span></span><span style="display:flex;"><span>docker compose up -d --build
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 로그 확인</span>
</span></span><span style="display:flex;"><span>docker logs ipam-app -f
</span></span><span style="display:flex;"><span>docker logs ipam-frontend -f
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 백엔드만 재배포</span>
</span></span><span style="display:flex;"><span>docker compose up -d --build app
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 프론트엔드만 재배포</span>
</span></span><span style="display:flex;"><span>docker compose up -d --build frontend
</span></span></code></pre></div><hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="spring-boot-healthcheck-curl-없음">Spring Boot healthcheck curl 없음</h3>
<p><code>eclipse-temurin:21-jre-alpine</code>에는 <code>curl</code>이 없어서 <code>CMD curl -f ...</code>가 실패했다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 실패</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;curl&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;-f&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;http://localhost:8080/actuator/health&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 해결: CMD-SHELL로 변경 (sh -c 경유)</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;curl -f http://localhost:8080/actuator/health || exit 1&#34;</span>]<span style="color:#6e7681">
</span></span></span></code></pre></div><p>alpine 이미지에는 sh가 있어서 <code>CMD-SHELL</code>을 쓰면 sh를 통해 명령이 실행된다. curl 설치를 Dockerfile에 추가하거나, wget이 있다면 wget으로 대체하는 방법도 있다.</p>
<h3 id="spring-boot-시작-시간--healthcheck">Spring Boot 시작 시간 + healthcheck</h3>
<p>Spring Boot가 완전히 뜨기까지 20~30초 걸린다. <code>retries: 12</code>, <code>interval: 10s</code>로 설정해서 최대 2분을 대기한다. 이 시간 안에 spring이 안 뜨면 컨테이너가 unhealthy로 전환된다.</p>
<h3 id="initsql-재실행-안-됨">init.sql 재실행 안 됨</h3>
<p>PostgreSQL 컨테이너는 <code>/var/lib/postgresql/data</code>가 비어있을 때만 <code>initdb</code>를 실행한다. 볼륨이 남아있으면 <code>init.sql</code>이 재실행되지 않는다. 스키마를 수동 적용하거나 볼륨을 삭제해야 한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 볼륨 삭제 후 재시작 (데이터 초기화 주의)</span>
</span></span><span style="display:flex;"><span>docker compose down -v
</span></span><span style="display:flex;"><span>docker compose up -d --build
</span></span></code></pre></div><hr>
<h2 id="마무리">마무리</h2>
<p>4편에 걸쳐 IPAM VM 관리 시스템을 만든 과정을 정리했다.</p>
<table>
  <thead>
      <tr>
          <th>편</th>
          <th>내용</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1편</td>
          <td>기획 배경 + 전체 아키텍처 + DB 스키마</td>
      </tr>
      <tr>
          <td>2편</td>
          <td>Node.js 에이전트 → Go 에이전트 전환</td>
      </tr>
      <tr>
          <td>3편</td>
          <td>Spring Boot Heartbeat 처리 + 이상 감지 + Slack 알림</td>
      </tr>
      <tr>
          <td>4편</td>
          <td>React 대시보드 + Docker Compose 배포 ← 지금 여기</td>
      </tr>
  </tbody>
</table>
<p>핵심은 두 가지다.</p>
<p><strong>MAC 주소 기반 VM 식별</strong> — IP는 바뀌지만 MAC은 고정이다. IP를 기준으로 삼으면 VM이 IP를 바꿀 때마다 새 VM으로 등록되는 문제가 생긴다.</p>
<p><strong>에이전트는 최대한 가볍게</strong> — Go 단일 바이너리 6 MB로 Node.js 45 MB 대비 크기를 87% 줄였다. 배포하기 쉬울수록 VM이 늘어나도 관리가 편하다.</p>
<p>현재 인사정보 시스템과 연동해서 VM 담당자가 퇴사했을 때 자동으로 알림이 오는 흐름을 구성할 예정이다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 인사정보 관리 시스템 만들기 - Docker Compose 멀티 서비스 배포 &#43; 트러블슈팅 (3편)</title>
      <link>https://chanyeols.com/posts/insa-03-docker-deploy/</link>
      <pubDate>Sat, 11 Apr 2026 13:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/insa-03-docker-deploy/</guid>
      <description>Docker Compose로 DB, 서버, 클라이언트 3개 서비스의 시작 순서를 보장하는 방법과 Prisma Alpine 오류, AG Grid 테마 충돌 등 실제 겪은 트러블슈팅을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="docker-compose-멀티-서비스-구성">Docker Compose 멀티 서비스 구성</h2>
<p>세 서비스가 올바른 순서로 떠야 한다.</p>
<pre tabindex="0"><code>db (PostgreSQL) → server (Express + Prisma) → client (React + nginx)
</code></pre><p>server가 DB보다 먼저 뜨면 Prisma 연결이 실패하고, client가 server보다 먼저 뜨면 API 프록시가 동작하지 않는다.</p>
<h3 id="docker-composeyml">docker-compose.yml</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">db</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgres:16-alpine</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">insa-db</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_DB</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unipost_insa</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_USER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">insa</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_PASSWORD</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">insa1234</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./data/postgres:/var/lib/postgresql/data</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;pg_isready -U insa -d unipost_insa&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">server</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">./server</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">insa-server</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;4000:4000&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">env_file</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./server/.env</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DATABASE_URL</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgresql://insa:insa1234@db:5432/unipost_insa</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">db</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;node&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;-e&#34;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#a5d6ff">&#34;require(&#39;http&#39;).get(&#39;http://localhost:4000/health&#39;, r =&gt; process.exit(r.statusCode===200?0:1)).on(&#39;error&#39;,()=&gt;process.exit(1))&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">client</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">./client</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">insa-client</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;3002:80&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">server</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>depends_on</code>에 <code>condition: service_healthy</code>를 쓰면 단순히 컨테이너가 &ldquo;시작됐는지&quot;가 아니라 &ldquo;준비됐는지&quot;를 확인하고 다음 서비스를 시작한다.</p>
<hr>
<h2 id="db-healthcheck">DB healthcheck</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;pg_isready -U insa -d unipost_insa&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>pg_isready</code>는 PostgreSQL이 연결을 받을 준비가 됐는지 확인하는 내장 커맨드다. 컨테이너가 떠도 PostgreSQL 프로세스가 완전히 초기화되기까지 몇 초 걸리기 때문에 이 healthcheck가 없으면 server가 너무 일찍 연결을 시도해서 실패한다.</p>
<hr>
<h2 id="server-healthcheck--wgetcurl-없을-때">server healthcheck — wget/curl 없을 때</h2>
<p>server healthcheck에서 문제가 생겼다. <code>node:20-slim</code> 이미지에는 <code>wget</code>과 <code>curl</code>이 설치되어 있지 않아서 일반적인 HTTP healthcheck 방법이 안 된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 이건 안 됨 (wget 없음)</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;wget&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;-q&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;--spider&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;http://localhost:4000/health&#34;</span>]<span style="color:#6e7681">
</span></span></span></code></pre></div><p><strong>해결:</strong> Node.js 내장 <code>http</code> 모듈로 대체한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;node&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;-e&#34;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;require(&#39;http&#39;).get(&#39;http://localhost:4000/health&#39;, r =&gt; process.exit(r.statusCode===200?0:1)).on(&#39;error&#39;,()=&gt;process.exit(1))&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>/health</code> 엔드포인트가 200을 반환하면 <code>process.exit(0)</code> (성공), 아니면 <code>process.exit(1)</code> (실패)로 healthcheck 결과를 전달한다.</p>
<p>서버에 <code>/health</code> 엔드포인트를 추가해둔다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>app.get(<span style="color:#a5d6ff">&#39;/health&#39;</span>, (req, res) =&gt; res.sendStatus(<span style="color:#a5d6ff">200</span>));
</span></span></code></pre></div><hr>
<h2 id="entrypointsh--db-대기--prisma-자동-마이그레이션">entrypoint.sh — DB 대기 + Prisma 자동 마이그레이션</h2>
<p>server Dockerfile에서 <code>entrypoint.sh</code>를 실행 진입점으로 쓴다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-weight:bold;font-style:italic">#!/bin/sh
</span></span></span><span style="display:flex;"><span>set -e
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;DB 연결 대기 중...&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">until</span> npx prisma db push --skip-generate 2&gt;/dev/null; <span style="color:#ff7b72">do</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#a5d6ff">&#34;DB 아직 준비 안 됨, 재시도...&#34;</span>
</span></span><span style="display:flex;"><span>  sleep <span style="color:#a5d6ff">2</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">done</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;DB 준비 완료, 서버 시작&#34;</span>
</span></span><span style="display:flex;"><span>exec node src/index.js
</span></span></code></pre></div><p><code>prisma db push</code>는 스키마와 DB를 동기화한다. DB가 아직 준비 안 됐으면 실패하고, 성공할 때까지 2초마다 재시도한다.</p>
<p><code>healthcheck</code> + <code>depends_on</code>이 있어도 이 루프를 유지하는 게 좋다. healthcheck 타이밍이 맞지 않는 엣지 케이스가 간혹 있기 때문이다.</p>
<h3 id="server-dockerfile">server Dockerfile</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:20-slim</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> apt-get update <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> apt-get install -y openssl <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> rm -rf /var/lib/apt/lists/*<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> package*.json ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm ci --omit<span style="color:#ff7b72;font-weight:bold">=</span>dev<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> prisma ./prisma<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npx prisma generate<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> src ./src<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> entrypoint.sh ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> chmod +x entrypoint.sh<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">4000</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">ENTRYPOINT</span> [<span style="color:#a5d6ff">&#34;./entrypoint.sh&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><p><code>openssl</code> 설치가 빠지면 안 된다. 트러블슈팅에서 다룬다.</p>
<hr>
<h2 id="client-nginx-설정">client nginx 설정</h2>
<p>React SPA를 서빙하면서 <code>/api/</code> 요청은 server로 프록시한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">root</span> <span style="color:#a5d6ff">/usr/share/nginx/html</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">index</span> <span style="color:#a5d6ff">index.html</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># API 요청 → server 컨테이너로 프록시
</span></span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/api/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://server:4000</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Host</span> <span style="color:#79c0ff">$host</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Real-IP</span> <span style="color:#79c0ff">$remote_addr</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># SPA 라우팅 (새로고침 시 404 방지)
</span></span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">try_files</span> <span style="color:#79c0ff">$uri</span> <span style="color:#79c0ff">$uri/</span> <span style="color:#a5d6ff">/index.html</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Docker Compose 내부 네트워크에서는 서비스 이름으로 통신할 수 있다. <code>http://server:4000</code>처럼 쓰면 된다.</p>
<h3 id="client-env">client .env</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-env" data-lang="env"><span style="display:flex;"><span><span style="color:#79c0ff">VITE_API_URL</span><span style="color:#ff7b72;font-weight:bold">=</span>/api
</span></span></code></pre></div><p>Vite 빌드 시 API URL을 <code>/api</code>로 설정해두면 nginx가 알아서 server로 프록시한다. 서버 IP를 하드코딩할 필요가 없다.</p>
<h3 id="client-dockerfile">client Dockerfile</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:20-alpine</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> package*.json ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm ci --silent<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> . .<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm run build<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">nginx:alpine</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>build /app/dist /usr/share/nginx/html<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> nginx.conf /etc/nginx/conf.d/default.conf<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">80</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">CMD</span> [<span style="color:#a5d6ff">&#34;nginx&#34;</span>, <span style="color:#a5d6ff">&#34;-g&#34;</span>, <span style="color:#a5d6ff">&#34;daemon off;&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><p>멀티스테이지 빌드로 최종 이미지에 node, npm이 포함되지 않는다.</p>
<hr>
<h2 id="배포-명령어">배포 명령어</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 최초 배포</span>
</span></span><span style="display:flex;"><span>cd unipost_insa
</span></span><span style="display:flex;"><span>docker compose up -d --build
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 최초 데이터 수집 (배치 수동 실행)</span>
</span></span><span style="display:flex;"><span>docker exec insa-server node -e <span style="color:#a5d6ff">&#34;require(&#39;./src/batch&#39;).runBatch().catch(console.error)&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 서버 코드 변경 후</span>
</span></span><span style="display:flex;"><span>docker compose up -d --build server
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 클라이언트 변경 후</span>
</span></span><span style="display:flex;"><span>docker compose up -d --build client
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 로그 확인</span>
</span></span><span style="display:flex;"><span>docker logs insa-server -f
</span></span><span style="display:flex;"><span>docker logs insa-client -f
</span></span></code></pre></div><h3 id="로컬-개발-docker-없이">로컬 개발 (Docker 없이)</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># DB만 Docker로 띄우기</span>
</span></span><span style="display:flex;"><span>docker compose up -d db
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 서버</span>
</span></span><span style="display:flex;"><span>cd server
</span></span><span style="display:flex;"><span>npm install
</span></span><span style="display:flex;"><span>npx prisma db push
</span></span><span style="display:flex;"><span>npm run dev   <span style="color:#8b949e;font-style:italic"># localhost:4000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 클라이언트 (새 터미널)</span>
</span></span><span style="display:flex;"><span>cd client
</span></span><span style="display:flex;"><span>npm install
</span></span><span style="display:flex;"><span>npm run dev   <span style="color:#8b949e;font-style:italic"># localhost:5173</span>
</span></span></code></pre></div><hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="prisma--alpine-openssl-오류">Prisma + Alpine OpenSSL 오류</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>Error: Unable to require(&#39;node_modules/.prisma/client/libquery_engine-linux-musl.so.node&#39;)
</span></span><span style="display:flex;"><span>PrismaClientInitializationError: Unable to open the libssl.so.1.1 file.
</span></span></code></pre></div><p><code>node:20-alpine</code>은 musl libc 기반이라 Prisma가 요구하는 OpenSSL 버전과 맞지 않는다.</p>
<p><strong>해결:</strong> <code>node:20-slim</code>(Debian 기반)으로 변경하고 OpenSSL을 명시적으로 설치한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:20-slim</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> apt-get update <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> apt-get install -y openssl <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> rm -rf /var/lib/apt/lists/*<span style="color:#f85149">
</span></span></span></code></pre></div><p><code>schema.prisma</code>에도 binaryTargets를 추가해야 한다.</p>
<pre tabindex="0"><code class="language-prisma" data-lang="prisma">generator client {
  provider      = &#34;prisma-client-js&#34;
  binaryTargets = [&#34;native&#34;, &#34;debian-openssl-3.0.x&#34;]
}
</code></pre><h3 id="docker-시작-순서-문제">Docker 시작 순서 문제</h3>
<p><code>depends_on</code>만 써도 순서는 보장되지만, 이건 컨테이너가 &ldquo;시작됐는지&quot;만 확인한다. PostgreSQL이 실제로 연결을 받을 준비가 되기 전에 server가 연결을 시도하면 실패한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 이것만으론 부족</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#a5d6ff">db</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 이렇게 해야 함</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">db</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>service_healthy</code>는 healthcheck가 통과한 후에 다음 서비스를 시작한다.</p>
<h3 id="server-healthcheck-wgetcurl-없음">server healthcheck wget/curl 없음</h3>
<p>위에서 다룬 것처럼 <code>node:20-slim</code>에는 wget/curl이 없다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># Node.js 내장 http로 대체</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;node&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;-e&#34;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#a5d6ff">&#34;require(&#39;http&#39;).get(&#39;http://localhost:4000/health&#39;, r =&gt; process.exit(r.statusCode===200?0:1)).on(&#39;error&#39;,()=&gt;process.exit(1))&#34;</span>]<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="ag-grid-v33-테마-충돌">AG Grid v33 테마 충돌</h3>
<p>2편에서 다뤘지만 배포 후에도 동일하게 적용해야 한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span>&lt;<span style="color:#7ee787">AgGridReact</span> theme<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;legacy&#34;</span> ... /&gt;
</span></span></code></pre></div><h3 id="인사-api-빈-배열-반환">인사 API 빈 배열 반환</h3>
<p>배치를 돌렸는데 <code>API 응답: 0명</code>이 나오는 경우가 있었다. Request Body 없이 호출하면 빈 배열을 반환하는 API 특성 때문이었다. 파라미터를 명시적으로 넘겨줘야 전체 인원이 조회된다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>3편에 걸쳐 인사정보 관리 시스템을 만든 과정을 정리했다.</p>
<table>
  <thead>
      <tr>
          <th>편</th>
          <th>내용</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1편</td>
          <td>아키텍처 + Prisma 스키마 설계</td>
      </tr>
      <tr>
          <td>2편</td>
          <td>배치 변동 감지 + React AG Grid UI</td>
      </tr>
      <tr>
          <td>3편</td>
          <td>Docker Compose 멀티 서비스 배포 + 트러블슈팅 ← 지금 여기</td>
      </tr>
  </tbody>
</table>
<p>핵심은 <strong>시작 순서 보장</strong>이다. <code>healthcheck</code> + <code>depends_on condition: service_healthy</code> + <code>entrypoint.sh</code> 재시도 루프, 세 가지를 같이 써야 안정적으로 동작한다.</p>
<p>향후 이 시스템에서 감지한 입퇴사·부서이동 변동을 Slack 알림 봇과 연동해서 자동으로 알림이 오도록 구성할 예정이다.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
