
"외부에서 접속할 수 있게 도메인 연결해주세요. 아, 그런데 포트포워딩은 안 됩니다."
최근에 이런 요구사항을 받았다. 사내 온프레미스 서버에서 AI 서비스를 운영하고 있었는데, 개발도 배포도 잘 돌아가는 상황에서 마지막 관문이 접근성이었다. 외부에서 https://myapp.example.com으로 들어올 수 있어야 하는데, 회사 네트워크 정책상 인바운드 포트포워딩이 불가능했다.
처음엔 막막했다. "포트포워딩이 안 되면 외부 노출은 불가능한 거 아닌가?" 결론부터 말하면, 가능하다. 그것도 새로 살 것 하나 없이, 이미 갖고 있던 자산만으로.
이 글은 그 과정의 기록이다. 조건을 정리하면 이랬다.
- 서비스는 사내 서버(
localhost:3000)에서만 접근 가능 - 방화벽/라우터 설정 변경 불가 — 인바운드로 뚫을 방법이 없음
- 아웃바운드 트래픽은 자유로움
- HTTPS + 커스텀 도메인 필수
핵심 통찰은 세 번째 줄에 있다. 인바운드가 막혀 있어도 아웃바운드가 열려 있다면, 가장 현실적인 패턴은 이것이다. 안에서 밖으로 먼저 연결을 열어두고, 그 연결을 타고 트래픽을 거꾸로 흘려보내는 것. 이른바 역터널(reverse tunnel) 패턴이다.
먼저, 대안부터 비교하자
역터널을 구현하는 방법은 하나가 아니다. 후보를 놓고 비교했다.
| 방법 | 장점 | 탈락/선택 이유 |
|---|---|---|
| Cloudflare Tunnel | 관리형, 무료, TLS 자동, 사실상 업계 표준 | 일반적인 구성에서는 해당 hostname이 Cloudflare 관리 zone에 있어야 함. 회사 도메인이 Route53에 고정돼 있고 zone 이관이나 partial setup(Business 플랜 이상)을 쓰기 어려워 제외 |
| ngrok | 가장 빠른 셋업 | 고정 커스텀 도메인은 유료, 외부 SaaS 의존 |
| autossh 역터널 | 추가 도구 없이 SSH만으로 가능 | 재접속 안정성이 아쉽고, 포트 추가할 때마다 손이 감 |
| frp | 재접속에 강하고 멀티포트 확장 쉬움, 필요 시 proxy 단위 헬스체크 설정 가능, 셀프호스팅 | ✅ 선택 — 이미 보유한 EC2를 릴레이로 활용 |
솔직하게 말하면, 이 상황에서 요즘의 기본값은 Cloudflare Tunnel이다. 릴레이 서버도, nginx도, certbot도 관리할 필요가 없다. 하지만 우리 도메인은 Route53에 묶여 있었고, zone 이관은 선택지가 아니었다.
그때 눈에 들어온 게 이미 nginx가 돌고 있던 EC2 한 대였다. 짚어두고 싶은 건, Cloudflare Tunnel을 몰라서 frp를 고른 게 아니라 제약 조건 위에서 의도적으로 고른 것이라는 점이다. 도메인이 Cloudflare에 있거나 이관이 자유롭다면 Cloudflare Tunnel을 먼저 검토하는 게 맞다.
전체 그림
[외부 사용자]
│ https://myapp.example.com
▼
① Route53: A 레코드 → EC2 EIP (1.2.3.4)
▼
② EC2 nginx: TLS 종단 + 리버스 프록시
│ proxy_pass → 127.0.0.1:13000
▼
③ frp 터널: EC2:13000 ←── 사내 서버가 밖으로 열어둔 상시 연결
▼
④ 사내 서버 앱 (localhost:3000) ← 변경 없음핵심은 ③이다. 사내 서버(frpc)가 EC2(frps)로 아웃바운드 연결을 먼저 열고 유지한다. 외부 요청이 EC2에 도착하면 이 연결을 타고 사내 서버까지 전달된다. 방화벽 입장에서는 사내 서버가 밖으로 나가는 평범한 연결일 뿐이다.
하나씩 보자.
① DNS — Route53 A 레코드
가장 쉬운 단계다. 서브도메인을 EC2의 탄력적 IP로 연결한다. 콘솔에서 레코드 하나 추가하면 끝.
myapp.example.com A 1.2.3.4② EC2 — frps (터널 서버)
# /etc/frp/frps.toml
bindPort = 7000
# 터널로 열리는 포트를 loopback에만 바인딩 → EC2 내부 nginx만 접근 가능
proxyBindAddr = "127.0.0.1"
# 클라이언트가 열 수 있는 remote port를 화이트리스트로 제한
allowPorts = [
{ single = 13000 }
]
auth.method = "token"
auth.token = "충분히-긴-랜덤-문자열"
# frpc의 TLS는 v0.50.0부터 기본 활성화 — 서버 쪽에서 TLS 연결만 받도록 강제
transport.tls.force = true
여기서 짚어둘 점이 있다. proxyBindAddr를 지정하지 않으면 터널 포트가 0.0.0.0:13000으로 뜰 수 있다. "보안그룹이 막아주니까 괜찮다"는 구조가 되는데, 방어선이 하나뿐인 구성은 불안하다. loopback에 바인딩하면 OS 레벨에서 외부 접근이 원천 차단된다. allowPorts는 클라이언트가 임의의 포트를 여는 것을 막는다.
보안그룹에서 7000 포트는 사내 서버의 egress 공인 IP에만 열어준다. 이걸 빼먹으면 내 EC2가 아무나 쓰는 터널 릴레이가 될 수 있다. 단, 사내 egress 공인 IP가 유동이면 IP가 바뀌는 순간 frpc가 붙지 못하므로, 고정 IP 회선인지 먼저 확인하거나 보안그룹 업데이트를 자동화해둬야 한다.
③ 사내 서버 — frpc (터널 클라이언트)
# /etc/frp/frpc.toml
serverAddr = "1.2.3.4"
serverPort = 7000
auth.method = "token"
auth.token = "충분히-긴-랜덤-문자열"
[[proxies]]
name = "myapp-web"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3000
remotePort = 13000
remotePort = 13000은 EC2 쪽에 열리는 포트다. 위에서 proxyBindAddr = "127.0.0.1"로 loopback에 묶어뒀으므로 EC2 내부의 nginx만 접근할 수 있고, 보안그룹에서도 닫아둔다. 이중 잠금이다.
터널이 끊겨도 자동 복구되도록 systemd 서비스로 등록한다.
# /etc/systemd/system/frpc.service
[Unit]
Description=frp client
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/frpc -c /etc/frp/frpc.toml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EC2의 frps도 같은 방식으로 등록하면 된다. 올리기 전에 검증까지 해두자.
# 사내 서버: 설정 문법 검증
frpc verify -c /etc/frp/frpc.toml
# EC2: frpc가 연결된 뒤, 터널 포트가 loopback에만 떠 있는지 확인 (127.0.0.1:13000 이어야 함)
# ※ 13000은 frpc가 붙어야 열린다 — frps만 올린 상태에선 아무것도 안 보이는 게 정상
ss -lntp | grep 13000
frp v0.64.0 이상이라면 token을 설정 파일에 평문으로 두는 대신 auth.tokenSource.type = "file"로 별도 파일에서 읽게 할 수도 있다. frpc/frps 버전은 서로 맞추고, 가능하면 최신 안정 버전을 쓴다.
④ EC2 nginx — TLS 종단 + 프록시
# WebSocket: Upgrade 헤더가 있을 때만 Connection: upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80; # certbot 실행 전 HTTP 상태 — certbot이 443 블록을 추가해준다
server_name myapp.example.com;
location / {
proxy_pass http://127.0.0.1:13000;
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;
# WebSocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
# SSE(스트리밍 응답)를 쓴다면 버퍼링 해제
proxy_buffering off;
}
}
여기서 주의할 점 두 가지.
첫째, map 블록은 server {} 내부가 아니라 nginx의 http context에 둬야 한다. Ubuntu의 sites-available 파일이라면 보통 server 블록 위에 두면 된다(해당 파일들이 http 블록 안에서 include되기 때문). 복붙하다가 server 안에 넣으면 에러가 난다.
둘째, WebSocket과 SSE는 다루는 방식이 다르다. WebSocket은 map $http_upgrade 패턴으로 Upgrade 요청일 때만 Connection: upgrade를 넘긴다. 반면 SSE는 protocol upgrade를 쓰지 않으므로 map과는 무관하고, 대신 proxy_buffering off가 핵심이다. AI 서비스처럼 토큰을 바로바로 흘려보내는 앱이라면 이 설정은 사실상 필수에 가깝다 — 없으면 응답이 nginx 버퍼에 고여 뭉텅이로 도착한다. 실제로 이걸 빼먹으면 채팅 응답이 실시간으로 흐르지 않고 덩어리째 떨어지는 걸 보게 된다. 또 nginx는 upstream이 60초간 데이터를 보내지 않으면 연결을 닫으므로, 장시간 유휴가 있는 WebSocket/SSE라면 proxy_read_timeout을 늘리거나 서버가 주기적으로 ping/keepalive를 보내게 한다.
인증서는 certbot으로 발급하면 위 서버블록에 TLS 설정을 자동으로 채워준다. HTTP-01 challenge를 쓰므로 보안그룹에서 80 포트가 열려 있어야 발급이 된다.
sudo certbot --nginx -d myapp.example.com
정리하면 EC2 보안그룹은 이렇게 된다.
| 포트 | 용도 | 허용 범위 |
|---|---|---|
| 80 | HTTP-01 challenge + HTTPS redirect | 전체 |
| 443 | HTTPS 서비스 | 전체 |
| 7000 | frpc 접속 | 사내 서버 egress 공인 IP만 |
| 13000 | 터널 포트 | 열지 않음 (proxyBindAddr로 loopback 전용) |
여기까지 하고 https://myapp.example.com을 열면 — 사내 서버의 앱이 그대로 뜬다.
다만 표현을 정확히 해야 한다. 네트워크 노출 방식만 바꿨고, 앱의 비즈니스 로직은 수정하지 않았다. 앱이 Host 헤더 allowlist, secure cookie, OAuth redirect URI, WebSocket origin 검증 등을 엄격히 하고 있다면 reverse proxy 관련 설정(예: X-Forwarded-* 신뢰 설정)은 필요할 수 있다.
열기 전에 반드시 챙겨야 할 것들
이 구성은 사내 서비스를 인터넷 전체에 노출하는 것이다. 동작 확인에 취해서 이 부분을 건너뛰면 안 된다.
- 인증 없이 공개하지 말 것. 앱 자체에 로그인이 없다면 최소한 nginx basic auth나 IP 허용목록을 걸어야 한다. 이 패턴에서 사고 대부분은 여기서 난다.
- frps 토큰 + 보안그룹 이중 잠금. 토큰만 믿지 말고 7000 포트를 소스 IP로 제한한다.
- EC2가 단일 장애점이자 대역폭 경유지가 된다. 모든 트래픽이 EC2를 경유하며, 특히 사용자에게 응답을 내려보내는 구간과 EC2→사내 서버 방향 구간이 EC2 outbound 트래픽으로 과금될 수 있다. 트래픽 규모가 커지면 비용과 가용성을 다시 계산해야 한다. 사내 도구 수준이면 무시해도 된다.
- certbot 자동 갱신 타이머가 살아 있는지 확인한다 (
systemctl list-timers | grep certbot).
정리
이번 작업을 요약하면 이렇다.
- DNS: Route53 A 레코드 한 줄 — 5분
- TLS + 프록시: EC2 nginx 서버블록 + certbot — 20분
- 터널: frps/frpc + systemd 등록 — 30분
- 앱: 변경 없음 — 0분
실제 작업 시간은 한 시간이 채 안 걸렸다. "포트포워딩이 안 되면 끝"이라고 생각했던 문제가, 아웃바운드가 열려 있다는 사실 하나로 완전히 다른 문제가 됐다.
frp를 선택한 보상은 확장성으로 돌아온다. 나중에 다른 서비스를 노출할 일이 생기면 [[proxies]] 블록 하나만 추가하면 같은 터널에 얹을 수 있다.
내가 가져갈 판단 기준은 명확하다. 도메인 DNS를 어디서 관리하느냐가 이 의사결정의 핵심 변수다. Cloudflare에 존이 있다면 Cloudflare Tunnel이 더 단순하고, Route53에 묶여 있고 놀고 있는 퍼블릭 서버가 있다면 이 구성이 실용적인 답이다.
그리고 하나 더. 이런 구성은 "되게 만드는 것"보다 "안전하게 여는 것"이 어렵다. proxyBindAddr, allowPorts, 토큰, 보안그룹, 인증 — 터널 자체는 30분이면 뚫리지만, 이 다섯 가지를 챙겼는지가 이 글을 삽질 기록으로 남길지 장애 기록으로 남길지를 가른다.
'Frontend Development' 카테고리의 다른 글
| React Compiler를 켰는데 왜 아직 useCallback과 memo를 쓰고 있을까? (0) | 2026.06.05 |
|---|---|
| 강제 새로고침해도 사이드바가 안 깜빡이게 만들기: Next.js 앱 셸 상태 저장 전략 (0) | 2026.04.08 |
| Next.js 16 릴리즈: 캐싱, 드디어 명시적으로 바뀌다 (0) | 2026.01.22 |
| React의 Error Boundary와 비동기 오류 처리 (0) | 2025.10.13 |
| React 리렌더링(Re-rendering): Trigger → Render → Commit (1) | 2025.10.03 |
