
직접 서버를 만들어보자: 홈서버 구축기
AWS와 Oracle Cloud에 서비스를 올리던 백엔드 개발자가 직접 서버를 설치하고, 네트워크를 연결하고, Docker로 서비스를 배포하면서 겪은 홈서버 구축 기록입니다.
목차
- 배경
- 목적
- 서버란?
- 홈서버란?
- 집에 서버를 설치하면 바로 인터넷에서 접속할 수 있을까?
- IP 주소란?
- 외부망과 내부망
- 공인 IP와 사설 IP
- NAT와 포트 포워딩
- 포트란?
- 왜 Docker를 사용할까?
- 지금까지의 전체 흐름
- 하드웨어 및 네트워크 구성
- Ubuntu Server 설치
- SSH 및 기본 보안 설정
- Docker 환경 구축
- 외부 접속 문제 발생
- 원인 분석
- 시도했던 방법과 실패 원인
- 최종 해결 방법
- 최종 홈서버 아키텍처
- 결과
- 무엇을 배웠는가?
- 아쉬웠던 점
- 앞으로 할 것
시작하기 전에
1. 배경
기존에는 개발한 서비스를 배포하기 위해 AWS와 Oracle Cloud 같은 외부 클라우드 인프라를 사용해왔다.
클라우드는 필요한 서버를 빠르게 생성하고 서비스를 배포할 수 있다는 장점이 있다.
하지만 이미 구축된 인프라 위에서 서버를 사용하는 방식이다 보니 물리적인 서버부터 운영체제, 네트워크, 보안, 외부 통신까지 서버 인프라가 실제로 어떻게 구성되고 동작하는지 직접 경험하기에는 한계가 있었다.
백엔드 개발자로서 애플리케이션을 개발하고 클라우드 인스턴스에 배포하는 것에서 그치지 않고,
직접 통제할 수 있는 서버를 처음부터 구축하고 운영해보고 싶었다.
그래서 Lenovo ThinkCentre M910q Tiny를 마련하고 Ubuntu Server를 직접 설치하여 홈서버 구축을 시작했다.
2. 목적
단순히 AWS나 Oracle Cloud를 홈서버로 대체하는 것이 목적은 아니었다.
직접 서버를 구축하면서 서비스 배포에 필요한 인프라의 전체 흐름을 직접 경험하고 이해하는 것을 목표로 했다.
- Linux 서버 운영
- SSH를 이용한 원격 접속
- 네트워크의 동작 원리
- IPv4 / IPv6
- 방화벽과 서버 보안
- Docker와 Container
- DNS와 Domain
- 외부 인터넷에 서비스 공개
- 서비스 배포 및 운영
- 장애 발생 시 원인 분석과 대응
그리고 앞으로 개발하는 서비스를 직접 배포하면서,
개발
↓
배포
↓
운영
↓
장애 대응
까지 직접 경험할 수 있는 개인 인프라를 구축하고자 했다.
홈서버를 이해해보자
본격적인 구축 과정에 들어가기 전에 홈서버가 무엇이고, 인터넷에서 어떻게 접근할 수 있는지부터 이해할 필요가 있다.
3. 서버란?
우리가 인터넷에서 사용하는 대부분의 서비스 뒤에는 서버(Server)가 있다.
예를 들어 웹사이트에 접속하면 내 컴퓨터가 해당 웹사이트의 서버에 데이터를 요청한다.
서버는 요청을 받아 필요한 데이터를 처리하고 다시 사용자에게 전달한다.
내 컴퓨터
│
│ "웹페이지를 보여줘"
↓
서버
│
│ HTML, 이미지, 데이터
↓
내 컴퓨터
즉 아주 단순하게 생각하면 서버는,
다른 컴퓨터의 요청을 받아 필요한 작업을 처리하고 결과를 돌려주는 컴퓨터
라고 볼 수 있다.
4. 홈서버란?
보통 개발자는 서버용 컴퓨터를 직접 구매하고 데이터센터에 설치하지 않는다.
AWS나 Oracle Cloud 같은 클라우드 업체가 운영하는 컴퓨터의 일부를 빌려 사용한다.
내 컴퓨터
│
│ Internet
↓
AWS / Oracle Cloud
│
↓
내가 빌린 서버
반면 홈서버(Home Server)는 말 그대로 서버로 사용할 컴퓨터를 집에 직접 설치하고 운영하는 방식이다.
내 컴퓨터
│
↓
집의 네트워크
│
↓
내가 직접 설치한 컴퓨터
│
↓
홈서버
홈서버라고 해서 특별한 종류의 컴퓨터가 반드시 필요한 것은 아니다.
일반 데스크톱이나 미니 PC에도 Linux 등의 운영체제를 설치하고 네트워크에 연결한 뒤 필요한 프로그램을 실행하면 서버로 사용할 수 있다.
이번에는 다음 장비와 운영체제를 사용했다.
Hardware
└─ Lenovo ThinkCentre M910q Tiny
└─ Intel Core i5-7500T
Operating System
└─ Ubuntu Server 24.04 LTS
GUI가 포함된 일반 Ubuntu Desktop이 아니라 CLI 기반 Ubuntu Server를 설치했다.
인터넷에서 홈서버까지
5. 집에 서버를 설치하면 바로 인터넷에서 접속할 수 있을까?
집에 홈서버를 설치했다고 해서 다른 사람이 인터넷을 통해 바로 접속할 수 있는 것은 아니다.
예를 들어 친구가 자신의 컴퓨터에서 내 홈서버에 배포된 웹사이트에 접속한다고 생각해보자.
가장 단순하게 표현하면 다음과 같은 경로를 거쳐야 한다.
친구의 컴퓨터
│
│ Internet
↓
우리 집 인터넷
│
↓
우리 집 네트워크
│
↓
홈서버
│
↓
실행 중인 웹 서비스
여기서 한 가지 의문이 생긴다.
인터넷에 연결된 수많은 컴퓨터 중에서 어떻게 우리 집을 찾을까?
그리고 우리 집까지 찾았다고 해도,
컴퓨터, 스마트폰, TV 등 여러 장치 중에서 어떻게 홈서버를 찾을까?
이를 이해하기 위해서는 먼저 IP 주소를 알아야 한다.
6. IP 주소란?
IP Address(IP 주소)는 네트워크에 연결된 장치를 구분하기 위해 사용하는 주소다.
택배를 보내기 위해 목적지의 주소가 필요한 것처럼 네트워크에서도 데이터를 어디로 보내야 하는지 알아야 한다.
예를 들어 다음과 같은 형태의 주소를 본 적이 있을 것이다.
192.168.0.10
그런데 일반적인 가정에는 인터넷을 사용하는 장치가 하나만 존재하지 않는다.
┌── 내 컴퓨터
│
Internet ── 공유기 ──┼── 스마트폰
│
├── TV
│
└── 홈서버
여러 장치가 하나의 공유기를 통해 인터넷에 연결되어 있다.
그래서 네트워크를 이해할 때 외부망과 내부망을 구분해서 생각하는 것이 중요하다.
7. 외부망과 내부망
집을 기준으로 생각하면 네트워크를 크게 두 영역으로 나누어 이해할 수 있다.
외부망 (External Network)
우리 집 바깥의 인터넷 영역이다.
다른 사람의 컴퓨터나 스마트폰에서 우리 집으로 접근하려면 외부 인터넷을 거쳐야 한다.
내부망 (Internal Network / LAN)
공유기 안쪽에 구성되어 있는 우리 집의 네트워크다.
[ 외부망 ]
Internet
│
↓
┌─────────────┐
│ 우리 집 Network │
└──────┬──────┘
════════════ 외부망 / 내부망 경계 ════════════
│
[ 내부망 ]
│
┌────────────┼────────────┐
│ │ │
↓ ↓ ↓
내 PC 스마트폰 홈서버
집 안의 컴퓨터와 홈서버가 같은 내부망에 있다면 서로 직접 통신할 수 있다.
하지만 인터넷 바깥에 있는 사용자가 내부망에 있는 홈서버를 바로 찾을 수 있는 것은 아니다.
8. 공인 IP와 사설 IP
여기서 IP 주소도 크게 두 종류로 나누어 생각할 수 있다.
공인 IP (Public IP)
인터넷에서 특정 네트워크를 찾기 위해 사용하는 주소다.
사설 IP (Private IP)
집이나 회사 같은 내부 네트워크에서 각각의 장치를 구분하기 위해 사용하는 주소다.
일반적인 가정용 IPv4 네트워크를 단순화한 예시는 다음과 같다.
Internet
│
│
[ 공인 IP ]
│
↓
┌────────┐
│ 공유기 │
└───┬────┘
│
내부망
│
┌───────────────┼───────────────┐
│ │ │
↓ ↓ ↓
192.168.0.2 192.168.0.3 192.168.0.10
내 PC 스마트폰 홈서버
└────────── 사설 IP ──────────┘
따라서 집 안에 있는 내 컴퓨터에서는 홈서버의 사설 IP를 이용하여 접근할 수 있다.
내 PC
│
↓
192.168.0.10
│
↓
홈서버
하지만 외부에 있는 친구가 192.168.0.10에 접속한다고 해서 내 홈서버에 연결되는 것은 아니다.
192.168.x.x와 같은 사설 IP는 해당 내부망 안에서 사용하기 위한 주소이기 때문이다.
따라서 외부 사용자의 요청을 내부의 홈서버까지 전달하는 과정이 필요하다.
9. NAT와 포트 포워딩
일반적인 IPv4 가정용 네트워크에서는 공유기가 외부망과 내부망 사이에서 중요한 역할을 한다.
이 과정에서 등장하는 개념 중 하나가 NAT(Network Address Translation, 네트워크 주소 변환)이다.
NAT를 아주 단순하게 표현하면,
외부에서 사용하는 주소와 내부에서 사용하는 주소 사이의 통신을 중간에서 변환해주는 것
이라고 이해할 수 있다.
그런데 외부에서 요청이 들어왔을 때는 또 하나의 문제가 생긴다.
"이 요청을 집 안의 어떤 기기로 전달해야 하지?"
이때 사용할 수 있는 방법 중 하나가 Port Forwarding(포트 포워딩)이다.
예를 들어 특정 포트로 들어오는 요청을 홈서버로 전달하도록 규칙을 설정할 수 있다.
Internet
│
│ 특정 Port로 요청
↓
┌─────────────┐
│ 공유기 │
│ │
│ Port │
│ Forwarding │
└──────┬──────┘
│
│ "이 요청은 홈서버로"
↓
홈서버
이렇게 외부에서 들어온 요청을 내부망에 있는 특정 장치까지 전달할 수 있다.
주의
여기까지의 설명은 네트워크의 원리를 이해하기 위한 일반적인 IPv4 가정용 네트워크의 예시다.
실제 이번 홈서버의 외부 접근 구성은 뒤에서 설명할 Cloudflare Tunnel을 사용했으며, 구축 과정에서는 IPv4/IPv6와 실제 가정 네트워크 환경의 차이 때문에 예상하지 못한 문제도 경험했다.
홈서버 내부에서는 무슨 일이 일어날까?
10. 포트란?
외부의 요청이 홈서버까지 도착했다고 해서 끝나는 것은 아니다.
하나의 홈서버에서는 여러 프로그램이 동시에 실행될 수 있다.
홈서버
┌─────────────────────────────────────┐
│ │
│ Ubuntu Server │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Web App │ │ Backend │ │
│ │ :3000 │ │ :8080 │ │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ DB │ │ Redis │ │
│ │ :5432 │ │ :6379 │ │
│ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────┘
여기서 3000, 8080, 5432, 6379와 같은 숫자가 Port(포트)다.
쉽게 비유하면,
IP 주소 = 건물의 주소
Port = 그 건물 안의 방 번호
라고 생각할 수 있다.
IP Address
└─ 어떤 컴퓨터인가?
Port
└─ 그 컴퓨터 안의 어떤 프로그램인가?
예를 들어 같은 홈서버에서도 8080 포트에서는 백엔드 서버가 실행되고, 5432 포트에서는 데이터베이스가 실행될 수 있다.
11. 왜 Docker를 사용할까?
프로젝트마다 필요한 실행 환경은 다르다.
예를 들어 어떤 프로젝트는 Java와 Spring Boot를 사용하고, 다른 프로젝트는 Node.js와 Next.js를 사용할 수 있다.
또 PostgreSQL이나 MySQL, Redis가 필요한 프로젝트도 있을 수 있다.
이 프로그램들을 하나의 운영체제 위에 직접 설치하고 관리하다 보면 프로젝트마다 필요한 버전이나 설정이 서로 충돌할 수 있다.
그래서 이번 홈서버에서는 Docker를 사용했다.
Docker를 사용하면 프로그램을 Container(컨테이너)라는 서로 분리된 환경에서 실행할 수 있다.
홈서버
┌──────────────────────────────────────────┐
│ Ubuntu Server │
│ │
│ Docker │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Container A│ │ Container B│ │
│ │ │ │ │ │
│ │ Spring Boot│ │ Next.js │ │
│ │ :8080 │ │ :3000 │ │
│ └────────────┘ └────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Container C│ │ Container D│ │
│ │ PostgreSQL │ │ Redis │ │
│ │ :5432 │ │ :6379 │ │
│ └────────────┘ └────────────┘ │
│ │
└──────────────────────────────────────────┘
위의 서비스와 포트는 구조를 설명하기 위한 예시다.
핵심은 하나의 물리적인 홈서버 안에서도 서로 다른 프로젝트와 프로그램을 독립적인 환경으로 나누어 실행할 수 있다는 것이다.
Docker Compose
하나의 서비스를 운영하다 보면 Container 하나만 필요한 경우보다 여러 Container가 함께 필요한 경우가 많다.
예를 들어 하나의 프로젝트가 다음과 같이 구성될 수 있다.
Project
├── Spring Boot
├── MySQL
└── Redis
Docker Compose를 사용하면 이렇게 서로 연관된 여러 Container의 실행 방법과 연결 관계를 하나의 설정으로 관리할 수 있다.
12. 지금까지의 전체 흐름
여기까지의 내용을 하나로 연결하면 우리가 만들고자 하는 홈서버의 기본적인 흐름은 다음과 같다.
사용자
│
↓
Domain / DNS
│
↓
Internet
│
↓
외부망
│
↓
우리 집 네트워크
│
↓
내부망
│
↓
홈서버
│
↓
Ubuntu Server
│
↓
Docker
│
↓
Container
│
↓
내가 만든 서비스
외부 사용자가 보낸 요청이 인터넷을 통해 집의 네트워크까지 도착하고, 내부의 홈서버까지 전달된다.
그리고 홈서버에서는 Ubuntu Server 위에서 Docker가 실행되고 있으며 Docker 내부의 각 Container에서 실제 애플리케이션이 실행된다.
이론적으로는 이제 이 구조를 실제로 구축하기만 하면 될 것처럼 보였다.
하지만 실제로 만들어보니 그렇게 간단하지 않았다.
실제 구축
13. 하드웨어 및 네트워크 구성
홈서버로 사용할 장비는 Lenovo ThinkCentre M910q Tiny로 정했다.
Lenovo ThinkCentre M910q Tiny
CPU
└── Intel Core i5-7500T
OS
└── Ubuntu Server 24.04 LTS
운영 방식
├── CLI
├── Docker
└── Docker Compose
처음부터 기존 프로젝트를 전부 홈서버로 옮길 생각은 아니었다.
우선 홈서버 자체를 구축하고 테스트한 뒤, 앞으로 만드는 프로젝트들을 하나씩 이 서버에 배포하는 방향으로 결정했다.
이후 실제 서비스를 배포할 때는 개발 PC와 홈서버가 같은 내부망에 있는 상태에서 SSH로 관리했다.
즉 관리와 파일 전송은 내부망을 이용하고, 웹 서비스에 대한 외부 요청은 뒤에서 설명할 Tunnel과 Domain을 통해 받는 구조다.
[관리]
개발 PC
│
│ 내부망
↓
홈서버
[서비스 이용]
외부 사용자
│
│ Internet
↓
Domain
│
↓
Tunnel
│
↓
홈서버
14. Ubuntu Server 설치
홈서버에는 Ubuntu Server 24.04 LTS를 설치했다.
일반적인 데스크톱 환경처럼 GUI를 사용하는 것이 아니라 CLI 환경에서 서버를 관리하는 방식이다.
이후 실제 서비스를 배포하기 전에 서버의 현재 상태부터 확인했다.
hostname
hostname -I
ip -4 addr show
docker --version
docker compose version
docker ps -a
docker compose ls
여기서 중요한 점은 새로운 설정부터 시작하지 않고,
현재 서버에 무엇이 설치되어 있고 무엇이 실행되고 있는지를 먼저 확인하는 것
이었다.
실제로 확인해보니 Docker와 테스트 Container는 이미 존재했지만 이후 배포할 애플리케이션과 DB는 아직 없는 상태였다.
15. SSH 및 기본 보안 설정
서버는 모니터와 키보드를 계속 연결해두고 사용하는 컴퓨터가 아니다.
따라서 다른 컴퓨터에서 서버에 접속하여 관리할 방법이 필요하다.
여기서 사용한 것이 SSH(Secure Shell)다.
개발 PC
│
│ SSH
↓
홈서버
│
↓
Ubuntu CLI
같은 내부망의 개발 PC에서 SSH를 통해 홈서버에 접속하면 홈서버 앞에 직접 앉아 있지 않아도 명령을 실행할 수 있다.
파일을 전송할 때는 SCP를 사용할 수 있다.
개발 PC
│
│ SCP
↓
홈서버
구축 과정에서 흥미로운 문제도 있었다.
별도의 진단 환경에서는 SSH나 포트 검사에 실패해 처음에는 서버가 꺼졌거나 포트가 닫힌 것처럼 보였다.
하지만 실제로 사용하고 있던 SSH 세션은 정상적으로 연결되어 있었다.
즉,
진단 도구에서 접근할 수 없다는 사실과 서버 자체가 동작하지 않는다는 사실은 같지 않았다.
어떤 위치에서 서버를 확인하고 있는지도 네트워크 문제를 판단할 때 중요하다는 것을 알게 됐다.
16. Docker 환경 구축
서버에는 Docker와 Docker Compose를 설치하여 서비스를 Container 단위로 운영하도록 구성했다.
실제 재점검 당시에는 다음과 같은 Container들이 존재했다.
| Container | 역할 |
|---|---|
traefik |
내부 서비스 라우팅 |
project1 |
whoami 테스트 서비스 |
cloudflared |
Cloudflare Tunnel 연결 |
| Application | 이후 배포한 실제 애플리케이션 |
| MySQL | 애플리케이션 DB |
구조를 단순화하면 다음과 같다.
Ubuntu Server
│
↓
Docker
│
├── cloudflared
│
├── Traefik
│
├── Project Container
│
├── Application
│
└── MySQL
프로젝트별 설정은 Docker Compose를 이용해 관리했다.
~/docker/
├── traefik/
│ └── compose.yaml
│
├── project1/
│ └── compose.yaml
│
└── todayletter/
└── compose.yaml
이 구조를 통해 앞으로 새로운 프로젝트를 추가할 때도 서버 전체의 실행 환경을 직접 변경하기보다 Container 단위로 추가할 수 있게 되었다.
예상하지 못했던 네트워크 문제
17. 외부 접속 문제 발생
이제 홈서버 내부에서 서비스가 실행되고 있으니 외부에서 접근할 수 있도록 연결해야 했다.
그 과정에서 Cloudflare Tunnel을 사용했다.
그런데 Tunnel 자체는 연결되었음에도 내부 서비스에 접근하지 못하는 문제가 발생했다.
Registered tunnel connection ... protocol=quic
Unable to reach the origin service
dial tcp <내부 IP>:8080: i/o timeout
즉 Cloudflare와 Tunnel 사이의 연결은 만들어졌지만 그 뒤에 있는 실제 서비스까지 요청이 전달되지 않았다.
목적지를 Container 이름으로 변경해보자 또 다른 문제가 발생했다.
lookup nginx-test on 127.0.0.11:53: no such host
이번에는 timeout이 아니라 이름 자체를 찾지 못했다.
이때부터 단순히,
"외부 접속이 안 된다."
라고 보는 것이 아니라,
"요청이 정확히 어디까지 도착하고 있는가?"
를 확인하기 시작했다.
18. 원인 분석
외부 사용자가 서비스에 접속하는 과정을 다시 나누어봤다.
사용자
│
↓
Domain
│
↓
Cloudflare
│
↓
Tunnel
│
↓
cloudflared
│
↓
Traefik
│
↓
Container
│
↓
Application
이렇게 나누고 보니 서비스 접속 실패라는 하나의 문제가 여러 개의 서로 다른 문제일 수 있었다.
1. 내부 목적지 접속 실패
Tunnel 연결은 등록되었지만 지정한 내부 IP의 8080 포트로 연결하지 못했다.
Cloudflare
↓
Tunnel
↓
cloudflared
↓
X
내부 IP:8080
이 시점의 기록만으로 앱 미실행, 주소 오류, 방화벽 등 어느 하나를 최종 원인으로 확정할 수는 없었다.
중요했던 것은 Cloudflare까지의 연결과 내부 서비스까지의 연결은 서로 다른 단계라는 점이었다.
2. Container 이름 조회 실패
다음에는 목적지를 다음과 같이 변경했다.
http://nginx-test:80
하지만 이번에는,
no such host
오류가 발생했다.
Docker에서 Container 이름을 이용해 통신하려면 해당 Container가 존재하는 것뿐만 아니라 서로 통신 가능한 Docker Network에 연결되어 있어야 한다.
즉 Container 이름도 인터넷 전체에서 사용할 수 있는 주소가 아니라 Docker Network라는 특정 영역 안에서 의미를 가지는 이름이었다.
3. 종료된 Tunnel
이후 서버를 다시 점검했을 때 Traefik과 테스트 서비스는 실행 중이었지만 cloudflared Container는 종료되어 있었다.
Traefik Running
Project Running
cloudflared Exited
Cloudflare 쪽 설정이 존재한다고 해도 실제 Tunnel 역할을 수행하는 프로그램이 서버에서 실행되고 있지 않다면 요청을 전달할 수 없다.
4. IPv4와 IPv6
구축 과정에서 특히 어려웠던 부분 중 하나는 IPv4와 IPv6, 그리고 실제 집의 네트워크 구조였다.
학교에서 배운 일반적인 IPv4 기반 네트워크 구조만 생각하고 접근했지만 실제 가정의 네트워크 환경에서는 외부와 내부에서 바라보는 주소와 연결 방식이 예상과 달랐다.
이 과정에서,
- 외부망과 내부망의 차이
- IPv4와 IPv6
- 집의 네트워크 주소
- 외부에서 내부 서버로 접근하는 방법
을 다시 공부하게 되었다.
다만 당시 IPv4/IPv6 설정을 변경했던 모든 명령과 출력까지 기록해두지는 못했다.
그래서 지금 확인할 수 없는 세부 설정을 결과에 맞춰 임의로 복원하지는 않았다.
이것 역시 이번 구축에서 기록의 중요성을 느끼게 된 이유가 되었다.
19. 시도했던 방법과 실패 원인
Cloudflare Tunnel 연결
처음 Tunnel 자체의 연결은 성공했다.
Cloudflare
↓
Cloudflare Tunnel
↓
cloudflared
연결 성공
하지만 내부 서비스의 8080으로 연결하려고 하자 timeout이 발생했다.
cloudflared
↓
내부 IP:8080
↓
TIMEOUT
Container 이름으로 연결
이후 내부 IP 대신 Container 이름을 사용해보았다.
cloudflared
↓
nginx-test:80
그러나 이번에는 Docker에서 해당 이름을 찾지 못했다.
no such host
같은 서버 안에 있다고 해서 모든 Container가 자동으로 서로를 찾을 수 있는 것은 아니었다.
Traefik을 통한 라우팅
이후 외부 요청을 직접 Application에 전달하는 대신 Traefik을 거치도록 구성했다.
project1.khoon.net
│
↓
Cloudflare Tunnel
│
↓
cloudflared
│
↓
Traefik
│
↓
project1
Cloudflare 쪽에서는,
project1.khoon.net
↓
http://traefik:80
으로 전달하고,
Traefik에서는 요청된 Host를 확인하여 적절한 Container로 전달하는 구조다.
MySQL 계정 문제
애플리케이션을 배포하면서는 네트워크 외에도 DB 문제가 발생했다.
Access denied for user
Compose의 DB 사용자와 실제 접속 계정을 다시 확인하고 애플리케이션용 계정을 사용하도록 정리했다.
docker exec -it todayletter-mysql \
mysql -utodayletter_app -p todayletter
이후 DB 선택까지 정상적으로 이루어지는 것을 확인했다.
Docker 내부 주소를 로컬에서 사용한 문제
서버에서 사용할 DB 주소를 로컬 IntelliJ에서도 그대로 사용했다가 다음과 같은 오류가 발생했다.
UnknownHostException: todayletter-mysql
todayletter-mysql이라는 이름은 Docker Network 안에서 Container들이 서로를 찾기 위해 사용하는 이름이었다.
따라서,
Docker 내부
Application
│
↓
todayletter-mysql
은 가능하지만,
Windows 개발 PC
│
↓
todayletter-mysql
X 찾을 수 없음
이었다.
이 경험을 통해 같은 서비스라도 어디에서 실행하느냐에 따라 접근 주소가 달라진다는 것을 알게 되었다.
JAR와 Dockerfile 경로 문제
로컬에서 생성한 JAR 파일을 다음 위치로 전송했다.
~/docker/todayletter/app.jar
하지만 처음 Dockerfile은 다음과 같이 작성되어 있었다.
COPY build/libs/*.jar app.jar
서버에는 build/libs 디렉터리가 없었기 때문에 빌드가 실패했다.
결국 서버의 실제 파일 구조에 맞게 수정했다.
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
단순한 문제였지만,
Dockerfile에 작성된 경로 역시 Docker Build가 실행되는 환경을 기준으로 생각해야 한다.
는 것을 다시 확인했다.
Compose 들여쓰기 오류
Compose 파일을 수정하는 과정에서는 app 서비스가 잘못된 위치에 들어가는 문제도 있었다.
구조는 다음과 같아야 했다.
services
├── mysql
└── app
volumes
└── mysql-data
networks
└── proxy
Compose는 YAML을 사용하기 때문에 들여쓰기 자체가 구조를 결정한다.
이후 다음 명령으로 설정 파일을 검증했다.
docker compose config
재배포
코드를 수정하고 새로운 JAR를 서버로 전송한 뒤에는 Application 이미지를 다시 빌드하고 Container를 재생성해야 했다.
cd ~/docker/todayletter
docker build --no-cache -t todayletter:latest .
docker compose rm -sf app
docker compose up -d app
docker logs todayletter-app --tail=100
이 과정은 아직 자동화하지 않았기 때문에 새로운 버전을 배포할 때마다 직접 수행해야 했다.
20. 최종 해결 방법
최종적으로 외부 웹 요청은 Cloudflare Tunnel과 Docker 내부의 Traefik을 연결하는 방식으로 구성했다.
project1.khoon.net
│
↓
Cloudflare
│
↓
Cloudflare Tunnel
│
↓
cloudflared
│
↓
Traefik
│
↓
Application
Cloudflare Tunnel에서는 요청을 Traefik으로 전달한다.
project1.khoon.net
↓
http://traefik:80
Traefik은 전달받은 요청의 Host를 보고 어떤 서비스로 보낼지 결정한다.
예를 들어 애플리케이션에는 다음과 같은 Host 기반 라우팅 규칙을 설정할 수 있다.
labels:
- "traefik.enable=true"
- "traefik.http.routers.todayletter.rule=Host(`project1.khoon.net`)"
- "traefik.http.routers.todayletter.entrypoints=web"
- "traefik.http.services.todayletter.loadbalancer.server.port=8080"
이 구조에서 재미있었던 부분은 하나의 도메인을 여러 서비스의 주소로 확장할 수 있다는 점이었다.
예를 들어 khoon.net이라는 도메인이 있다면,
khoon.net
│
├── project1.khoon.net
├── project2.khoon.net
├── api.khoon.net
├── blog.khoon.net
└── monitor.khoon.net
처럼 여러 Subdomain(서브도메인)을 만들 수 있다.
그리고 각 요청을 서로 다른 Container로 연결할 수 있다.
project1.khoon.net ──────→ Project 1
project2.khoon.net ──────→ Project 2
api.khoon.net ──────→ API Server
물리적인 서버는 한 대지만 외부에서는 여러 개의 독립적인 서비스가 존재하는 것처럼 사용할 수 있는 것이다.
처음에는 단순히 도메인을 서버 하나에 연결하는 정도로 생각했기 때문에 이 구조를 직접 구성해본 것이 특히 신기했다.
최종 구성
21. 최종 홈서버 아키텍처
지금까지 설명한 내용을 모두 합치면 이번에 구축한 홈서버는 다음과 같은 구조로 볼 수 있다.
[ 외부망 ]
사용자
│
HTTPS
│
↓
project1.khoon.net
│
↓
Cloudflare
│
↓
Cloudflare Tunnel
│
↓
════════════════════════ 외부망 / 내부망 ════════════════════════
│
↓
우리 집 네트워크
│
↓
Lenovo M910Q 홈서버
│
↓
Ubuntu Server 24.04
│
↓
Docker
│
┌──────────────────┼───────────────────┐
│ │ │
↓ ↓ ↓
cloudflared Traefik Application
│ │ │
└─────────────────→│ │
│ │
Host Routing │
│ │
└──────────────────→│
│
↓
MySQL
│
↓
mysql-data Volume
[ 관리 / 배포 ]
개발 PC
│
│ 내부망
↓
SSH / SCP
│
↓
홈서버
│
app.jar 전송
│
↓
Docker Build
│
↓
Container 재생성
이 그림을 처음 봤다면 상당히 복잡해 보였을 것이다.
하지만 앞에서부터 하나씩 살펴보면 결국 다음 질문들이 연결된 결과다.
사용자는 어떻게 우리 집을 찾을까?
↓
Domain / DNS
외부 요청은 어떻게 집까지 올까?
↓
Internet / Tunnel
홈서버에서는 누가 요청을 받을까?
↓
cloudflared
여러 서비스 중 어디로 보낼까?
↓
Traefik / Host Routing
실제 프로그램은 어디서 실행될까?
↓
Docker Container
데이터는 어디에 저장할까?
↓
Database / Volume
22. 결과
1차 구축을 통해 다음 단계까지 확인했다.
| 항목 | 결과 |
|---|---|
| Ubuntu Server | 설치 및 운영 |
| 내부망 관리 | SSH 접속 |
| 파일 전송 | SCP 기반 수동 전송 |
| Docker | Container 실행 환경 구성 |
| Docker Compose | 서비스 구성 관리 |
| Cloudflare Tunnel | 외부 요청 전달 |
| Traefik | Host 기반 내부 라우팅 |
| Subdomain | 서비스별 주소 구성 가능 |
| MySQL | 애플리케이션 DB 연결 |
| Spring Boot | Container 기동 및 MySQL 연결 확인 |
| 외부 테스트 | 테스트 서비스의 HTTPS 응답 확인 |
| 배포 방식 | 수동 배포 |
다만 모든 것이 완성된 것은 아니다.
특히 자동 배포, 모니터링, 백업은 아직 남아 있다.
그래서 이번 상태를 최종 완성이라고 부르기보다,
홈서버 1차 구축 완료
라고 정리하려고 한다.
회고
23. 무엇을 배웠는가?
이번 홈서버 구축에서 가장 신기했던 부분은 네트워크의 흐름을 처음부터 끝까지 직접 따라가 본 것이었다.
기존에는 AWS나 Oracle Cloud에 애플리케이션을 배포하면서도 사용자가 입력한 도메인이 어떻게 서버까지 찾아오고, 서버에 도착한 요청이 다시 어떤 과정을 거쳐 내가 만든 애플리케이션까지 전달되는지 깊게 생각해본 적이 없었다.
홈서버를 직접 구축하면서 처음으로 하나의 요청을 다음과 같이 바라보게 되었다.
사용자
↓
Domain
↓
DNS
↓
Internet
↓
외부망
↓
우리 집 네트워크
↓
내부망
↓
홈서버
↓
Docker Network
↓
Container
↓
Application
각 단계가 모두 연결되어야 비로소 사용자가 하나의 서비스에 접속할 수 있다는 점이 인상적이었다.
특히 구축 과정에서 문제가 발생하면서 이 구조를 더 깊게 이해하게 되었다.
Tunnel 연결 자체는 성공했는데 내부 서비스에 접근하지 못하기도 했고, Docker 내부에서는 Container 이름을 찾지 못하기도 했다.
로컬 컴퓨터에서는 Docker 내부에서 사용하는 DB 주소를 그대로 사용할 수 없었다.
이런 문제들을 하나씩 해결하면서 '서버가 안 된다'는 하나의 문제가 아니라 네트워크의 어느 구간에서 연결이 끊어졌는지를 찾아야 한다는 것을 배웠다.
DNS 문제인가?
↓
외부에서 집까지 들어오지 못하는가?
↓
Tunnel 문제인가?
↓
Docker Network 문제인가?
↓
Routing 문제인가?
↓
Container가 실행되고 있는가?
↓
Application 문제인가?
↓
DB까지 연결되는가?
또 하나 새롭게 알게 된 것은 도메인 하나를 여러 서비스의 진입점으로 활용할 수 있다는 것이었다.
처음에는 도메인 하나를 구입하면 하나의 서비스에 연결해서 사용하는 정도로 생각했다.
하지만 하나의 도메인 아래에 여러 Subdomain을 만들어 각각 다른 서비스의 주소로 사용할 수 있었다.
khoon.net
│
├── project1.khoon.net
├── project2.khoon.net
├── api.khoon.net
├── blog.khoon.net
└── monitor.khoon.net
그리고 서버에서는 요청된 Host를 기준으로 각각 다른 서비스로 요청을 전달할 수 있었다.
하나의 물리 서버
┌──────────────────────────────┐
│ │
│ Home Server │
│ │
│ project1 project2 api │
│ ↑ ↑ ↑ │
│ └─────────┼───────┘ │
│ Traefik │
│ │
└──────────────────────────────┘
단순히 Ubuntu를 설치하거나 Docker 명령어 몇 개를 알게 된 것보다,
하나의 서비스가 사용자에게 도달하기까지 네트워크가 어떻게 연결되어 있는지를 이해하게 된 것
이 이번 홈서버 구축에서 가장 크게 배운 부분이었다.
이제 서비스에 접속할 수 없는 문제가 발생했을 때 단순히,
"서버가 왜 안 되지?"
라고 생각하기보다,
"요청이 어디까지 정상적으로 도달했고, 어느 계층부터 실패했지?"
라고 생각하며 문제의 범위를 하나씩 좁혀갈 수 있게 되었다.
24. 아쉬웠던 점
가장 아쉬웠던 점은 구축 과정을 충분히 기록하지 않았다는 것이다.
구축 당시에는 문제가 생기면 원인을 찾고 해결하는 데 집중하다 보니 어떤 설정을 왜 변경했는지, 어떤 명령을 실행했고 결과가 어떻게 달라졌는지를 체계적으로 남기지 못했다.
특히 네트워크 문제를 해결하면서 여러 설정을 변경하고 다시 되돌리는 과정을 반복했기 때문에 시간이 지난 뒤 전체 과정을 다시 정리하려고 하니 최종 결과는 남아 있지만 왜 그 결과에 도달했는지를 복원하기 어려운 부분이 있었다.
이번 글을 작성하면서도 당시 채팅과 로그를 다시 찾아보며 구축 과정을 복원해야 했다.
두 번째로 아쉬웠던 점은 네트워크에 대한 이해가 충분하지 않은 상태에서 구축을 시작했다는 것이다.
학교에서 네트워크를 공부하며 IP, TCP/UDP, DNS, NAT 등의 개념은 알고 있었다.
하지만 대부분 개념과 시험 문제를 중심으로 이해하고 있었기 때문에 실제 가정용 네트워크에서 외부의 요청이 내부의 서버까지 어떻게 전달되는지는 제대로 생각해본 적이 없었다.
학교에서 알고 있던 것
IP
NAT
DNS
Port
TCP / UDP
IPv4 / IPv6
↓
실제로 구축하면서 마주친 것
외부망과 내부망은 어떻게 연결되는가?
내 홈서버의 주소는 무엇인가?
외부에서는 이 서버를 어떻게 찾아오는가?
DNS로 찾은 뒤 요청은 어디로 이동하는가?
Tunnel은 어느 구간을 연결하는가?
Docker Network는 또 어디에 존재하는가?
Container 이름은 어디에서만 사용할 수 있는가?
용어를 알고 있는 것과 실제 요청 하나가 이동하는 전체 경로를 이해하는 것은 전혀 다른 문제였다.
그만큼 초반에는 많이 헤맸지만, 역설적으로 이 과정이 이번 홈서버 구축에서 네트워크를 가장 많이 공부하게 된 이유이기도 했다.
마지막으로 아쉬웠던 것은 배포 자동화까지 완성하지 못했다는 점이다.
현재는 개발 PC에서 애플리케이션을 빌드하고, 생성된 파일을 홈서버로 전송한 뒤 Docker 이미지를 다시 빌드하고 Container를 재생성하는 수동 배포 방식을 사용하고 있다.
코드 수정
↓
로컬 Build
↓
JAR 생성
↓
SSH / SCP로 홈서버 전송
↓
Docker Image Build
↓
Container 재생성
↓
배포 확인
홈서버를 구축하면서 최종적으로는 코드가 변경되면 자동으로 테스트와 빌드가 진행되고 홈서버까지 배포되는 CI/CD 환경까지 구성하고 싶었다.
하지만 이번 1차 구축에서는 우선 서버와 네트워크를 구성하고 실제 서비스를 배포할 수 있는 상태를 만드는 데 집중하면서 자동 배포까지 적용하지 못했다.
그래서 홈서버 자체는 사용할 수 있는 상태가 되었지만, 개발부터 배포까지의 전체 과정을 자동화하지 못하고 수동 배포 단계에서 마무리한 것은 아쉬움으로 남았다.
다만 이 세 가지 아쉬움은 그대로 다음 단계의 목표가 되었다.
다음에는 구축 과정을 처음부터 기록하고, 네트워크 구조를 먼저 설계한 뒤, CI/CD까지 연결된 운영 환경을 만들어보고 싶다.
25. 앞으로 할 것
이번 구축을 통해 홈서버에 서비스를 올리고 외부에서 접근할 수 있는 기본적인 환경까지 만들었다.
하지만 홈서버를 한 번 구축했다고 해서 끝난 것은 아니다.
오히려 직접 서버를 운영해보니 배포 이후부터가 진짜 운영의 시작이라는 생각이 들었다.
1. 배포 자동화
가장 먼저 해보고 싶은 것은 배포 자동화다.
현재는,
코드 수정
↓
로컬 Build
↓
서버로 파일 전송
↓
Docker Image Build
↓
Container 재생성
↓
배포 확인
과정을 직접 수행하고 있다.
앞으로는 이 과정을 CI/CD로 연결해보고 싶다.
코드 수정
↓
Git Push
↓
Test
↓
Build
↓
Deploy
↓
홈서버 Container 업데이트
어떤 방식으로 자동화할지는 실제 다음 프로젝트를 배포하면서 결정할 예정이다.
2. 모니터링
서비스가 실행되고 있다는 것만으로는 안정적으로 운영하고 있다고 하기 어렵다.
앞으로는,
- CPU 사용량
- Memory 사용량
- Disk 사용량
- Docker Container 상태
- Application 상태
- 장애 발생 여부
등을 확인할 수 있는 환경도 구성해보고 싶다.
3. Logging
장애가 발생했을 때 매번 Container에 접속해서 로그를 직접 찾는 방식보다 여러 서비스의 로그를 확인할 수 있는 구조도 만들어보고 싶다.
Application A ──┐
Application B ──┼──→ Logging
Application C ──┘
4. Backup
DB와 서비스 데이터의 백업도 필요하다.
홈서버는 결국 내가 직접 관리하는 물리적인 장비다.
따라서 장비나 저장장치에 문제가 생겼을 때 데이터를 복구할 수 있도록,
Database
│
↓
Backup
│
↓
Restore
과정도 직접 구성해볼 예정이다.
5. 앞으로 만드는 프로젝트를 직접 운영하기
그리고 앞으로 새롭게 만드는 프로젝트들은 가능한 경우 이 홈서버에 직접 배포하면서 운영 경험을 계속 쌓아보려고 한다.
프로젝트가 하나씩 추가되면 새로운 문제도 자연스럽게 생길 것이다.
Home Server
│
├── project1.khoon.net
├── project2.khoon.net
├── project3.khoon.net
│
└── ...
서비스마다 Domain을 어떻게 나눌지,
Container를 어떻게 관리할지,
Database를 어떻게 구성할지,
장애가 발생했을 때 어떻게 발견하고 복구할지도 직접 고민해야 한다.
그 과정 역시 이번처럼 기록으로 남길 생각이다.
마치며
처음 홈서버를 만들기 시작했을 때는 Ubuntu를 설치하고 Docker를 띄운 뒤 내가 만든 서비스를 배포하면 끝날 것이라고 생각했다.
실제로 해보니 그 사이에는 생각보다 훨씬 많은 것들이 존재했다.
사용자
↓
Domain / DNS
↓
Internet
↓
외부망
↓
우리 집 네트워크
↓
내부망
↓
홈서버
↓
Ubuntu Server
↓
Docker
↓
Container
↓
Application
↓
Database
특히 가장 인상적이었던 것은 네트워크였다.
학교에서 IP, DNS, NAT, Port 같은 개념을 공부했지만 각각의 개념을 알고 있는 것과 실제 서비스를 구축하면서 이들이 어떻게 연결되는지 이해하는 것은 전혀 다른 경험이었다.
도메인 하나에서 여러 Subdomain을 만들어 여러 서비스의 주소로 활용하고, 외부에서 들어온 요청이 Tunnel과 서버 내부의 Routing을 거쳐 특정 Container까지 전달되는 과정도 이번에 처음 직접 구성해보았다.
무엇보다 문제가 발생했을 때,
"서버가 왜 안 되지?"
라고 생각하는 것에서,
"요청이 어디까지 도착했고, 어느 구간부터 전달되지 않는 거지?"
라고 생각하게 된 것이 이번 구축에서 얻은 가장 큰 변화라고 생각한다.
아직 자동 배포도 없고, 모니터링과 백업도 제대로 구성하지 않았다.
그래서 이번 글의 결과를 홈서버 구축 완료라기보다는,
홈서버 1차 구축 완료
라고 부르고 싶다.
앞으로 새로운 프로젝트를 하나씩 이 서버에 배포하면서 부족한 부분을 직접 추가하고, 문제가 발생하면 해결하고, 그 과정 역시 계속 기록해볼 생각이다.
그리고 언젠가 이 서버를 다시 처음부터 구축해야 하는 상황이 왔을 때,
이 글 하나만 보고도 다시 만들 수 있을 정도로 기록을 남기는 것.
그것을 이번 홈서버 프로젝트의 다음 목표로 삼으려고 한다.
최종 아키텍처
마지막으로 이번 글에서 하나씩 살펴봤던 요소들을 하나의 그림으로 합쳐보자.
[ 외부망 ]
사용자
│
HTTPS
│
↓
project1.khoon.net
│
↓
Cloudflare
│
↓
Cloudflare Tunnel
│
↓
════════════════════════ 외부망 / 내부망 ════════════════════════
│
↓
우리 집 네트워크
│
↓
Lenovo M910Q 홈서버
│
↓
Ubuntu Server 24.04
│
↓
Docker
│
┌──────────────────┼───────────────────┐
│ │ │
↓ ↓ ↓
cloudflared Traefik Application
│ │ │
└─────────────────→│ │
│ │
Host Routing │
│ │
└──────────────────→│
│
↓
MySQL
│
↓
mysql-data Volume
[ 관리 / 배포 ]
개발 PC
│
│ 내부망
↓
SSH / SCP
│
↓
홈서버
│
app.jar 전송
│
↓
Docker Build
│
↓
Container 재생성
처음에는 각각 별개의 기술처럼 보였던 것들이 이제 하나의 흐름으로 연결된다.
Domain은 사용자가 서비스를 찾을 수 있게 해주고,
Tunnel은 외부와 홈서버를 연결하고,
Traefik은 요청을 적절한 서비스로 나누고,
Docker는 각 서비스를 독립적인 환경에서 실행하고,
Application과 Database가 실제 서비스를 구성한다.
그리고 이 모든 것이 집 한쪽에 놓여 있는 작은 M910Q 한 대에서 돌아간다.
이것이 현재 내가 직접 구축하고 운영하기 시작한 첫 번째 홈서버다.
'2026 Dev Log' 카테고리의 다른 글
| [SpringBoot] 자동 배포 2026 최종본 (Docker + GitHub Actions) (0) | 2026.05.14 |
|---|---|
| [SpringBoot] 2026 CRUD 서비스 로직 (0) | 2026.05.04 |
| [Springboot] entity <-> DTO 변환 방법 (0) | 2026.04.30 |
| [Spring Boot] Service 파일 CRUD (0) | 2026.04.29 |
| Port 8080 is already in use (1) | 2026.03.04 |