왜 이 글을 쓰나
프로덕션 서버 MySQL에 붙어야 했다. DB 포트(3306)는 외부에서 직접 접근이 막혀 있다. 당연히 막아야 하는 게 맞다. 그런데 쿼리를 직접 확인해야 하는 상황이라 TablePlus를 쓰고 싶었다.
처음엔 보안 그룹 인바운드에 내 IP로 3306 열까 싶었다. 다행히 잠깐 멈추고 SSH 터널링을 찾아봤다. 방화벽도 안 건드리고, 기존 SSH 접속만으로 로컬에서 원격 DB에 바로 붙을 수 있었다.
이 글은 SSH 터널링의 세 가지 모드(-L, -R, -D)를 실제로 어떤 상황에서 쓰는지 중심으로 정리한다. ~/.ssh/config로 반복 명령을 줄이는 방법, 백그라운드 실행, 그리고 겪었던 삽질까지 함께 담는다.
SSH 터널링 기본 개념
SSH 터널링은 SSH 연결 위에 다른 프로토콜의 트래픽을 얹어서 전달하는 기법이다. SSH 연결이 성립되면 암호화된 채널이 생기는데, 이 채널을 일반 네트워크 연결처럼 쓸 수 있다.
일반 접속:
Client → (막혀 있음) → Remote DB:3306
SSH 터널링:
Client:3307 → SSH 터널 → Remote SSH:22 → Remote DB:3306
핵심은 SSH 접속이 되는 서버만 있으면, 그 서버가 접근할 수 있는 어떤 포트에도 마치 내 로컬에서 직접 접속하는 것처럼 쓸 수 있다는 것이다.
포워딩 방향에 따라 세 가지 모드가 있다.
| 옵션 | 이름 | 방향 | 용도 |
|---|---|---|---|
-L |
로컬 포트 포워딩 | 로컬 → 원격 | 원격 DB, 내부망 서비스 접근 |
-R |
원격 포트 포워딩 | 원격 → 로컬 | 로컬 서버를 외부에 노출 |
-D |
동적 포트 포워딩 | SOCKS 프록시 | 브라우저로 내부망 전체 접근 |
로컬 포트 포워딩 (-L): 원격 서비스를 로컬로
가장 많이 쓰는 방식이다. 원격 서버나 그 서버에서 접근 가능한 서비스를 내 로컬 포트에 연결한다.
기본 문법
ssh -L [로컬포트]:[목적지호스트]:[목적지포트] [user]@[SSH서버]
원격 DB(MySQL/MariaDB)를 로컬에서 접속
# 원격 서버(192.168.1.10)의 MySQL(3306)을 로컬 3307로 포워딩
ssh -L 3307:localhost:3306 ubuntu@192.168.1.10
# 터널이 연결된 상태에서 다른 터미널에서
mysql -h 127.0.0.1 -P 3307 -u dbuser -p
localhost:3306에서 localhost는 SSH 서버 기준이다. SSH 서버 자신의 3306 포트를 뜻한다.
서버 내부 네트워크에 별도 DB 서버가 있다면 그쪽 주소를 쓰면 된다.
# 내부망 DB 서버(10.0.0.5)의 5432(PostgreSQL)를 로컬 5433으로
ssh -L 5433:10.0.0.5:5432 ubuntu@bastion-server.com
TablePlus / DBeaver에서 SSH 터널로 연결
GUI 클라이언트 대부분이 SSH 터널 연결을 내장 지원한다. 직접 명령어 없이도 된다.
TablePlus 설정:
| 항목 | 값 |
|---|---|
| Connection Type | SSH Tunnel 탭 활성화 |
| SSH Host | 192.168.1.10 |
| SSH Port | 22 |
| SSH User | ubuntu |
| SSH Key | ~/.ssh/id_rsa |
| DB Host | 127.0.0.1 (터널 기준 localhost) |
| DB Port | 3306 |
DBeaver 설정:
연결 설정 → SSH 탭 → "Use SSH tunnel" 체크 → SSH 서버 정보 입력. DB Host는 원격 서버 기준 localhost로.
원격 포트 포워딩 (-R): 로컬 서버를 외부에 노출
방향이 반대다. 내 로컬에서 실행 중인 서버를 원격 서버의 포트에 바인딩해서, 원격 서버에 접근하는 사람이 내 로컬 서버에 닿을 수 있게 한다.
기본 문법
ssh -R [원격포트]:[로컬호스트]:[로컬포트] [user]@[SSH서버]
웹훅 테스트에 활용
로컬에서 개발 중인 서버(3000번)를 외부 서비스가 호출해야 할 때 쓴다.
# 로컬 3000번을 원격 서버의 8080번에 바인딩
ssh -R 8080:localhost:3000 ubuntu@my-server.com
# 이제 http://my-server.com:8080 → 내 로컬 3000번으로 들어옴
# Slack webhook, GitHub webhook, 결제 콜백 테스트 등에 활용
원격 서버의 방화벽에서 해당 포트(8080)가 열려 있어야 외부에서 접근된다. 또한 GatewayPorts yes가 원격 서버 /etc/ssh/sshd_config에 설정돼야 외부 IP에서도 접근 가능하다. 기본값은 localhost에서만 바인딩된다.
동적 포트 포워딩 (-D): SOCKS 프록시로 내부망 전체 접근
-D 옵션은 SOCKS 프록시 서버를 로컬에 만든다. 브라우저나 앱이 이 프록시를 통하면, SSH 서버 입장에서 요청을 보내는 것처럼 동작한다.
기본 문법
ssh -D [로컬포트] [user]@[SSH서버]
# 로컬 1080번에 SOCKS5 프록시 생성
ssh -D 1080 ubuntu@my-server.com
브라우저 프록시 설정
Firefox:
설정 → 일반 → 네트워크 설정 → 연결 설정 → 수동 프록시 설정
- SOCKS 호스트:
127.0.0.1 - 포트:
1080 - SOCKS v5 선택
macOS 시스템 프록시 (터미널):
# SOCKS 프록시 활성화
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 1080
networksetup -setsocksfirewallproxystate Wi-Fi on
# 비활성화
networksetup -setsocksfirewallproxystate Wi-Fi off
이렇게 하면 내부망 전체를 SSH 서버를 경유해서 접근할 수 있다. 방화벽 뒤의 여러 서비스에 접근해야 할 때 -L로 하나하나 포워딩하는 것보다 훨씬 편하다.
~/.ssh/config로 터널 설정 저장
매번 긴 명령을 치는 게 번거롭다면 ~/.ssh/config에 저장해두면 된다.
기본 SSH 접속 설정
# ~/.ssh/config
Host myserver
HostName 192.168.1.10
User ubuntu
IdentityFile ~/.ssh/id_rsa
ServerAliveInterval 60
ServerAliveCountMax 3
설정 후에는 ssh myserver만 치면 된다.
ServerAliveInterval 60: 60초마다 keepalive 패킷 전송. 연결 끊김 방지.ServerAliveCountMax 3: keepalive 응답이 3번 없으면 연결 종료.
LocalForward로 터널 설정 저장
# ~/.ssh/config
Host db-tunnel
HostName 192.168.1.10
User ubuntu
IdentityFile ~/.ssh/id_rsa
LocalForward 3307 localhost:3306
LocalForward 6380 localhost:6379
ServerAliveInterval 60
ServerAliveCountMax 3
ExitOnForwardFailure yes
이제 ssh db-tunnel만 치면 MySQL(3307)과 Redis(6380)이 동시에 포워딩된다.
ExitOnForwardFailure yes를 넣으면 포워딩 설정에 실패했을 때 SSH 연결 자체가 실패로 처리된다. 터널이 제대로 안 됐는데 접속만 된 줄 알고 쓰는 상황을 막아준다.
RemoteForward 설정
Host expose-local
HostName my-server.com
User ubuntu
IdentityFile ~/.ssh/id_rsa
RemoteForward 8080 localhost:3000
ServerAliveInterval 30
백그라운드 터널 실행 (-f -N 옵션)
터미널 창 하나를 터널 전용으로 점유하기 싫다면 백그라운드로 실행한다.
# -f: 백그라운드로 실행
# -N: 원격 명령 실행하지 않음 (포워딩만)
ssh -f -N -L 3307:localhost:3306 ubuntu@192.168.1.10
# config를 써서도 가능
ssh -f -N db-tunnel
백그라운드 터널 종료:
# 터널 프로세스 찾기
ps aux | grep "ssh -f"
# PID로 종료
kill <PID>
# 또는 한 번에
pkill -f "ssh -f -N"
특정 포트 기준으로 찾을 수도 있다.
# 3307 포트를 점유한 프로세스 확인 (macOS)
lsof -i :3307
# Linux
ss -tlnp | grep 3307
포워딩 종류 비교
| 항목 | -L (로컬) | -R (원격) | -D (동적) |
|---|---|---|---|
| 트래픽 방향 | 로컬 → 원격 | 원격 → 로컬 | 양방향 (SOCKS) |
| 포워딩 대상 | 특정 포트 | 특정 포트 | 모든 목적지 |
| 주 사용 사례 | 원격 DB/서비스 접근 | 웹훅, 로컬 서버 노출 | 내부망 브라우징 |
| config 키워드 | LocalForward |
RemoteForward |
DynamicForward |
| 프록시 유형 | 직접 포워딩 | 직접 포워딩 | SOCKS5 |
| 브라우저 설정 필요 | 아니요 | 아니요 | 예 |
| 서버 방화벽 설정 | 불필요 | 포트 오픈 필요 | 불필요 |
| 복수 서비스 처리 | 포트별 각각 | 포트별 각각 | 프록시 하나로 전체 |
삽질 기록
1. 포트 충돌 — Address already in use
bind: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 3307
로컬 3307번이 이미 사용 중이다. 이전에 열어둔 터널이 백그라운드에 살아 있거나, 다른 프로세스가 쓰고 있는 경우다.
# 어떤 프로세스가 3307 쓰는지 확인
lsof -i :3307 # macOS
ss -tlnp | grep 3307 # Linux
# 기존 터널 종료
pkill -f "ssh -f -N"
# 또는 포트 번호를 3308로 바꿔서 쓰기
ssh -L 3308:localhost:3306 ubuntu@192.168.1.10
2. 연결 끊김 — Broken pipe / Timeout
장시간 idle 상태이면 SSH 연결이 자동으로 끊긴다. 네트워크 장비(방화벽, NAT)가 일정 시간 후 비활성 TCP 연결을 끊어버리기 때문이다.
~/.ssh/config에 keepalive 설정이 정답이다.
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
Host *로 모든 서버에 공통 적용할 수 있다. 60초마다 패킷을 보내서 연결을 유지한다.
터널 전용으로 쓴다면 autossh를 쓰는 것도 방법이다. 연결이 끊기면 자동으로 재연결한다.
# autossh 설치 (macOS)
brew install autossh
# 자동 재연결 터널
autossh -M 0 -f -N -L 3307:localhost:3306 ubuntu@192.168.1.10
-M 0은 autossh의 모니터링 포트를 비활성화하고 SSH keepalive에만 의존하는 설정이다.
3. known_hosts 오류
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
서버를 재설치했거나 IP를 재사용한 경우, ~/.ssh/known_hosts에 저장된 공개키와 달라서 발생한다.
# 해당 호스트 항목 삭제
ssh-keygen -R 192.168.1.10
# 또는 직접 파일에서 해당 줄 삭제
# ~/.ssh/known_hosts 열어서 문제 호스트 줄 삭제
삭제 후 다시 접속하면 새 키를 등록할지 묻는다. 서버를 실제로 재설치한 게 맞다면 yes로 등록하면 된다. 예상치 못한 변경이라면 중간자 공격(MITM) 가능성도 있으니 서버 관리자에게 확인하는 게 맞다.
4. Permission denied (publickey)
ubuntu@192.168.1.10: Permission denied (publickey).
키 인증 실패다. 원인 몇 가지:
공개키가 서버에 없는 경우:
# 로컬 공개키를 서버에 복사
ssh-copy-id -i ~/.ssh/id_rsa.pub ubuntu@192.168.1.10
# 수동으로
cat ~/.ssh/id_rsa.pub | ssh ubuntu@192.168.1.10 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
키 파일 권한 문제:
# SSH는 키 파일 권한이 너무 열려 있으면 거부함
chmod 600 ~/.ssh/id_rsa
chmod 700 ~/.ssh/
여러 키가 있어서 다른 키를 시도하는 경우:
# 특정 키를 명시해서 접속
ssh -i ~/.ssh/myserver_rsa ubuntu@192.168.1.10
# 또는 config에 IdentityFile 명시
접속 실패 시 -v 옵션으로 디버그 로그를 보면 어느 단계에서 실패하는지 바로 보인다.
ssh -v ubuntu@192.168.1.10
ssh -vvv ubuntu@192.168.1.10 # 더 상세하게
5. -R 터널이 localhost에서만 동작하는 문제
-R로 원격 서버의 포트를 열었는데 외부에서 접근이 안 되는 경우다. SSH 기본 동작이 127.0.0.1에만 바인딩하기 때문이다.
서버의 /etc/ssh/sshd_config에 추가:
GatewayPorts yes
설정 후 SSH 데몬 재시작:
sudo systemctl restart sshd
이렇게 하면 -R로 연 포트가 0.0.0.0에 바인딩되어 외부에서 접근 가능해진다.
마무리
SSH 터널링은 설정이 복잡해 보이지만 옵션 하나로 대부분의 상황이 해결된다.
- 원격 DB 접속:
ssh -L 3307:localhost:3306 user@server - 웹훅 테스트:
ssh -R 8080:localhost:3000 user@server - 내부망 브라우징:
ssh -D 1080 user@server
반복해서 쓰는 패턴은 ~/.ssh/config에 LocalForward로 저장해두면 명령 한 줄로 줄어든다. 백그라운드 실행은 -f -N 조합, 장시간 유지는 ServerAliveInterval 설정이나 autossh 조합으로 해결된다.
보안 그룹 건드리기 전에 SSH 터널로 해결할 수 있는지 먼저 확인하는 습관이 생겼다.
기술 스택: SSH, OpenSSH, SOCKS5, MySQL, MariaDB, PostgreSQL, autossh
관련 도구: TablePlus, DBeaver, autossh
'개발 팁' 카테고리의 다른 글
| API 문서화 실전 — Swagger/OpenAPI로 자동 문서 만들기 (0) | 2026.03.20 |
|---|---|
| TypeScript 실전 팁 — 유틸리티 타입, 타입 가드, 제네릭 완전 정복 (0) | 2026.03.20 |
| cron 표현식 완전 정복 — 스케줄링 실전 치트시트 (1) | 2026.03.20 |
| npm vs yarn vs pnpm vs Bun — 패키지 매니저 제대로 선택하기 (1) | 2026.03.20 |
| Nginx 실전 설정 — 리버스 프록시, HTTPS, 캐싱 완전 정복 (0) | 2026.03.20 |