<?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>Sse on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/sse/</link>
    <description>Recent content in Sse on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Fri, 03 Apr 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://chanyeols.com/tags/sse/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>노트북으로 홈서버 구축하기 - 직접 만든 컨테이너 대시보드 (Spring Boot &#43; React &#43; SSE) (12편)</title>
      <link>https://chanyeols.com/posts/part-12-dashboard/</link>
      <pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-12-dashboard/</guid>
      <description>Portainer 대신 Spring Boot &#43; React로 컨테이너 모니터링 대시보드를 직접 만들었다. Docker API 연동, SSE 실시간 로그 스트리밍, 멀티스테이지 Docker 빌드까지 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-직접-만들었나">왜 직접 만들었나</h2>
<p>Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다.</p>
<p>Portainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다.</p>
<hr>
<h2 id="완성-화면">완성 화면</h2>
<ul>
<li>컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지)</li>
<li>Start / Stop / Restart 원클릭 제어</li>
<li>Logs 버튼으로 실시간 로그 스트리밍 (SSE)</li>
<li>5초 주기 자동 갱신</li>
</ul>
<p><img alt="대시보드 메인 UI - 컨테이너 카드 목록" loading="lazy" src="/images/homeserver-12-dashboard-ui.png"></p>
<h2 id="기술-스택">기술 스택</h2>
<table>
  <thead>
      <tr>
          <th>역할</th>
          <th>기술</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>백엔드</td>
          <td>Spring Boot 3.5, Java 17, Maven</td>
      </tr>
      <tr>
          <td>Docker 연동</td>
          <td>docker-java 3.3.6 (zerodep transport)</td>
      </tr>
      <tr>
          <td>실시간 로그</td>
          <td>SSE (Server-Sent Events) + WebFlux</td>
      </tr>
      <tr>
          <td>프론트엔드</td>
          <td>React 19, plain CSS</td>
      </tr>
      <tr>
          <td>배포</td>
          <td>Docker Compose + nginx</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="백엔드-spring-boot">백엔드 (Spring Boot)</h2>
<h3 id="의존성-설정">의존성 설정</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-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">&lt;!-- Docker Java --&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;dependency&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;groupId&gt;</span>com.github.docker-java<span style="color:#7ee787">&lt;/groupId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;artifactId&gt;</span>docker-java<span style="color:#7ee787">&lt;/artifactId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;version&gt;</span>3.3.6<span style="color:#7ee787">&lt;/version&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;/dependency&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;dependency&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;groupId&gt;</span>com.github.docker-java<span style="color:#7ee787">&lt;/groupId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;artifactId&gt;</span>docker-java-transport-zerodep<span style="color:#7ee787">&lt;/artifactId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;version&gt;</span>3.3.6<span style="color:#7ee787">&lt;/version&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;/dependency&gt;</span>
</span></span></code></pre></div><p>transport 선택이 중요하다. <code>httpclient5</code> 트랜스포트는 Unix 소켓을 제대로 처리하지 못해서 아래 에러가 난다.</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-plain" data-lang="plain"><span style="display:flex;"><span>Connect to unix://localhost:2375 failed
</span></span></code></pre></div><p><strong><code>zerodep</code> 트랜스포트</strong>를 써야 <code>/var/run/docker.sock</code> 연결이 정상 동작한다.</p>
<h3 id="dockerclient-빈-설정">DockerClient 빈 설정</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Configuration</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">DockerConfig</span><span style="color:#6e7681"> </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:#d2a8ff;font-weight:bold">@Value</span>(<span style="color:#a5d6ff">&#34;${docker.host:unix:///var/run/docker.sock}&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>dockerHost;<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:#d2a8ff;font-weight:bold">@Bean</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>DockerClient<span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">dockerClient</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>DockerClientConfig<span style="color:#6e7681"> </span>config<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>DefaultDockerClientConfig.createDefaultConfigBuilder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withDockerHost(dockerHost)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.build();<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>DockerHttpClient<span style="color:#6e7681"> </span>httpClient<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>ZerodepDockerHttpClient.Builder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.dockerHost(config.getDockerHost())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.sslConfig(config.getSSLConfig())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.build();<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:#ff7b72">return</span><span style="color:#6e7681"> </span>DockerClientImpl.getInstance(config,<span style="color:#6e7681"> </span>httpClient);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>application.properties</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-plain" data-lang="plain"><span style="display:flex;"><span>docker.host=unix:///var/run/docker.sock
</span></span></code></pre></div><h3 id="컨테이너-목록-조회">컨테이너 목록 조회</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>ContainerSummaryDto<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">listContainers</span>(<span style="color:#ff7b72">boolean</span><span style="color:#6e7681"> </span>all)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>dockerClient.listContainersCmd()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.withShowAll(all)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.exec()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.stream()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.map(<span style="color:#ff7b72">this</span>::toDto)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.toList();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="실시간-로그-스트리밍-sse">실시간 로그 스트리밍 (SSE)</h3>
<p>이 프로젝트의 핵심이다. docker-java의 콜백 기반 API를 WebFlux의 <code>Flux</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>Flux<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">streamLogs</span>(String<span style="color:#6e7681"> </span>containerId,<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>tail)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>Flux.create(sink<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>dockerClient.logContainerCmd(containerId)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withStdOut(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withStdErr(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withFollowStream(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withTail(tail)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withTimestamps(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.exec(<span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>ResultCallback.Adapter<span style="color:#ff7b72;font-weight:bold">&lt;&gt;</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onNext</span>(Frame<span style="color:#6e7681"> </span>frame)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.next(<span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>String(frame.getPayload()).stripTrailing());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </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:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onError</span>(Throwable<span style="color:#6e7681"> </span>throwable)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.error(throwable);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </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:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onComplete</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.complete();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>});<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>});<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><p>컨트롤러에서 SSE로 내보낸다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@GetMapping</span>(value<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;/{id}/logs&#34;</span>,<span style="color:#6e7681"> </span>produces<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>MediaType.TEXT_EVENT_STREAM_VALUE)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>Flux<span style="color:#ff7b72;font-weight:bold">&lt;</span>ServerSentEvent<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">streamLogs</span>(<span style="color:#d2a8ff;font-weight:bold">@PathVariable</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>id,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                                                 </span><span style="color:#d2a8ff;font-weight:bold">@RequestParam</span>(defaultValue<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;100&#34;</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>tail)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>containerService.streamLogs(id,<span style="color:#6e7681"> </span>tail)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.map(line<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>ServerSentEvent.<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;</span>builder().data(line).build());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="api-목록">API 목록</h3>
<table>
  <thead>
      <tr>
          <th>Method</th>
          <th>Endpoint</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GET</td>
          <td><code>/api/containers?all=true</code></td>
          <td>컨테이너 목록</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/containers/{id}/logs?tail=100</code></td>
          <td>SSE 로그 스트리밍</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/start</code></td>
          <td>시작</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/stop</code></td>
          <td>정지</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/restart</code></td>
          <td>재시작</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="프론트엔드-react">프론트엔드 (React)</h2>
<h3 id="프로젝트-구조">프로젝트 구조</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>src/
</span></span><span style="display:flex;"><span>  api.js               # API 호출 함수
</span></span><span style="display:flex;"><span>  App.js               # 루트 컴포넌트 (목록 + 폴링)
</span></span><span style="display:flex;"><span>  App.css              # 전체 스타일 (다크 테마)
</span></span><span style="display:flex;"><span>  components/
</span></span><span style="display:flex;"><span>    ContainerCard.js   # 카드 UI + 액션 버튼
</span></span><span style="display:flex;"><span>    LogViewer.js       # SSE 실시간 로그 모달
</span></span></code></pre></div><h3 id="5초-폴링">5초 폴링</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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> POLL_INTERVAL <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">5000</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">default</span> <span style="color:#ff7b72">function</span> App() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> [containers, setContainers] <span style="color:#ff7b72;font-weight:bold">=</span> useState([]);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> [logTarget, setLogTarget] <span style="color:#ff7b72;font-weight:bold">=</span> useState(<span style="color:#79c0ff">null</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> load <span style="color:#ff7b72;font-weight:bold">=</span> useCallback(<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> fetchContainers();
</span></span><span style="display:flex;"><span>    setContainers(data);
</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>    load();
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> timer <span style="color:#ff7b72;font-weight:bold">=</span> setInterval(load, POLL_INTERVAL);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> () =&gt; clearInterval(timer);
</span></span><span style="display:flex;"><span>  }, [load]);
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// ...
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>setInterval</code>을 <code>useEffect</code> cleanup에서 <code>clearInterval</code>로 정리해야 컴포넌트 언마운트 시 폴링이 멈춘다.</p>
<h3 id="상태별-색상-배지">상태별 색상 배지</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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> STATE_META <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  running<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Running&#39;</span>, color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#22c55e&#39;</span> },
</span></span><span style="display:flex;"><span>  exited<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Exited&#39;</span>,  color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#ef4444&#39;</span> },
</span></span><span style="display:flex;"><span>  paused<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Paused&#39;</span>,  color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#f59e0b&#39;</span> },
</span></span><span style="display:flex;"><span>  created<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Created&#39;</span>, color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#6b7280&#39;</span> },
</span></span><span style="display:flex;"><span>  dead<span style="color:#ff7b72;font-weight:bold">:</span>    { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Dead&#39;</span>,    color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#991b1b&#39;</span> },
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><h3 id="sse-실시간-로그">SSE 실시간 로그</h3>
<p>브라우저 내장 <code>EventSource</code> API를 사용한다. WebSocket보다 단방향 스트리밍에 적합하고 서버 구현도 단순하다.</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>useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> es <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> EventSource(getLogUrl(containerId));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onopen <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt; setConnected(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onmessage <span style="color:#ff7b72;font-weight:bold">=</span> (e) =&gt; {
</span></span><span style="display:flex;"><span>    setLines((prev) =&gt; {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> next <span style="color:#ff7b72;font-weight:bold">=</span> [...prev, e.data];
</span></span><span style="display:flex;"><span>      <span style="color:#8b949e;font-style:italic">// 메모리 관리: 최대 2000줄 유지
</span></span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">return</span> next.length <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">2000</span> <span style="color:#ff7b72;font-weight:bold">?</span> next.slice(<span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">2000</span>) <span style="color:#ff7b72;font-weight:bold">:</span> next;
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onerror <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt; {
</span></span><span style="display:flex;"><span>    setConnected(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span>    setError(<span style="color:#a5d6ff">&#39;Connection lost.&#39;</span>);
</span></span><span style="display:flex;"><span>    es.close();
</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> () =&gt; es.close(); <span style="color:#8b949e;font-style:italic">// 모달 닫으면 SSE 연결 종료
</span></span></span><span style="display:flex;"><span>}, [containerId]);
</span></span></code></pre></div><p>cleanup에서 <code>es.close()</code>를 빠뜨리면 모달을 닫아도 서버와 연결이 계속 유지된다. 꼭 넣어야 한다.</p>
<p><img alt="Logs 모달 - SSE 실시간 로그 스트리밍" loading="lazy" src="/images/homeserver-12-dashboard-logs.png"></p>
<hr>
<h2 id="배포">배포</h2>
<h3 id="백엔드-dockerfile-멀티스테이지-빌드">백엔드 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">eclipse-temurin:17-jdk</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">builder</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> .mvn/ .mvn/<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> mvnw pom.xml ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> ./mvnw 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> ./mvnw 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:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">eclipse-temurin:17-jre</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>builder /app/target/*.jar app.jar<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><h3 id="프론트엔드-dockerfile-멀티스테이지-빌드">프론트엔드 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 package-lock.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/build /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><h3 id="nginxconf--sse-버퍼링-비활성화가-핵심">nginx.conf — SSE 버퍼링 비활성화가 핵심</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-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://100.109.108.36:28080</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_http_version</span> <span style="color:#a5d6ff">1</span><span style="color:#a5d6ff">.1</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Connection</span> <span style="color:#a5d6ff">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_buffering</span> <span style="color:#79c0ff;font-weight:bold">off</span>;       <span style="color:#8b949e;font-style:italic"># SSE 필수 설정
</span></span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_cache</span> <span style="color:#79c0ff;font-weight:bold">off</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">chunked_transfer_encoding</span> <span style="color:#79c0ff;font-weight:bold">on</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 style="color:#8b949e;font-style:italic"># SPA 라우팅
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>proxy_buffering off</code>가 없으면 nginx가 SSE 응답을 버퍼에 쌓았다가 한꺼번에 보내서 실시간성이 깨진다. 삽질 포인트다.</p>
<h3 id="배포-플로우">배포 플로우</h3>
<p>서버에서 직접 빌드하면 node_modules 설치에 시간이 오래 걸린다. 로컬에서 이미지를 만들고 tar로 전송하는 방식을 선택했다.</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 build -t dashboard-front ./frontend
</span></span><span style="display:flex;"><span>docker build -t dashboard-back ./backend
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># tar로 저장</span>
</span></span><span style="display:flex;"><span>docker save dashboard-front | gzip &gt; dashboard-front.tar.gz
</span></span><span style="display:flex;"><span>docker save dashboard-back | gzip &gt; dashboard-back.tar.gz
</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>scp dashboard-front.tar.gz dashboard-back.tar.gz user@홈서버IP:~/dashboard/
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 서버에서 로드 &amp; 실행</span>
</span></span><span style="display:flex;"><span>ssh user@홈서버IP
</span></span><span style="display:flex;"><span>cd ~/dashboard
</span></span><span style="display:flex;"><span>docker load &lt; dashboard-front.tar.gz
</span></span><span style="display:flex;"><span>docker load &lt; dashboard-back.tar.gz
</span></span><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><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">dashboard-back</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">dashboard-back</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">dashboard-back</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;28080:8080&#34;</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">/var/run/docker.sock:/var/run/docker.sock</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">dashboard-front</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">dashboard-front</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">dashboard-front</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;23000:80&#34;</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>/var/run/docker.sock</code>을 마운트해서 호스트의 Docker 데몬에 접근한다.</p>
<hr>
<h2 id="구현하면서-신경-쓴-것들">구현하면서 신경 쓴 것들</h2>
<ul>
<li><strong>SSE cleanup</strong>: <code>useEffect</code> return에서 <code>es.close()</code> 호출 필수. 안 하면 모달 닫아도 연결 유지</li>
<li><strong>메모리 관리</strong>: 로그 줄 수를 2000줄로 제한해 장시간 열어둬도 브라우저가 버벅이지 않게 처리</li>
<li><strong>nginx SSE 설정</strong>: <code>proxy_buffering off</code> 없으면 로그가 실시간으로 안 온다</li>
<li><strong>zerodep transport</strong>: docker-java에서 Unix 소켓 연결 시 반드시 zerodep 사용</li>
</ul>
<p>Stop 버튼을 누르면 실제로 컨테이너가 정지되는 걸 Portainer에서 교차 확인했다.</p>
<p><img alt="Portainer에서 Stop 확인 - 컨테이너 정지 상태" loading="lazy" src="/images/homeserver-12-portainer-confirm.png"></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>12편에 걸쳐 ThinkPad 노트북 한 대로 홈서버를 구축한 과정을 정리했다.</p>
<p>처음엔 파일 서버 하나 만들려고 시작했는데 어쩌다 보니 사진 관리, 비밀번호 관리, 모니터링, 보안, 자동 백업에 커스텀 대시보드까지 만들어버렸다. 홈서버는 공부할 게 계속 생긴다는 게 가장 큰 장점이자 단점이다.</p>
<h2 id="시리즈-구성">시리즈 구성</h2>
<p>이 구축기는 총 12편으로 구성된다.</p>
<ol>
<li><a href="/posts/part-01-intro">왜 홈서버인가? + 전체 아키텍처</a></li>
<li><a href="/posts/part-02-ubuntu-tailscale">Ubuntu Server 설치 + Tailscale VPN</a></li>
<li><a href="/posts/part-03-ssd-filebrowser">외장 SSD 마운트 + Filebrowser 원격 파일 관리</a></li>
<li><a href="/posts/part-04-immich">Immich로 구글 포토 대체하기</a></li>
<li><a href="/posts/part-05-vaultwarden">Vaultwarden으로 비밀번호 자체 호스팅</a></li>
<li><a href="/posts/part-06-portainer">Portainer CE로 Docker GUI 관리</a></li>
<li><a href="/posts/part-07-monitoring">Grafana + Prometheus로 홈서버 모니터링</a></li>
<li><a href="/posts/part-08-fail2ban">Fail2ban으로 SSH 브루트포스 차단</a></li>
<li><a href="/posts/part-09-ssl">certbot &ndash;expand로 SSL 서브도메인 추가</a></li>
<li><a href="/posts/part-10-postgresql-backup">PostgreSQL 자동 백업 (pg_dump + cron)</a></li>
<li><a href="/posts/part-11-tuning">TLP + thinkfan + Swap 튜닝으로 운영 최적화</a></li>
<li><a href="/posts/part-12-dashboard">직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE)</a> ← 지금 여기</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>Spring WebFlux와 SSE로 구현하는 AI 스트리밍 API: 실시간 대화 경험 (5편)</title>
      <link>https://chanyeols.com/posts/spring-webflux-sse-ai-streaming-api/</link>
      <pubDate>Sat, 21 Mar 2026 14:30:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/spring-webflux-sse-ai-streaming-api/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;[Dev-Fortune] 시리즈 다시보기&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://chanyeols.com/posts/spring-ai-ollama-chatbot-planning/&#34;&gt;1편: 기획부터 스택 선정까지&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://chanyeols.com/posts/ollama-spring-boot-local-llm-setup/&#34;&gt;2편: 로컬 LLM Ollama 연동&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://chanyeols.com/posts/spring-ai-rag-simplevectorstore-ingestion/&#34;&gt;3편: RAG와 Vector Store 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://chanyeols.com/posts/prompt-engineering-ai-persona-tuning/&#34;&gt;4편: 프롬프트 엔지니어링 실전&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;1-서론-ai-답변-왜-기다리게-하나요&#34;&gt;1. 서론: AI 답변, 왜 기다리게 하나요?&lt;/h2&gt;
&lt;p&gt;사용자 경험(UX)을 위해 한 글자씩 타이핑하듯 보여주는 스트리밍 방식은 필수적입니다. 우리 프로젝트는 &lt;strong&gt;WebFlux&lt;/strong&gt;와 **SSE(Server-Sent Events)**를 활용했습니다.&lt;/p&gt;
&lt;h2 id=&#34;2-스트리밍-시퀀스-다이어그램&#34;&gt;2. 스트리밍 시퀀스 다이어그램&lt;/h2&gt;
&lt;p&gt;서버와 클라이언트 간의 끊임없는 데이터 흐름을 살펴보세요.&lt;/p&gt;
&lt;div class=&#34;mermaid&#34;&gt;
sequenceDiagram
    participant U as User
    participant S as Spring Server (Flux)
    participant A as AI Model (Ollama)
&lt;pre&gt;&lt;code&gt;U-&amp;gt;&amp;gt;S: POST /chat (Request)
Note over S,A: Connection Stay Open
A--&amp;gt;&amp;gt;S: &amp;quot;오늘의&amp;quot; (Token 1)
S--&amp;gt;&amp;gt;U: data: &amp;quot;오늘의&amp;quot;
A--&amp;gt;&amp;gt;S: &amp;quot; 사주는&amp;quot; (Token 2)
S--&amp;gt;&amp;gt;U: data: &amp;quot; 사주는&amp;quot;
Note right of U: 사용자는 실시간으로 글자가 보임
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h2 id=&#34;3-flux와-sse&#34;&gt;3. Flux와 SSE&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;MediaType.TEXT_EVENT_STREAM_VALUE&lt;/code&gt;를 사용하여 AI가 단어(Token)를 생성할 때마다 즉시 클라이언트로 전송합니다. 비차단(Non-blocking) 방식인 WebFlux는 답변을 기다리는 동안 쓰레드를 점유하지 않아 성능적으로도 우수합니다.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<blockquote>
<p><strong>[Dev-Fortune] 시리즈 다시보기</strong></p>
</blockquote>
<ul>
<li><a href="/posts/spring-ai-ollama-chatbot-planning/">1편: 기획부터 스택 선정까지</a></li>
<li><a href="/posts/ollama-spring-boot-local-llm-setup/">2편: 로컬 LLM Ollama 연동</a></li>
<li><a href="/posts/spring-ai-rag-simplevectorstore-ingestion/">3편: RAG와 Vector Store 구축</a></li>
<li><a href="/posts/prompt-engineering-ai-persona-tuning/">4편: 프롬프트 엔지니어링 실전</a></li>
</ul>
<h2 id="1-서론-ai-답변-왜-기다리게-하나요">1. 서론: AI 답변, 왜 기다리게 하나요?</h2>
<p>사용자 경험(UX)을 위해 한 글자씩 타이핑하듯 보여주는 스트리밍 방식은 필수적입니다. 우리 프로젝트는 <strong>WebFlux</strong>와 **SSE(Server-Sent Events)**를 활용했습니다.</p>
<h2 id="2-스트리밍-시퀀스-다이어그램">2. 스트리밍 시퀀스 다이어그램</h2>
<p>서버와 클라이언트 간의 끊임없는 데이터 흐름을 살펴보세요.</p>
<div class="mermaid">
sequenceDiagram
    participant U as User
    participant S as Spring Server (Flux)
    participant A as AI Model (Ollama)
<pre><code>U-&gt;&gt;S: POST /chat (Request)
Note over S,A: Connection Stay Open
A--&gt;&gt;S: &quot;오늘의&quot; (Token 1)
S--&gt;&gt;U: data: &quot;오늘의&quot;
A--&gt;&gt;S: &quot; 사주는&quot; (Token 2)
S--&gt;&gt;U: data: &quot; 사주는&quot;
Note right of U: 사용자는 실시간으로 글자가 보임
</code></pre>
</div>
<h2 id="3-flux와-sse">3. Flux와 SSE</h2>
<p><code>MediaType.TEXT_EVENT_STREAM_VALUE</code>를 사용하여 AI가 단어(Token)를 생성할 때마다 즉시 클라이언트로 전송합니다. 비차단(Non-blocking) 방식인 WebFlux는 답변을 기다리는 동안 쓰레드를 점유하지 않아 성능적으로도 우수합니다.</p>
<h2 id="4-클라이언트에서의-처리">4. 클라이언트에서의 처리</h2>
<p>프론트엔드에서는 <code>fetch</code> API의 <code>getReader()</code>를 사용하여 한 글자씩 화면에 덧붙이는 작업을 수행합니다.</p>
<p><strong>다음 6편에서는 지금까지의 기술들을 하나로 묶어 전체 워크플로우를 심층 분석해 보겠습니다.</strong></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
