- Published on
원격 디버깅 실전 가이드: SSH 터널 · VS Code · IntelliJ · PyCharm으로 Java·Node·Python·Go 원격 Attach
- Authors
- Name
이 글은 디버깅 실전 시리즈 5편 중 5편이다.
원격 디버깅이 필요한 순간
로컬에서 재현되지 않는 버그는 반드시 온다. 스테이징 전용 데이터, 운영 환경 고유의 네트워크 토폴로지, 특정 리전에서만 발생하는 타이밍 이슈 등 원인은 다양하다. 로그만으로 해결되지 않을 때 원격 디버깅(Remote Debugging)이 최후의 카드가 된다.
하지만 원격 디버깅은 양날의 검이다. 디버그 포트가 외부에 노출되면 RCE(Remote Code Execution) 취약점이 되고, 운영 프로세스를 suspend 상태로 멈추면 장애가 확대된다. 이 글에서는 안전하게 원격 디버거를 붙이고, 세션을 끝낸 뒤 깨끗하게 원복하는 전체 흐름을 정리한다.
1. 보안 원칙 — 원격 디버깅의 전제 조건
원격 디버깅은 보안 사고와 장애 확산 위험을 동시에 안고 있다. 아래 원칙을 팀 표준으로 정해 두어야 한다.
1-1. 디버그 포트 외부 공개 금지
디버그 프로토콜(JDWP, Chrome DevTools Protocol, DAP 등)은 인증 없이 코드 실행이 가능하다. 방화벽/Security Group에서 디버그 포트를 절대 퍼블릭으로 열지 않는다.
# 잘못된 예: 0.0.0.0으로 바인딩 → 모든 인터페이스에서 접근 가능
java -agentlib:jdwp=transport=dt_socket,server=y,address=0.0.0.0:5005,suspend=n -jar app.jar
# 올바른 예: localhost에만 바인딩
java -agentlib:jdwp=transport=dt_socket,server=y,address=127.0.0.1:5005,suspend=n -jar app.jar
1-2. SSH 터널 우선 원칙
로컬 IDE와 원격 서버 사이에는 반드시 SSH 터널 또는 VPN을 경유한다.
# 로컬 5005 → 원격 서버 127.0.0.1:5005 로 터널
ssh -N -L 5005:127.0.0.1:5005 deploy@staging-server
# 백그라운드 실행 + 자동 재연결
ssh -f -N -L 5005:127.0.0.1:5005 deploy@staging-server
# -f: 백그라운드, -N: 명령 실행 안 함
-L 옵션 구조: 로컬포트:원격_바인드_주소:원격포트
1-3. 운영 환경 디버깅 승인 프로세스
운영(Production) 환경에서 디버거를 붙이려면 다음 절차를 반드시 거친다.
- 장애 대응 티켓(Incident Ticket) 생성
- 팀 리드 또는 온콜 담당자 승인
- 디버깅 세션 시작/종료 시각 Slack 채널 공유
- 디버그 JVM 옵션/환경변수 제거 후 프로세스 원복 확인
- 사후 회고(Postmortem)에 디버깅 세션 기록 포함
팁: 스테이징에서 충분히 재현을 시도한 뒤, 그래도 안 될 때만 운영 디버깅을 요청한다.
2. 언어별 원격 디버그 설정
Table 1: 언어별 원격 디버그 포트·프로토콜·시작 명령 비교
| 언어 | 프로토콜 | 기본 포트 | 서버 시작 명령 | 비고 |
|---|---|---|---|---|
| Java | JDWP (Java Debug Wire Protocol) | 5005 | java -agentlib:jdwp=transport=dt_socket,server=y,address=127.0.0.1:5005,suspend=n -jar app.jar | JDK 9+ address=*:5005 문법 변경 |
| Node.js | Chrome DevTools Protocol | 9229 | node --inspect=127.0.0.1:9229 app.js | --inspect-brk로 첫 줄 suspend 가능 |
| Python | DAP (Debug Adapter Protocol) via debugpy | 5678 | python -m debugpy --listen 127.0.0.1:5678 --wait-for-client app.py | pip install debugpy 필요 |
| Go | Delve DAP | 2345 | dlv debug --headless --listen=127.0.0.1:2345 --api-version=2 --accept-multiclient | go install github.com/go-delve/delve/cmd/dlv@latest |
2-1. Java JDWP Remote Attach
Java는 가장 성숙한 원격 디버깅 생태계를 가지고 있다.
# JDK 8
java -agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n \
-jar myservice.jar
# JDK 9+ (주소 바인딩 문법 변경)
java -agentlib:jdwp=transport=dt_socket,server=y,address=127.0.0.1:5005,suspend=n \
-jar myservice.jar
# Spring Boot Gradle 프로젝트에서 디버그 모드
./gradlew bootRun --debug-jvm
# → 기본 5005 포트에 suspend=y 로 대기
JDWP 옵션 정리:
| 옵션 | 값 | 설명 |
|---|---|---|
transport | dt_socket | TCP 소켓 통신 (거의 항상 이 값) |
server | y | 디버그 서버 역할 (IDE가 클라이언트로 접속) |
address | host:port | 바인드 주소 |
suspend | y / n | y: 디버거 연결까지 JVM 대기, n: 즉시 실행 |
timeout | ms | 디버거 연결 타임아웃 (0=무제한) |
SSH 터널 연결:
# 로컬 PC에서
ssh -N -L 5005:127.0.0.1:5005 deploy@staging-01.internal
# 이후 IDE에서 localhost:5005 로 attach
2-2. Node.js --inspect Remote Attach
# 기본 inspect (연결 전에도 실행 계속)
node --inspect=127.0.0.1:9229 dist/server.js
# 첫 줄에서 멈춤 (초기화 코드 디버깅 시)
node --inspect-brk=127.0.0.1:9229 dist/server.js
# 이미 실행 중인 프로세스에 시그널로 inspect 활성화
kill -USR1 <PID>
# → stderr에 "Debugger listening on ws://127.0.0.1:9229/..." 출력
SSH 터널:
ssh -N -L 9229:127.0.0.1:9229 deploy@staging-node-server
주의: Node.js inspect는 WebSocket 기반이므로, 일부 프록시에서 WebSocket 업그레이드가 차단될 수 있다. SSH 터널이 가장 안전하다.
2-3. Python debugpy Remote Attach
# 1. 설치
pip install debugpy
# 2-A. CLI로 실행 (가장 단순)
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client app.py
# 2-B. 코드에 삽입 (기존 실행 스크립트 유지할 때)
코드 삽입 방식:
import debugpy
# 포트 5678에서 대기
debugpy.listen(("127.0.0.1", 5678))
print("Waiting for debugger attach...")
debugpy.wait_for_client() # 디버거 붙을 때까지 블로킹
debugpy.breakpoint() # 여기서 멈춤
# 이후 비즈니스 로직
def process_order(order_id):
# 디버거로 여기까지 step-into 가능
...
# 2-C. 이미 실행 중인 프로세스에 attach (제한적)
# → debugpy를 코드에 미리 내장해야 함. 런타임 inject는 지원 안 됨.
SSH 터널:
ssh -N -L 5678:127.0.0.1:5678 deploy@staging-api-server
2-4. Go Delve Remote Attach
# 설치
go install github.com/go-delve/delve/cmd/dlv@latest
# 방법 A: 소스에서 빌드 + 디버그 (개발용)
dlv debug ./cmd/server \
--headless --listen=127.0.0.1:2345 \
--api-version=2 --accept-multiclient
# 방법 B: 이미 빌드된 바이너리에 attach
dlv attach <PID> \
--headless --listen=127.0.0.1:2345 \
--api-version=2 --accept-multiclient
# 방법 C: 빌드된 바이너리 실행 (최적화 비활성화 필수)
go build -gcflags="all=-N -l" -o myserver ./cmd/server
dlv exec ./myserver \
--headless --listen=127.0.0.1:2345 \
--api-version=2 --accept-multiclient
중요: Go 컴파일러 최적화가 켜져 있으면 변수 값이
<optimized out>으로 보인다. 디버그용 빌드에는 반드시-gcflags="all=-N -l"을 추가한다.
SSH 터널:
ssh -N -L 2345:127.0.0.1:2345 deploy@staging-go-server
3. IDE별 Attach 설정 예시
Table 2: IDE별 원격 Attach 설정 요약
| IDE | 언어 | Attach 타입 | 핵심 설정 | 비고 |
|---|---|---|---|---|
| VS Code | Java | java attach | host, port | Extension Pack for Java 필요 |
| VS Code | Node.js | node attach | port, localRoot/remoteRoot | 소스맵 경로 맞추기 필수 |
| VS Code | Python | debugpy attach | connect.host/port, pathMappings | ms-python 확장 필요 |
| VS Code | Go | go dlv attach | host, port, remotePath | Go 확장(golang.go) 필요 |
| IntelliJ | Java | Remote JVM Debug | host, port, JDK version | 커맨드라인 인자 자동 생성 |
| PyCharm | Python | Python Remote Debug | host, port, path mappings | Professional 에디션 필요 |
3-1. VS Code launch.json — 언어별 Remote Attach
{
"version": "0.2.0",
"configurations": [
// ── Java Remote Attach ──
{
"type": "java",
"name": "Java Remote (5005)",
"request": "attach",
"hostName": "localhost",
"port": 5005,
"projectName": "myservice"
},
// ── Node.js Remote Attach ──
{
"type": "node",
"name": "Node Remote (9229)",
"request": "attach",
"port": 9229,
"address": "localhost",
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app",
"sourceMaps": true,
"skipFiles": ["<node_internals>/**"]
},
// ── Python Remote Attach (debugpy) ──
{
"type": "debugpy",
"name": "Python Remote (5678)",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app"
}
],
"justMyCode": false
},
// ── Go Remote Attach (Delve) ──
{
"type": "go",
"name": "Go Remote (2345)",
"request": "attach",
"mode": "remote",
"port": 2345,
"host": "localhost",
"substitutePath": [
{
"from": "${workspaceFolder}",
"to": "/app"
}
]
}
]
}
경로 매핑이 가장 중요하다: 로컬 소스 경로와 원격 서버의 소스/빌드 경로가 다르면 브레이크포인트가 "unverified" 상태가 된다. localRoot/remoteRoot 또는 pathMappings/substitutePath를 정확히 맞춰야 한다.
3-2. IntelliJ IDEA — Remote JVM Debug
- Run > Edit Configurations > + > Remote JVM Debug 선택
- 설정값 입력:
| 항목 | 값 |
|---|---|
| Debugger mode | Attach to remote JVM |
| Transport | Socket |
| Host | localhost |
| Port | 5005 |
| Command line arguments for remote JVM | (자동 생성됨) |
| Use module classpath | 대상 모듈 선택 |
- SSH 터널이 열린 상태에서 Debug 버튼 클릭
# IntelliJ가 자동 생성해 주는 JVM 인자 (복사해서 서버에 적용)
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
IntelliJ 추가 팁:
- Evaluate Expression (Alt+F8): 런타임에 임의 코드 실행 가능. 운영에서는 사이드이펙트 주의.
- Drop Frame: 현재 메서드를 재실행. 상태 변경이 롤백되지는 않으므로 주의.
- HotSwap: 클래스 바디 수정 후 재로드. 메서드 시그니처 변경은 불가.
3-3. PyCharm — Python Remote Debug (debugpy attach)
PyCharm Professional에서 원격 Python 디버깅을 설정하는 방법:
- Run > Edit Configurations > + > Python Debug Server 선택 (구 버전) 또는 Python Remote Debug 선택
- 설정값:
| 항목 | 값 |
|---|---|
| Host | localhost |
| Port | 5678 |
| Path mappings | 로컬 프로젝트 루트 ↔ 원격 /app |
- 원격 서버에서 debugpy 코드 삽입 또는 CLI 실행
- PyCharm에서 Debug 버튼 → 서버가 연결 대기 상태 → 원격 앱 시작
# 원격 서버의 코드에 추가
import debugpy
debugpy.listen(("0.0.0.0", 5678)) # SSH 터널 사용 시 0.0.0.0도 가능
debugpy.wait_for_client()
PyCharm Community Edition에서는 Python Remote Debug가 지원되지 않는다. VS Code + debugpy 조합이 무료 대안이다.
4. Kubernetes 환경 원격 디버깅
컨테이너 오케스트레이션 환경에서는 SSH 터널 대신 kubectl port-forward가 표준이다.
4-1. kubectl port-forward로 디버그 포트 노출
# Pod 이름으로 직접
kubectl port-forward pod/myservice-abc123-xyz 5005:5005 -n staging
# Deployment를 통해 (임의 Pod 선택)
kubectl port-forward deployment/myservice 5005:5005 -n staging
# Service를 통해
kubectl port-forward svc/myservice 5005:5005 -n staging
이후 IDE에서 localhost:5005로 attach하면 된다.
디버그 모드 Pod 배포 예시 (Java):
# deployment-debug.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myservice-debug
namespace: staging
spec:
replicas: 1 # 디버그용은 반드시 1개
selector:
matchLabels:
app: myservice-debug
template:
metadata:
labels:
app: myservice-debug
spec:
containers:
- name: myservice
image: myregistry/myservice:debug-20260307
ports:
- containerPort: 8080
name: http
- containerPort: 5005
name: jdwp
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-agentlib:jdwp=transport=dt_socket,server=y,
address=127.0.0.1:5005,suspend=n
resources:
requests:
memory: '512Mi'
cpu: '500m'
limits:
memory: '1Gi'
cpu: '1000m'
kubectl apply -f deployment-debug.yaml
kubectl port-forward deployment/myservice-debug 5005:5005 -n staging
4-2. 디버거 사이드카 패턴
기존 컨테이너 이미지를 수정하지 않고, 사이드카 컨테이너에 Delve 등 디버거를 넣어 공유 프로세스 네임스페이스로 attach하는 패턴이다.
apiVersion: v1
kind: Pod
metadata:
name: myservice-debug-sidecar
spec:
shareProcessNamespace: true # PID 네임스페이스 공유
containers:
- name: myservice
image: myregistry/myservice:latest
ports:
- containerPort: 8080
- name: debugger
image: golang:1.22
command: ['sleep', 'infinity']
securityContext:
capabilities:
add: ['SYS_PTRACE'] # ptrace 권한 필요
# 사이드카 컨테이너에 진입하여 Delve로 attach
kubectl exec -it myservice-debug-sidecar -c debugger -- bash
# 컨테이너 안에서:
go install github.com/go-delve/delve/cmd/dlv@latest
dlv attach <PID> --headless --listen=:2345 --api-version=2
4-3. Telepresence / Ephemeral Containers
Telepresence: 로컬 프로세스를 클러스터 네트워크에 "끼워 넣는" 도구. 원격 Pod 대신 로컬에서 실행하면서 클러스터 내부 DNS·서비스 접근이 가능하다.
# 설치
brew install datawire/blackbird/telepresence
# 클러스터 연결
telepresence connect
# 특정 워크로드 가로채기 (트래픽을 로컬로 리다이렉트)
telepresence intercept myservice --port 8080:8080 --env-file .env.telepresence
# 로컬에서 디버그 모드로 실행
JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,address=127.0.0.1:5005,suspend=n" \
java -jar myservice.jar
Ephemeral Containers (Kubernetes 1.25+): 실행 중인 Pod에 임시 컨테이너를 주입한다.
kubectl debug -it pod/myservice-abc123 \
--image=busybox \
--target=myservice \
--share-processes \
-n staging
Ephemeral Container에는 디버거 바이너리가 포함된 이미지를 사용해야 한다. 프로덕션 이미지에 디버거를 넣지 않아도 되는 것이 장점이다.
5. 운영 환경 주의사항
5-1. suspend=y vs suspend=n
| 옵션 | 동작 | 사용 시점 |
|---|---|---|
suspend=y | 디버거가 붙을 때까지 프로세스 정지 | 애플리케이션 시작 로직 디버깅 |
suspend=n | 디버거 없이도 즉시 실행 | 운영·스테이징 상시 적용 |
운영 환경에서는 반드시 suspend=n을 사용한다. suspend=y는 프로세스가 디버거 연결을 기다리며 멈추므로 헬스체크 실패 → Pod 재시작 → 연쇄 장애로 이어질 수 있다.
5-2. 성능 영향 (디버거 연결 시 오버헤드)
디버그 에이전트가 로드된 상태에서도 디버거가 연결되지 않으면 오버헤드는 미미하다 (1-3% 이내). 그러나 다음 상황에서 성능 저하가 심해진다:
- 브레이크포인트 다수 설정: 매 히트마다 모든 스레드가 suspend됨
- 조건부 브레이크포인트: 조건 evaluation 비용이 매 실행마다 발생
- Evaluate Expression: 무거운 expression 실행 시 전체 스레드 정지
- Step-over/Step-into 반복: 단일 스레드 외에도 다른 스레드의 suspend/resume이 빈번
# 성능 영향 체감 수준
에이전트 로드만 (연결 없음) : ~1-3% 오버헤드
브레이크포인트 1-2개 (히트 빈도 낮음) : ~5% 오버헤드
브레이크포인트 다수 + 조건부 : ~10-30% 오버헤드 (가변)
Evaluate Expression 빈번 실행 : 순간적으로 응답 지연 수 초
5-3. 세션 종료 후 원복 절차
디버깅이 끝났으면 반드시 환경을 원래 상태로 돌린다.
# 1. IDE에서 디버거 연결 해제 (Disconnect)
# 2. SSH 터널 종료
# 터미널에서 실행 중이면 Ctrl+C
# 백그라운드면:
lsof -i :5005 | grep ssh
kill <PID>
# 3. 디버그 JVM 옵션 제거 후 프로세스 재시작
# Kubernetes의 경우:
kubectl rollout restart deployment/myservice -n staging
# 또는 디버그 전용 Deployment 삭제:
kubectl delete -f deployment-debug.yaml
# 4. 디버그용 코드 제거 (debugpy.listen 등)
git diff # 실수로 커밋하지 않았는지 확인
# 5. port-forward 프로세스 종료 확인
lsof -i :5005
lsof -i :5678
lsof -i :9229
lsof -i :2345
5-4. 타임아웃과 연결 끊김 대응
| 상황 | 원인 | 대응 |
|---|---|---|
| SSH 터널 자동 종료 | 유휴 타임아웃 | ServerAliveInterval 60 설정 |
| IDE "Connection refused" | 포트 바인딩 안 됨 | 서버에서 ss -tlnp | grep 5005 확인 |
| 브레이크포인트 히트 안 됨 | 경로 매핑 오류 | localRoot/remoteRoot 재확인 |
| Pod 재시작 후 연결 끊김 | OOMKill / 헬스체크 실패 | Resource limits 상향, suspend=n 확인 |
| Delve "could not attach" | ptrace 권한 없음 | SYS_PTRACE capability 추가 |
# SSH 유휴 타임아웃 방지
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 \
-N -L 5005:127.0.0.1:5005 deploy@staging-server
# 연결 상태 확인
ss -tlnp | grep 5005
# 또는
netstat -tlnp | grep 5005
6. 원격 디버깅 세션 체크리스트
세션 전 (Before)
- 로컬에서 재현 시도를 먼저 했는가?
- 스테이징에서 재현 가능한가? (운영 디버깅은 최후 수단)
- 디버깅 대상 서비스의 소스 코드가 배포 버전과 일치하는가?
- 디버그 에이전트/debugpy/Delve가 서버에 설치되어 있는가?
- 디버그 포트가
127.0.0.1에만 바인딩되는가? - SSH 터널 또는 kubectl port-forward 명령이 준비되었는가?
- 운영 환경이라면 팀 리드/온콜 승인을 받았는가?
- IDE launch.json / Run Configuration에 경로 매핑이 설정되었는가?
세션 중 (During)
-
suspend=n으로 설정했는가? (운영 필수) - 브레이크포인트는 최소한으로 설정했는가?
- 조건부 브레이크포인트를 활용해 히트 빈도를 줄였는가?
- Evaluate Expression에서 상태 변경(write) 코드를 실행하지 않았는가?
- 디버깅 세션 시간을 제한했는가? (권장: 30분 이내)
- 다른 팀원에게 디버깅 진행 중임을 공유했는가?
세션 후 (After)
- IDE에서 디버거를 Disconnect 했는가?
- SSH 터널 / port-forward 프로세스를 종료했는가?
- 디버그 JVM 옵션/환경변수를 제거하고 프로세스를 원복했는가?
- 소스 코드에 삽입한
debugpy.listen()등을 제거했는가? - 디버그용 Deployment/Pod를 삭제했는가?
- 발견한 내용을 티켓/Slack에 기록했는가?
- 재발 방지를 위한 로그/메트릭 추가 계획을 세웠는가?
마무리
원격 디버깅은 "마지막 수단"이지만, 제대로 준비하면 가장 강력한 도구가 된다. 핵심을 요약하면:
- 보안 먼저: 디버그 포트는 localhost 바인딩 + SSH 터널이 원칙이다.
- 환경별 표준화: 언어별 디버그 시작 명령과 IDE attach 설정을 팀 wiki에 정리해 둔다.
- Kubernetes 친화적:
kubectl port-forward, Telepresence, Ephemeral Container를 상황에 맞게 선택한다. - 원복은 필수: 디버깅이 끝나면 에이전트 제거, 터널 종료, 코드 원복까지 확인한다.
로그와 메트릭만으로 해결되지 않는 문제가 왔을 때, 이 가이드의 체크리스트를 따라가면 안전하고 빠르게 원인을 찾을 수 있다.
원격 디버깅에서 찾은 문제의 근본 원인 분석은 언어×프레임워크 장애 사례집의 8가지 사례를 참고한다. IDE별 상세 설정은 IDE별 디버깅 완전정리를 참고한다.