카카오톡 같은 크로스플랫폼 메신저 ‘딩동(DingDong)’ 만들기 프로젝트 3탄!
이번 글에서는 우리 집 메인 서버인 유그린(Ugreen) NAS(UGOS Pro)의 Docker 환경 위에, 스마트폰 보안 통신(HTTPS)과 도메인 라우팅의 대문 역할을 맡을 Caddy 웹서버와 메신저의 핵심 데이터베이스인 PostgreSQL 16을 각각 독립된 컨테이너로 분리해서 세팅합니다.
1. 왜 Caddy와 PostgreSQL을 분리해서 설치할까요?
처음 Docker를 세팅할 때는 하나의 docker-compose.yml에 웹서버와 DB를 함께 묶기도 하지만, 홈서버를 오래 운영하려면 역할에 따라 분리해 두는 편이 훨씬 편합니다.
① Caddy를 독립시키는 이유 (홈서버 전체의 통합 대문)
- 자동 HTTPS(SSL) 전담: iOS와 Android 9 이상은 기본 설정에서 암호화되지 않은
http://통신을 막습니다. Caddy는 도메인만 적어 두면 Let’s Encrypt SSL 인증서를 자동으로 발급받고, 만료 전에 자동으로 갱신해 줍니다. (80/443 포트 연결과 도메인 설정이 유지되는 동안에 한해서입니다.) - 모든 서비스의 교통정리(Reverse Proxy): NAS 안에는 메신저 API 서버(3000), 통화 서버(7880)뿐 아니라 앞으로 추가할 서비스도 돌아갑니다. Caddy를 따로 띄워 두면 메신저 DB나 백엔드를 재시작해도 다른 서비스의 웹 접속은 끊기지 않습니다.
② 아키텍처 구조도
공유기의 80, 443 포트를 NAS로 포트포워딩하고, Caddy가 공용 Docker 네트워크(caddy-net)를 통해 각 컨테이너로 트래픽을 나눠 줍니다.
flowchart LR
classDef nodeStyle fill:#1e293b,stroke:#475569,stroke-width:2px,color:#ffffff;
classDef dbStyle fill:#0f172a,stroke:#0284c7,stroke-width:2px,color:#ffffff;
A["스마트폰 앱 (HTTPS 443)"]:::nodeStyle --> B["공유기 (80, 443 포트포워딩)"]:::nodeStyle
B --> C["유그린 NAS Caddy (자동 SSL)"]:::nodeStyle
C -->|"api.내도메인"| D["백엔드 API (내부 3000)"]:::nodeStyle
C -->|"rtc.내도메인"| E["LiveKit 통화 (내부 7880)"]:::nodeStyle
D --> F[("PostgreSQL 16 (DB)")]:::dbStyle2. 폴더 구조 및 사전 준비
💡 저장소 배치 팁
- Caddy 설정과 PostgreSQL DB: 응답 속도가 빠르고 HDD 절전(Sleep)의 영향을 받지 않도록 NVMe SSD 볼륨(
/volume1)에 둡니다.- 사진/동영상/파일 저장소: 나중에 백엔드와 연동할 때 대용량 미디어는 HDD 볼륨(
/volume2)에 마운트합니다.
/volume1/docker/ # SSD 볼륨 루트
├─ caddy/ # [독립] NAS 전체 리버스 프록시 전담
│ ├─ docker-compose.yml
│ ├─ .env # 도메인 환경변수 관리
│ ├─ Caddyfile # 라우팅 및 자동 SSL 설정
│ ├─ data/ # 발급된 SSL 인증서와 개인키 보관 폴더
│ └─ config/ # Caddy 설정 캐시 폴더
│
└─ dingdong/ # [독립] 딩동 프로젝트 전용 폴더
└─ postgres/ # PostgreSQL 16 데이터베이스
├─ docker-compose.yml
├─ .env # DB 계정 및 비밀번호 (보안 주의!)
└─ data/ # 실제 DB 데이터 영구 저장소
필수 사전 작업
Caddy가 인터넷에서 SSL 인증서를 발급받으려면 아래 네 가지가 먼저 준비되어야 합니다.
① 도메인 연결: 사용할 도메인(예: api.내도메인, rtc.내도메인)이 우리 집 공인 IP를 가리키고 있어야 합니다. DuckDNS 같은 무료 DDNS를 쓴다면 api.내이름.duckdns.org 같은 하위 도메인도 같은 IP로 연결되는지 먼저 확인해 보세요.
② 공유기 포트포워딩: 공유기 설정 화면에서 아래 포트를 NAS의 내부 IP로 포워딩합니다.
| 외부 포트 | 프로토콜 | 용도 |
|---|---|---|
| 80 | TCP | 인증서 발급 확인, HTTP → HTTPS 리다이렉트 |
| 443 | TCP | HTTPS 통신 |
| 443 | UDP | HTTP/3(QUIC) 통신 (선택) |
⚠️ PostgreSQL 포트(5432)는 포트포워딩하지 않습니다. DB는 인터넷에서 직접 접근할 이유가 전혀 없습니다.
③ 80/443 포트 충돌 확인: NAS에서 이미 80이나 443 포트를 쓰는 서비스가 있으면 Caddy가 뜨지 않습니다. NAS 터미널(SSH)에서 확인해 보세요.
sudo ss -tlnp | grep -E ':(80|443)\s'
아무것도 나오지 않으면 비어 있는 것입니다.
④ Docker 공용 네트워크 생성: 분리된 컨테이너들이 이름으로 통신할 수 있도록, NAS 터미널(SSH)에서 아래 명령으로 caddy-net 네트워크를 미리 만들어 둡니다.
docker network create caddy-net
3. [파트 1] Caddy 단독 설치하기 (리버스 프록시 & 자동 SSL)
먼저 NAS의 대문 역할을 할 Caddy부터 구축합니다.
3.1 Caddy 환경변수 파일 (/volume1/docker/caddy/.env)
도메인 주소를 한곳에서 관리하기 위해 .env 파일을 만듭니다.
# 도메인 설정 (예시입니다. 본인 도메인으로 변경하세요) DOMAIN_API=api.myfamily.com DOMAIN_RTC=rtc.myfamily.com
3.2 Caddy 설정 파일 (/volume1/docker/caddy/Caddyfile)
도메인 앞에 http://를 붙이지 않고 도메인 이름만 적으면, Caddy가 자동으로 HTTPS 인증서를 발급받아 적용합니다.
# 1. 딩동 API 서버 및 WebSocket 리버스 프록시 (자동 HTTPS 적용)
{$DOMAIN_API} {
# 대용량 사진/파일 전송을 위해 요청 본문 크기 상한을 310MB로 설정
request_body {
max_size 310MB
}
# 압축 전송 활성화 (모바일 데이터 절약)
encode zstd gzip
# 백엔드 API 컨테이너로 전달 (WebSocket 업그레이드도 Caddy가 처리함)
reverse_proxy dingdong-api:3000
# 보안 헤더 설정
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
-Server
}
}
# 2. LiveKit 통화 서버 시그널링 프록시 (자동 HTTPS 적용)
{$DOMAIN_RTC} {
reverse_proxy host.docker.internal:7880
}
📌 LiveKit(통화)은 포트가 하나 더 필요합니다. 위 설정은 통화 연결을 맺는 시그널링(7880) 만 Caddy를 거칩니다. 실제 음성 데이터를 주고받는 WebRTC 미디어는 Caddy를 거치지 않고 별도의 TCP/UDP 포트(LiveKit 기본값 기준 TCP 7881과 UDP 50000~60000번대)를 사용하므로, 통화 편에서 해당 포트를 공유기에 추가로 포워딩해야 합니다. 정확한 포트는 LiveKit 문서와 설정 파일에서 확인하고, 지금 단계에서는 80/443만 열어 두면 됩니다.
3.3 Caddy 실행 파일 (/volume1/docker/caddy/docker-compose.yml)
호스트의 80, 443 포트를 직접 매핑하고, 앞서 만든 caddy-net 네트워크에 연결합니다.
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
env_file: .env # Caddyfile의 {$DOMAIN_API} 변수를 읽기 위해 필수!
ports:
- "80:80" # 인증서 발급 확인 및 HTTP -> HTTPS 리다이렉트용
- "443:443" # 실제 보안 통신(HTTPS) 포트
- "443:443/udp" # HTTP/3 (QUIC) 지원용
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data:/data # 인증서와 개인키가 저장되는 곳 (삭제 주의!)
- ./config:/config
extra_hosts:
# 리눅스 Docker에서 host.docker.internal(호스트 IP)을 인식하기 위해 필요
- "host.docker.internal:host-gateway"
networks:
- caddy-net
networks:
caddy-net:
external: true # 미리 만들어 둔 외부 공용 네트워크 사용
⚠️
./data폴더에는 인증서와 개인키가 들어 있습니다. 지우면 인증서를 다시 발급받아야 하고, 짧은 기간에 재발급을 반복하면 Let’s Encrypt의 발급 횟수 제한에 걸릴 수 있습니다. 외부에 공유하거나 올리지 말고 백업할 때도 주의하세요.
3.4 Caddy 컨테이너 실행 및 인증서 발급 확인
터미널에서 Caddy 폴더로 이동해 컨테이너를 실행합니다.
cd /volume1/docker/caddy docker compose up -d # 인증서가 정상적으로 발급되었는지 로그 확인 (빠져나올 땐 Ctrl + C) docker compose logs -f
로그에 certificate obtained successfully 같은 문구가 보이면 무료 SSL 인증서 발급이 끝난 것입니다. 아직 백엔드(dingdong-api)를 띄우지 않았으므로 브라우저로 접속하면 502 Bad Gateway가 나오지만, 주소창에 보안 자물쇠(HTTPS)가 표시되면 정상입니다. 터미널에서는 아래처럼 확인할 수도 있습니다.
curl -I https://api.myfamily.com
🔧 인증서 발급이 안 될 때 체크리스트
- 도메인이 정말 우리 집 공인 IP를 가리키는가? (공유기 WAN IP와 비교)
- 공유기에서 80, 443이 NAS 내부 IP로 포워딩되어 있는가?
- 통신사가 80/443을 막고 있거나 공인 IP가 아닌(CGNAT) 환경은 아닌가?
- NAS에서 80/443을 이미 다른 서비스가 쓰고 있지는 않은가? (2장 ③번 명령)
원인은
docker compose logs의 에러 메시지에 대부분 나옵니다.
4. [파트 2] PostgreSQL 16 단독 설치하기 (데이터베이스)
이제 Caddy와 분리된 딩동 프로젝트 폴더에서 데이터베이스를 구축합니다.
4.1 PostgreSQL 환경변수 파일 (/volume1/docker/dingdong/postgres/.env)
먼저 NAS 터미널에서 무작위 비밀번호를 만듭니다. 영문과 숫자로만 된 값이라 나중에 접속 주소(DSN)에 넣을 때도 특수문자 때문에 깨지지 않습니다.
openssl rand -hex 24
출력된 값을 복사해 .env 파일에 넣습니다.
POSTGRES_DB=dingdong POSTGRES_USER=dingdong POSTGRES_PASSWORD=CHANGE_ME_위에서_만든_비밀번호로_교체
⚠️ 비밀번호는 다른 사람이 볼 수 있는 곳(블로그, GitHub, 캡처 화면)에 올리지 마세요. 파일 권한도 제한해 두세요.
chmod 600 .env
⚠️
POSTGRES_PASSWORD는 DB를 처음 만들 때 한 번만 적용됩니다. 이미data/폴더에 DB가 만들어진 뒤에는.env의 값만 바꿔도 비밀번호가 바뀌지 않습니다. 예전 글의 예시 비밀번호로 DB를 만들어 두었다면 아래처럼 직접 바꿔야 합니다.docker compose exec postgres psql -U dingdong -d dingdong \ -c "ALTER USER dingdong WITH PASSWORD '새_비밀번호';"바꾼 뒤에는
.env와 백엔드 쪽 접속 설정도 같은 값으로 맞춰야 합니다.
4.2 PostgreSQL 실행 파일 (/volume1/docker/dingdong/postgres/docker-compose.yml)
DB는 인터넷에서 직접 접근할 필요가 없습니다. 나중에 띄울 백엔드 서버와 내부에서만 통신하도록 전용 네트워크(dingdong-net)를 사용합니다.
services:
postgres:
image: postgres:16-alpine
container_name: dingdong-postgres
restart: unless-stopped
env_file: .env # POSTGRES_DB / USER / PASSWORD를 읽어 옴
environment:
TZ: Asia/Seoul
ports:
# NAS 자기 자신(127.0.0.1)에서만 접속 가능하게 묶습니다.
# 내 Mac에서는 4.4의 SSH 터널로 접속합니다.
- "127.0.0.1:5432:5432"
volumes:
- ./data:/var/lib/postgresql/data
healthcheck: # DB가 준비되었는지 확인 (다음 편에서 백엔드가 이를 기다립니다)
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- dingdong-net
networks:
dingdong-net:
name: dingdong-net # 백엔드 API 서버와 DB가 대화할 전용 내부 네트워크
💡 왜
127.0.0.1:5432:5432인가요?"5432:5432"처럼 적으면 NAS의 모든 네트워크 인터페이스에 DB 포트가 열립니다. “공유기에서 포트포워딩만 안 하면 안전하다”고 생각하기 쉽지만, 공유기의 UPnP 자동 포트포워딩이나 설정 실수, 같은 집 네트워크의 다른 기기를 생각하면 DB를 굳이 열어 둘 이유가 없습니다. 또 Docker는 자체적으로 방화벽 규칙을 만들기 때문에 NAS의 방화벽 설정만 믿을 수도 없습니다. 그래서 NAS 자신에게만 열어 두고, 개발 PC에서는 아래 SSH 터널로 접속합니다.
4.3 PostgreSQL 컨테이너 실행 및 연결 테스트
터미널에서 PostgreSQL 폴더로 이동해 컨테이너를 실행합니다.
cd /volume1/docker/dingdong/postgres # 1. 백그라운드 실행 docker compose up -d # 2. 컨테이너 정상 구동 여부 확인 (STATUS가 healthy면 준비 완료) docker compose ps
컨테이너 내부의 psql 명령어로 데이터베이스가 잘 만들어졌는지 확인합니다.
docker compose exec postgres psql -U dingdong -d dingdong -c "\l"
표 목록에 dingdong 데이터베이스가 보이면 DB 준비가 끝난 것입니다.
4.4 내 Mac에서 DB에 접속하기 (SSH 터널)
DBeaver 같은 도구로 접속하거나 다음 편들에서 스키마를 적용하려면 Mac에서 DB에 닿아야 합니다. Mac 터미널에서 아래 명령을 실행하고 창을 열어 둔 채로 사용하세요. (NAS의 SSH가 포트 포워딩을 막아 두지 않았다면 동작합니다.)
ssh -N -L 5432:127.0.0.1:5432 <NAS계정>@<NAS내부IP>
이제 Mac에서 localhost:5432로 접속하면 NAS의 DB에 연결됩니다. 이미 Mac에 PostgreSQL이 설치되어 5432 포트를 쓰고 있다면 -L 15432:127.0.0.1:5432처럼 바꾸고 localhost:15432로 접속하세요.명령어 실행 후 표 목록에 dingdong 데이터베이스가 보이면 DB 준비도 완벽히 끝났습니다!
5. 분리형 구조의 핵심 요약 (다음 편과의 연결 고리)
이렇게 Caddy와 PostgreSQL을 분리해 두면 관리가 훨씬 깔끔해집니다.
- 독립적인 유지보수: 앞으로 NAS에 다른 웹 서비스를 추가할 때는
/volume1/docker/caddy/Caddyfile에 도메인 두세 줄을 추가하고 아래 명령으로 설정을 다시 읽게 하면, DB나 다른 컨테이너를 재시작하지 않고 반영할 수 있습니다.docker exec -w /etc/caddy caddy caddy reload(설정이 반영되지 않으면docker compose restart caddy로 재시작해 보세요.) - 네트워크 분리: 다음 편에서 만들 백엔드 API 컨테이너(
dingdong-api)만caddy-net(Caddy와 통신용)과dingdong-net(DB와 통신용) 양쪽에 연결합니다. DB는caddy-net에 없고 포트도 NAS 자기 자신에게만 열려 있으므로, 인터넷에서 DB로 바로 들어오는 길이 없는 구조가 됩니다.
📌 이 구조는 DB를 인터넷에서 직접 닿지 않게 하는 기본 단계입니다. 안전하게 운영하려면 NAS와 Docker 이미지를 주기적으로 업데이트하고, DB 비밀번호를 지키고, 백업도 따로 준비해야 합니다. 백업은 시리즈 뒤쪽에서 다룹니다.
6. 다음 편 예고
이제 NAS의 HTTPS 통신을 맡을 Caddy 리버스 프록시와 메신저의 심장인 PostgreSQL 16 데이터베이스가 준비되었습니다.
다음 **[개발일지 #04 | 백엔드]**에서는 Go로 가벼운 API 서버를 만들어 Docker로 배포하고, Caddy를 통해 실제 도메인으로 접속해 봅니다. DB 스키마(회원, 채팅방, 메시지 테이블)를 만들고 마이그레이션하는 과정은 그다음 편(#05)에서 다룹니다!
📂 Part 1. 기획 & 아키텍처 설계
- ✅ [개발일지 #01 | 기획] 유그린 NAS와 Flutter로 만드는 나만의 프라이빗 메신저 ‘딩동’ (기획 & 아키텍처 편)
📂 Part 2. 홈서버 & 인프라 구축
- ✅ [개발일지 #02 | 인프라] Mac에서 Flutter & VS Code 개발 환경 구축하기 (초보자 완벽 가이드)
- ✅ [개발일지 #03 | 인프라] 유그린 NAS에 Caddy & PostgreSQL 구축하기
📂 Part 3. 백엔드(API) & 실시간 통화 서버
- ✅ [개발일지 #04 | 백엔드] 유그린 NAS에 Go(Golang) 초경량 메신저 백엔드 서버 구축하기
- ✅ [개발일지 #05 | 백엔드] 메신저 DB 스키마 설계 & Go sqlc 연동하기
- ✅ [개발일지 #06 | 백엔드] 초대코드 회원가입, Argon2id 암호화 & 다중 기기 JWT 인증 구현하기
- ⏳ [#08 | 백엔드] 연락처 HMAC 해시 친구 매칭 & 1:1 / 그룹 채팅방 생성 API (예정)
- ⏳ [#10 | 백엔드] Gorilla WebSocket 실시간 채팅 허브 & 단조 증가 순번(
seq) 동기화 (예정)
📂 Part 4. 모바일 앱 개발 (iOS / Android)
- ✅ [개발일지 #07 | 클라이언트] Flutter 앱 첫 연동: Secure Storage 토큰 관리, Dio 자동 갱신 & 로그인 화면 구현
- ⏳ [#09 | 클라이언트] 친구 목록 동기화 & 채팅방 목록 UI 연동하기 (예정)
- ⏳ [#11 | 클라이언트] 실시간 WebSocket 채팅방 화면 & 끊김 없는 대화 동기화 구현 (예정)
- ⏳ [#12 | 앱-공통] 300MB 대용량 원본 사진/영상 전송 및 미디어 처리 (예정)
- ⏳ [#13 | 앱-푸시] 앱이 꺼져 있어도 울리는 푸시 알림(FCM/APNs) 및 보이스톡 연동 (예정)
📂 Part 5. 정식 배포 & 운영
- ⏳ [#14 | 배포] 백엔드 서버 도커 이미지 빌드 및 유그린 NAS 정식 배포 (예정)
- ⏳ [#15 | 운영] DB 자동 백업 스크립트 구성 및 가족 실사용 후기 (예정)