개인 홈서버 세팅기 — nginx 라우팅 확장, SVN 저장소 구축, Claude MCP 연동까지
개인 Ubuntu 홈서버 정비 과정 — nginx 404 트러블슈팅, Hairpin NAT 원인 분석, SVN 저장소 구축, Claude MCP 서버 연동까지 하루치 작업을 기술적으로 정리했다.
집에 Ubuntu 서버 PC를 두고 개인 개발 환경으로 활용하고 있다. 오늘은 이 홈서버를 정비하면서 생긴 크고 작은 이슈들을 처리했다. nginx 라우팅 404 문제부터 SVN 저장소 구축, Claude Code MCP 서버 연동까지 — 흐름대로 정리해본다.
1. nginx /static/, /webPayment/ 경로 404 문제
문제 상황
홈서버에서 돌리는 PAY 서버는 Vert.x 기반으로 동작하고, HTML 내부에서 /static/js/bootstrap.js 같은 절대 경로로 정적 리소스를 참조한다. 문제는 nginx에서 이 경로를 프론트엔드(5173 포트)로 보내버려서 404가 계속 났다는 점이다.
nginx 경로 매칭 원리
nginx의 location 블록은 요청 URI를 아래 우선순위로 처리한다.
1
2
3
4
1. = (완전 일치) - 가장 우선
2. ^~ (prefix 우선 지정)
3. ~ / ~* (정규식 매칭)
4. / (일반 prefix 매칭) - 가장 낮은 우선순위
기존 설정에서 location / { proxy_pass http://127.0.0.1:5173; } 가 있었기 때문에, /static/이나 /webPayment/를 별도로 선언하지 않으면 전부 프론트엔드로 떨어지는 구조였다. 해결 방법은 더 구체적인 prefix 블록을 추가하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
location /static/ {
proxy_pass http://127.0.0.1:18082/static/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /webPayment/ {
proxy_pass http://127.0.0.1:18082/webPayment/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
proxy_pass끝 슬래시 주의:proxy_pass http://127.0.0.1:18082/static/;처럼 뒤에 슬래시가 있으면 location prefix를 제거하고 나머지 경로만 upstream에 전달한다. 슬래시가 없으면 요청 URI 전체를 그대로 붙인다. 이 차이로 예상치 못한 경로가 만들어지는 경우가 많으니 주의가 필요하다.
sed 이스케이프 함정
nginx 설정을 sed로 자동 수정하다가 nginx -t에서 아래 에러가 났다.
1
invalid number of arguments in "proxy_set_header"
원인은 쉘의 변수 치환이었다. sed 명령어 안에서 $host, $remote_addr 같은 문자열이 쉘 변수로 인식되어 빈 문자열로 치환된 것이다. 결과적으로 proxy_set_header Host ; 처럼 값이 빠진 구문이 만들어졌다.
1
2
3
4
5
# 문제: 쉘이 $host를 빈 문자열로 치환함
sed -i 's/old/proxy_set_header Host $host;/' nginx.conf
# 해결: Python으로 대체 (변수 치환 없이 파일 직접 수정)
python3 fix_nginx.py
단순한 문자열 치환이라도 nginx 변수가 포함된 경우엔 Python이나 heredoc 방식을 쓰는 게 안전하다.
최종 라우팅 구조
1
2
3
4
5
6
7
example.ddns.net (443/SSL)
├── /testpage/* → 127.0.0.1:18085 (TEST_PAGE 톰캣)
├── /payapi/* → 127.0.0.1:18082 (PAY 서버 Vert.x)
├── /webPayment/* → 127.0.0.1:18082 (PAY 서버 - 절대경로 대응) ← 신규
├── /static/* → 127.0.0.1:18082 (PAY 서버 - 정적 리소스) ← 신규
├── /api/* → 127.0.0.1:18080 (백엔드 API)
└── /* → 127.0.0.1:5173 (프론트엔드)
2. Hairpin NAT 이슈
현상
| 접속 환경 | 결과 |
|---|---|
| 휴대폰 데이터 (외부망) | 됨 |
| 휴대폰 와이파이 (내부망) | 안 됨 |
| 같은 망 다른 PC | 안 됨 |
| 외부 PC | 됨 |
처음엔 nginx 설정이나 방화벽 문제인 줄 알았는데, 외부에서는 잘 되고 내부에서만 안 되는 패턴이라 원인이 달랐다.
원인: Hairpin NAT 미지원
Hairpin NAT(NAT Loopback) 는 내부 기기가 공인 IP로 자신의 공유기에 접속하는 경우를 처리하는 기능이다. 일반적인 NAT 동작과 달리 패킷이 공유기 안에서 U턴해야 한다.
1
[내부 기기] → 공유기 → 공인 IP(WAN)로 나감 → 다시 공유기로 돌아옴 → 차단
가정용 공유기 대부분은 이 U턴 경로를 지원하지 않는다. 그래서 외부에서는 정상 접속되지만, 같은 공유기 안에서 도메인(example.ddns.net)으로 접속하면 차단된다.
해결 방법
방법 1 — 내부에서는 내부 IP로 직접 접속
1
http://192.168.1.10:18085/...
방법 2 — hosts 파일 매핑 (PC)
1
2
# C:\Windows\System32\drivers\etc\hosts
192.168.1.10 example.ddns.net
도메인으로 요청해도 DNS 대신 hosts 파일을 먼저 참조하므로 내부 IP로 바로 연결된다.
방법 3 — 공유기 Hairpin NAT 활성화 공유기 관리 페이지에서 지원 여부를 확인한다. ipTIME은 고급 설정 → NAT/라우터 관리 → Hairpin NAT, ASUS는 WAN → NAT Passthrough 항목에서 설정할 수 있다.
3. SVN 운영 저장소 구축
배경
개인 홈서버에서 관리하는 여러 프로젝트를 SVN으로 형상관리하기 위해 기존 DEV 저장소와 분리하여 REAL_PAY를 새로 구성했다.
file:// vs svn:// 프로토콜 차이
| 프로토콜 | 용도 | 특징 |
|---|---|---|
file:// | 서버 로컬에서 직접 접근 | svnserve 불필요, 속도 빠름 |
svn:// | 네트워크를 통한 원격 접근 | svnserve 데몬 필요 |
svn import는 저장소에 최초로 소스를 밀어 넣는 작업이다. 서버 안에서 실행하면 file:// 프로토콜로 svnserve 없이도 바로 import가 가능하다. 이후 실제 작업 경로에 checkout할 때는 svn://로 네트워크 접근한다.
로컬 SVN 클라이언트 없이 import하는 법
로컬 Windows에 SVN 클라이언트가 없어서 직접 svn import를 실행할 수 없었다. 아래 흐름으로 우회했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
C:\dev\workspace\REAL\
│
│ SCP (9개 모듈 병렬 전송, SSH 키 인증)
▼
/tmp/pay_import/
│
│ svn import (서버에서 file:// 프로토콜로 실행)
▼
/svn/repos/pay/REAL_PAY/
│
│ svn checkout (svn:// 프로토콜)
▼
/usr/local/workspace/pay/REAL/
SCP 병렬 전송은 &로 백그라운드 실행하고 wait로 완료를 기다리는 패턴을 썼다.
1
2
3
4
5
scp -r -i ~/.ssh/id_ed25519 ./PAY_ADMIN admin@192.168.1.10:/tmp/pay_import/ &
scp -r -i ~/.ssh/id_ed25519 ./PAY_BATCH admin@192.168.1.10:/tmp/pay_import/ &
# ... 나머지 모듈
wait
echo "전송 완료"
서버에서 import는 각 모듈별로 실행한다.
1
2
3
svn import /tmp/pay_import/PAY_ADMIN \
file:///svn/repos/pay/REAL_PAY/PAY_ADMIN \
-m "REAL 운영 환경 최초 import"
4. Claude Code MCP 서버 등록 (pay-log-server)
MCP 프로토콜 개요
MCP(Model Context Protocol)는 Claude가 외부 도구와 통신하는 표준 프로토콜이다. Claude Code는 stdio 방식으로 MCP 서버 프로세스와 통신한다. Claude가 도구를 호출하면 JSON-RPC 메시지를 표준 입력으로 보내고, 서버는 표준 출력으로 결과를 돌려준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
Claude Code
│ stdin (JSON-RPC 요청)
▼
pay-log-server (node index.js)
│ SSH2 라이브러리로 원격 서버 접속
▼
192.168.1.10 (홈서버)
│ 명령 실행 결과
▼
pay-log-server
│ stdout (JSON-RPC 응답)
▼
Claude Code
등록 명령
1
claude mcp add pay-log-server -- node index.js
-- 이후가 실행할 명령어다. Claude Code가 대화 중 이 MCP를 사용할 때 node index.js를 실행하고 stdio로 통신한다.
제공 도구
| 도구명 | 설명 |
|---|---|
pay_list_logs | 로그 파일 목록 조회 |
pay_read_log | 로그 파일 tail 읽기 |
pay_search_error | 키워드 기반 grep 검색 |
pay_search_error_context | 에러 전후 컨텍스트 포함 검색 |
pay_recent_errors | 최근 에러 빠른 조회 |
pay_server_status | 프로세스/디스크/메모리 상태 확인 |
pay_ssh_command | 직접 명령어 실행 |
pay_search_error_context가 특히 유용하다. 에러 키워드 주변 N줄을 함께 보여줘서 단순 grep보다 원인 파악이 훨씬 빠르다. 내부적으로는 grep -C N 옵션을 사용한다.
Claude Desktop 연동
Claude Desktop(claude_desktop_config.json)에도 같은 서버에 SSH + MySQL MCP를 연결했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"mcpServers": {
"ssh-server": {
"command": "npx",
"args": ["-y", "ssh-mcp"],
"env": {
"SSH_HOST": "192.168.1.10",
"SSH_PORT": "22",
"SSH_USERNAME": "admin",
"SSH_PASSWORD": "YOUR_PASSWORD_HERE"
}
},
"mysql": {
"command": "npx",
"args": ["-y", "@benborla29/mcp-server-mysql"],
"env": {
"MYSQL_HOST": "192.168.1.10",
"MYSQL_PORT": "3306",
"MYSQL_USER": "admin",
"MYSQL_PASS": "YOUR_PASSWORD_HERE",
"MYSQL_DB": ""
}
}
}
}
설정 파일 위치:
C:\Users\{사용자명}\AppData\Roaming\Claude\claude_desktop_config.json
Node.js v18 이상 필요, 저장 후 Claude Desktop 완전 재시작
5. Docker 이중화 구성 검토
단일 서버 환경에서 이중화를 구성하는 가장 현실적인 방법은 Nginx + 컨테이너 2개 조합이다.
방식 비교
| 방식 | 도구 | 난이도 | 적합한 규모 |
|---|---|---|---|
| 컨테이너 복제 | docker-compose replicas | 낮음 | 단일 서버 |
| 로드밸런서 + 복제 | Nginx + Compose | 낮음 | 단일 서버 실습 |
| 멀티 호스트 클러스터 | Docker Swarm | 중간 | 소~중규모 |
| 프로덕션 오케스트레이션 | Kubernetes | 높음 | 대규모 |
단일 서버 실전 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
app1:
image: my-pay-app:latest
container_name: app1
app2:
image: my-pay-app:latest
container_name: app2
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app1
- app2
1
2
3
4
5
6
7
8
9
10
11
upstream app_cluster {
server app1:8080;
server app2:8080;
# least_conn; # 최소 연결 방식 (선택)
}
server {
listen 80;
location / {
proxy_pass http://app_cluster;
}
}
물리 서버가 1대인 환경에서는 서버 자체 장애는 막을 수 없다. 하지만 컨테이너 장애 복구, 무중단 배포, 로드 분산 테스트 목적으로는 충분히 유효한 구성이다.
마치며
오늘 작업의 공통된 흐름은 결국 “개인 홈서버를 외부에서 안정적으로 접근할 수 있는 환경으로 만드는 것” 이었다. nginx 라우팅을 정리해서 각 서버가 제대로 노출되게 하고, SVN으로 프로젝트 형상관리 체계를 잡고, MCP로 Claude에서 서버를 직접 들여다볼 수 있게 만들었다. 각각은 작은 작업이지만 연결해서 보면 하나의 맥락이다.
Hairpin NAT처럼 원인을 찾기까지 시간이 걸린 것들도 있었지만, 결국 네트워크 계층을 한 단계씩 따라가면 풀리는 문제였다. 홈서버를 운영하다 보면 이런 종류의 트러블슈팅이 꽤 자주 생기는데, 증상보다 원리를 먼저 의심하는 습관이 결국 시간을 아껴준다.