왜 직접 만들었나

Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다.

Portainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다.


완성 화면

  • 컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지)
  • Start / Stop / Restart 원클릭 제어
  • Logs 버튼으로 실시간 로그 스트리밍 (SSE)
  • 5초 주기 자동 갱신

대시보드 메인 UI - 컨테이너 카드 목록

기술 스택

역할 기술
백엔드 Spring Boot 3.5, Java 17, Maven
Docker 연동 docker-java 3.3.6 (zerodep transport)
실시간 로그 SSE (Server-Sent Events) + WebFlux
프론트엔드 React 19, plain CSS
배포 Docker Compose + nginx

백엔드 (Spring Boot)

의존성 설정

<!-- Docker Java -->
<dependency>
    <groupId>com.github.docker-java</groupId>
    <artifactId>docker-java</artifactId>
    <version>3.3.6</version>
</dependency>
<dependency>
    <groupId>com.github.docker-java</groupId>
    <artifactId>docker-java-transport-zerodep</artifactId>
    <version>3.3.6</version>
</dependency>

transport 선택이 중요하다. httpclient5 트랜스포트는 Unix 소켓을 제대로 처리하지 못해서 아래 에러가 난다.

Connect to unix://localhost:2375 failed

zerodep 트랜스포트를 써야 /var/run/docker.sock 연결이 정상 동작한다.

DockerClient 빈 설정

@Configuration
public class DockerConfig {

    @Value("${docker.host:unix:///var/run/docker.sock}")
    private String dockerHost;

    @Bean
    public DockerClient dockerClient() {
        DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
                .withDockerHost(dockerHost)
                .build();

        DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder()
                .dockerHost(config.getDockerHost())
                .sslConfig(config.getSSLConfig())
                .build();

        return DockerClientImpl.getInstance(config, httpClient);
    }
}

application.properties:

docker.host=unix:///var/run/docker.sock

컨테이너 목록 조회

public List<ContainerSummaryDto> listContainers(boolean all) {
    return dockerClient.listContainersCmd()
            .withShowAll(all)
            .exec()
            .stream()
            .map(this::toDto)
            .toList();
}

실시간 로그 스트리밍 (SSE)

이 프로젝트의 핵심이다. docker-java의 콜백 기반 API를 WebFlux의 Flux로 브리징한다.

public Flux<String> streamLogs(String containerId, int tail) {
    return Flux.create(sink -> {
        dockerClient.logContainerCmd(containerId)
                .withStdOut(true)
                .withStdErr(true)
                .withFollowStream(true)
                .withTail(tail)
                .withTimestamps(true)
                .exec(new ResultCallback.Adapter<>() {
                    @Override
                    public void onNext(Frame frame) {
                        sink.next(new String(frame.getPayload()).stripTrailing());
                    }

                    @Override
                    public void onError(Throwable throwable) {
                        sink.error(throwable);
                    }

                    @Override
                    public void onComplete() {
                        sink.complete();
                    }
                });
    });
}

컨트롤러에서 SSE로 내보낸다.

@GetMapping(value = "/{id}/logs", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamLogs(@PathVariable String id,
                                                 @RequestParam(defaultValue = "100") int tail) {
    return containerService.streamLogs(id, tail)
            .map(line -> ServerSentEvent.<String>builder().data(line).build());
}

API 목록

Method Endpoint 설명
GET /api/containers?all=true 컨테이너 목록
GET /api/containers/{id}/logs?tail=100 SSE 로그 스트리밍
POST /api/containers/{id}/start 시작
POST /api/containers/{id}/stop 정지
POST /api/containers/{id}/restart 재시작

프론트엔드 (React)

프로젝트 구조

src/
  api.js               # API 호출 함수
  App.js               # 루트 컴포넌트 (목록 + 폴링)
  App.css              # 전체 스타일 (다크 테마)
  components/
    ContainerCard.js   # 카드 UI + 액션 버튼
    LogViewer.js       # SSE 실시간 로그 모달

5초 폴링

const POLL_INTERVAL = 5000;

export default function App() {
  const [containers, setContainers] = useState([]);
  const [logTarget, setLogTarget] = useState(null);

  const load = useCallback(async () => {
    const data = await fetchContainers();
    setContainers(data);
  }, []);

  useEffect(() => {
    load();
    const timer = setInterval(load, POLL_INTERVAL);
    return () => clearInterval(timer);
  }, [load]);
  // ...
}

setIntervaluseEffect cleanup에서 clearInterval로 정리해야 컴포넌트 언마운트 시 폴링이 멈춘다.

상태별 색상 배지

const STATE_META = {
  running: { label: 'Running', color: '#22c55e' },
  exited:  { label: 'Exited',  color: '#ef4444' },
  paused:  { label: 'Paused',  color: '#f59e0b' },
  created: { label: 'Created', color: '#6b7280' },
  dead:    { label: 'Dead',    color: '#991b1b' },
};

SSE 실시간 로그

브라우저 내장 EventSource API를 사용한다. WebSocket보다 단방향 스트리밍에 적합하고 서버 구현도 단순하다.

useEffect(() => {
  const es = new EventSource(getLogUrl(containerId));

  es.onopen = () => setConnected(true);

  es.onmessage = (e) => {
    setLines((prev) => {
      const next = [...prev, e.data];
      // 메모리 관리: 최대 2000줄 유지
      return next.length > 2000 ? next.slice(-2000) : next;
    });
  };

  es.onerror = () => {
    setConnected(false);
    setError('Connection lost.');
    es.close();
  };

  return () => es.close(); // 모달 닫으면 SSE 연결 종료
}, [containerId]);

cleanup에서 es.close()를 빠뜨리면 모달을 닫아도 서버와 연결이 계속 유지된다. 꼭 넣어야 한다.

Logs 모달 - SSE 실시간 로그 스트리밍


배포

백엔드 Dockerfile (멀티스테이지 빌드)

FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -q
COPY src/ src/
RUN ./mvnw package -DskipTests -q

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

프론트엔드 Dockerfile (멀티스테이지 빌드)

FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --silent
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

nginx.conf — SSE 버퍼링 비활성화가 핵심

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://100.109.108.36:28080;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_buffering off;       # SSE 필수 설정
        proxy_cache off;
        chunked_transfer_encoding on;
    }

    location / {
        try_files $uri $uri/ /index.html;  # SPA 라우팅
    }
}

proxy_buffering off가 없으면 nginx가 SSE 응답을 버퍼에 쌓았다가 한꺼번에 보내서 실시간성이 깨진다. 삽질 포인트다.

배포 플로우

서버에서 직접 빌드하면 node_modules 설치에 시간이 오래 걸린다. 로컬에서 이미지를 만들고 tar로 전송하는 방식을 선택했다.

# 로컬에서 이미지 빌드
docker build -t dashboard-front ./frontend
docker build -t dashboard-back ./backend

# tar로 저장
docker save dashboard-front | gzip > dashboard-front.tar.gz
docker save dashboard-back | gzip > dashboard-back.tar.gz

# 서버로 전송
scp dashboard-front.tar.gz dashboard-back.tar.gz user@홈서버IP:~/dashboard/

# 서버에서 로드 & 실행
ssh user@홈서버IP
cd ~/dashboard
docker load < dashboard-front.tar.gz
docker load < dashboard-back.tar.gz
docker compose up -d

docker-compose.yml

services:
  dashboard-back:
    image: dashboard-back
    container_name: dashboard-back
    ports:
      - "28080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    restart: unless-stopped

  dashboard-front:
    image: dashboard-front
    container_name: dashboard-front
    ports:
      - "23000:80"
    restart: unless-stopped

백엔드 컨테이너에도 /var/run/docker.sock을 마운트해서 호스트의 Docker 데몬에 접근한다.


구현하면서 신경 쓴 것들

  • SSE cleanup: useEffect return에서 es.close() 호출 필수. 안 하면 모달 닫아도 연결 유지
  • 메모리 관리: 로그 줄 수를 2000줄로 제한해 장시간 열어둬도 브라우저가 버벅이지 않게 처리
  • nginx SSE 설정: proxy_buffering off 없으면 로그가 실시간으로 안 온다
  • zerodep transport: docker-java에서 Unix 소켓 연결 시 반드시 zerodep 사용

Stop 버튼을 누르면 실제로 컨테이너가 정지되는 걸 Portainer에서 교차 확인했다.

Portainer에서 Stop 확인 - 컨테이너 정지 상태


마무리

12편에 걸쳐 ThinkPad 노트북 한 대로 홈서버를 구축한 과정을 정리했다.

처음엔 파일 서버 하나 만들려고 시작했는데 어쩌다 보니 사진 관리, 비밀번호 관리, 모니터링, 보안, 자동 백업에 커스텀 대시보드까지 만들어버렸다. 홈서버는 공부할 게 계속 생긴다는 게 가장 큰 장점이자 단점이다.

시리즈 구성

이 구축기는 총 12편으로 구성된다.

  1. 왜 홈서버인가? + 전체 아키텍처
  2. Ubuntu Server 설치 + Tailscale VPN
  3. 외장 SSD 마운트 + Filebrowser 원격 파일 관리
  4. Immich로 구글 포토 대체하기
  5. Vaultwarden으로 비밀번호 자체 호스팅
  6. Portainer CE로 Docker GUI 관리
  7. Grafana + Prometheus로 홈서버 모니터링
  8. Fail2ban으로 SSH 브루트포스 차단
  9. certbot –expand로 SSL 서브도메인 추가
  10. PostgreSQL 자동 백업 (pg_dump + cron)
  11. TLP + thinkfan + Swap 튜닝으로 운영 최적화
  12. 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) ← 지금 여기