사이드 프로젝트를 여러 개 운영하다 보면 서버 비용이 슬슬 신경 쓰이기 시작합니다. 지금의 맥미니 홈서버에 도달하기까지 꽤 몇 단계를 거쳤습니다.
여기까지 오게 된 이력
처음에는 집에 굴러다니던 레노버 노트북을 켜두는 것으로 시작했습니다. 개발용 노트북을 서버로 쓰는 거라 불안정했고, 노트북 뚜껑을 닫으면 절전 모드로 들어가는 문제도 있었습니다. 그래도 서버를 직접 운영해보는 첫 경험으로는 충분했습니다.
그 다음은 AWS로 넘어갔습니다. 프리티어를 사용했는데 리전 관련 데이터 전송 요금이 생각지도 못하게 붙어서 한 달에 2만원 가까이 청구됐습니다. 프리티어라고 완전히 무료는 아니라는 걸 몸으로 배웠습니다.
그래서 오라클 클라우드 무료 티어로 이동했습니다. Always Free 인스턴스는 진짜 무료인데, 스펙이 너무 낮아서 Docker 컨테이너를 여러 개 올리면 메모리가 금방 찼습니다. 사이드 프로젝트가 늘어날수록 한계가 뚜렷했습니다.
결국 선택한 게 Mac Mini (M1, 16GB)입니다. 초기 비용이 들지만 월 운영비가 전기세 정도라 1~2년이면 클라우드보다 저렴해집니다.
왜 맥미니인가?
홈서버 장비를 고를 때 가장 중요하게 본 기준은 세 가지였습니다.
- 저전력: 24시간 켜둬야 하니까 전기세가 중요합니다. 맥미니 M1은 아이들 시 6~7W 수준으로 라즈베리파이와 비슷한 소비전력입니다.
- 조용함: 거실에 놓을 거라 팬 소음이 거슬려선 안 됩니다. M1 기반이라 평소엔 완전 무소음입니다.
- 성능: 여러 Docker 컨테이너를 동시에 돌려야 해서 라즈베리파이로는 부족할 것 같았습니다. M1 칩의 성능은 충분하고도 남습니다.
macOS가 서버 OS로서 단점이 있긴 하지만, Docker를 통해 리눅스 컨테이너를 띄우면 대부분의 서버 작업을 충분히 할 수 있습니다.
전체 아키텍처
구성은 생각보다 단순합니다. 모든 서비스를 Docker 컨테이너로 올리고, 앞단에 Nginx 컨테이너가 리버스 프록시 역할을 합니다.
인터넷 → 공유기 포트포워딩(80, 443)
→ Nginx 컨테이너 (리버스 프록시 + SSL)
→ serv-api:8080 (메인 API 서비스)
→ serv-image:8280 (이미지 호스팅)
→ serv-lol:8300 (LOL 전적 트래커)
→ Jenkins:8090 (CI/CD)
Docker Compose로 서비스 관리
서비스마다 별도 폴더를 두고 docker-compose.yml을 작성했습니다. 전체를 하나의 Compose 파일로 묶는 방법도 있지만, 서비스별로 독립적으로 관리하는 게 더 편했습니다.
각 서비스는 빌드된 WAR 파일과 설정 파일을 볼륨으로 마운트해서 컨테이너에 주입하는 방식으로 구성했습니다. JRE 이미지를 베이스로 하고 java -jar app.war로 실행합니다.
모든 서비스 컨테이너는 공통 네트워크에 연결합니다. Nginx 컨테이너도 같은 네트워크에 있어서 컨테이너 이름으로 서비스에 접근할 수 있습니다.
Nginx 리버스 프록시 설정
각 도메인마다 별도의 .conf 파일을 만들어 site-enabled 폴더에 넣어두고, Nginx 컨테이너가 이 폴더를 마운트해서 읽도록 했습니다.
# img.example.com.conf
server {
listen 443 ssl;
server_name img.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location /images/ {
alias /host/docker/images/;
expires 24h;
}
location / {
proxy_pass http://serv-image:8280;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server.forward-headers-strategy=framework를 application.properties에 추가해야 HTTPS 리다이렉트가 제대로 됩니다. 이걸 모르면 로그인 후 http로 돌아오는 현상이 생깁니다.Let's Encrypt SSL 인증서
SSL은 Certbot을 사용해 무료로 발급받았습니다. 서브도메인이 여러 개라 --expand 옵션으로 한 인증서에 여러 도메인을 추가했습니다.
sudo certbot certonly --webroot \
-w /var/www/certbot \
-d example.com \
-d img.example.com \
--expand
인증서 갱신은 Certbot이 cron으로 자동 처리합니다. 갱신 후 Nginx 리로드까지 자동화하려면 post-hook을 설정해두면 됩니다.
Jenkins CI/CD 파이프라인
맥미니에 Jenkins를 Docker 컨테이너로 띄웠습니다. GitHub 웹훅을 연동해서 main 브랜치에 push가 오면 자동으로 빌드 → 패키징 → 컨테이너 재시작이 이루어집니다.
파이프라인은 크게 세 단계입니다. Checkout 단계에서 소스를 받아오고, Build 단계에서 Gradle로 WAR 파일을 생성합니다. Deploy 단계에서 빌드 결과물을 배포 디렉토리에 복사하고 해당 서비스 컨테이너를 재시작합니다.
Jenkins 컨테이너 안에서 호스트의 Docker 명령을 실행할 수 있도록 /var/run/docker.sock을 볼륨으로 마운트해두는 게 핵심입니다. 이렇게 하면 Jenkins가 직접 docker compose restart를 호출할 수 있습니다.
덕분에 로컬에서 코드 수정하고 git push 한 번으로 배포가 끝납니다. 개발 사이클이 많이 빨라졌습니다.
외부 접근 구성 — Tailscale
공유기 포트포워딩 없이 Tailscale을 사용해서 외부 접근을 구성했습니다. Tailscale은 WireGuard 기반 VPN으로, 설치된 디바이스끼리 100번대 가상 IP로 통신할 수 있습니다. 공유기 설정을 건드릴 필요 없고, 고정 IP나 DDNS도 필요 없습니다.
Dozzle 같은 Docker 컨테이너 모니터링 툴도 Tailscale 가상 IP로만 접근하도록 구성해서, 내 디바이스에서만 볼 수 있게 처리했습니다. 퍼블릭 인터넷에 노출할 필요 없는 관리 도구들을 안전하게 운영하기에 딱 좋습니다.
→ 자세한 구성은 Tailscale로 홈서버 외부 접근 구성하기를 참고하세요.