왜 직접 만들었나
Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다.
Portainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다.
완성 화면
- 컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지)
- Start / Stop / Restart 원클릭 제어
- Logs 버튼으로 실시간 로그 스트리밍 (SSE)
- 5초 주기 자동 갱신

기술 스택
| 역할 | 기술 |
|---|---|
| 백엔드 | 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]);
// ...
}
setInterval을 useEffect 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()를 빠뜨리면 모달을 닫아도 서버와 연결이 계속 유지된다. 꼭 넣어야 한다.

배포
백엔드 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:
useEffectreturn에서es.close()호출 필수. 안 하면 모달 닫아도 연결 유지 - 메모리 관리: 로그 줄 수를 2000줄로 제한해 장시간 열어둬도 브라우저가 버벅이지 않게 처리
- nginx SSE 설정:
proxy_buffering off없으면 로그가 실시간으로 안 온다 - zerodep transport: docker-java에서 Unix 소켓 연결 시 반드시 zerodep 사용
Stop 버튼을 누르면 실제로 컨테이너가 정지되는 걸 Portainer에서 교차 확인했다.

마무리
12편에 걸쳐 ThinkPad 노트북 한 대로 홈서버를 구축한 과정을 정리했다.
처음엔 파일 서버 하나 만들려고 시작했는데 어쩌다 보니 사진 관리, 비밀번호 관리, 모니터링, 보안, 자동 백업에 커스텀 대시보드까지 만들어버렸다. 홈서버는 공부할 게 계속 생긴다는 게 가장 큰 장점이자 단점이다.
시리즈 구성
이 구축기는 총 12편으로 구성된다.
- 왜 홈서버인가? + 전체 아키텍처
- Ubuntu Server 설치 + Tailscale VPN
- 외장 SSD 마운트 + Filebrowser 원격 파일 관리
- Immich로 구글 포토 대체하기
- Vaultwarden으로 비밀번호 자체 호스팅
- Portainer CE로 Docker GUI 관리
- Grafana + Prometheus로 홈서버 모니터링
- Fail2ban으로 SSH 브루트포스 차단
- certbot –expand로 SSL 서브도메인 추가
- PostgreSQL 자동 백업 (pg_dump + cron)
- TLP + thinkfan + Swap 튜닝으로 운영 최적화
- 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) ← 지금 여기