포스트

개인 홈서버 세팅기 — nginx 라우팅 확장, SVN 저장소 구축, Claude MCP 연동까지

개인 Ubuntu 홈서버 정비 과정 — nginx 404 트러블슈팅, Hairpin NAT 원인 분석, SVN 저장소 구축, Claude MCP 서버 연동까지 하루치 작업을 기술적으로 정리했다.

개인 홈서버 세팅기 — nginx 라우팅 확장, 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처럼 원인을 찾기까지 시간이 걸린 것들도 있었지만, 결국 네트워크 계층을 한 단계씩 따라가면 풀리는 문제였다. 홈서버를 운영하다 보면 이런 종류의 트러블슈팅이 꽤 자주 생기는데, 증상보다 원리를 먼저 의심하는 습관이 결국 시간을 아껴준다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.