디버깅
Node.js에서는 Chrome DevTools나 --inspect 플래그로 디버깅한다. 브라우저 기반 DevTools에 익숙하다면 Go의 디버깅 방식이 낯설 수 있다. Go에는 delve라는 전용 디버거가 있다. breakpoint, 변수 검사, step 실행은 물론 goroutine 상태까지 확인할 수 있다. Node.js 디버깅 경험과 비교하며 Go의 디버깅 도구를 다룬다.
delve 설치
delve는 Go 전용 디버거다. GDB도 Go를 지원하지만, goroutine이나 Go 런타임을 제대로 이해하지 못한다. delve는 Go를 위해 만들어졌다:
$ go install github.com/go-delve/delve/cmd/dlv@latest
$ dlv version
Delve Debugger
Version: 1.24.1
Node.js는 V8 inspector가 내장되어 있지만, Go는 delve를 별도로 설치해야 한다.
dlv debug — 기본 사용법
간단한 프로그램으로 시작한다:
// main.go
package main
import "fmt"
func fibonacci(n int) int {
if n <= 1 {
return n
}
return fibonacci(n-1) + fibonacci(n-2)
}
func main() {
for i := 0; i < 10; i++ {
fmt.Println(fibonacci(i))
}
}
dlv debug로 디버깅 세션을 시작한다:
$ dlv debug main.go
Type 'help' for list of commands.
(dlv)
Node.js의 node --inspect-brk main.js에 해당한다. --inspect-brk는 첫 줄에서 멈추고, dlv debug는 프로그램을 컴파일한 뒤 진입점에서 대기한다.
기본 명령어:
(dlv) break main.fibonacci # breakpoint 설정
Breakpoint 1 set at 0x104a2e0 for main.fibonacci() ./main.go:6
(dlv) continue # breakpoint까지 실행
> main.fibonacci() ./main.go:6 (hits goroutine(1):1 total:1)
5:
=> 6: func fibonacci(n int) int {
7: if n <= 1 {
(dlv) print n # 변수 값 확인
0
(dlv) next # 다음 줄로 이동 (step over)
(dlv) step # 함수 내부로 진입 (step in)
(dlv) stepout # 현재 함수에서 빠져나옴 (step out)
(dlv) continue # 다음 breakpoint까지 실행
(dlv) quit # 디버거 종료
Chrome DevTools의 버튼과 대응시키면:
| Chrome DevTools | delve | 단축키 |
|---|---|---|
| Resume (F8) | continue | c |
| Step over (F10) | next | n |
| Step into (F11) | step | s |
| Step out (Shift+F11) | stepout | so |
breakpoint 다루기
줄 번호로 breakpoint를 설정할 수 있다:
(dlv) break main.go:8 # 파일명:줄번호
Breakpoint 1 set at 0x104a2f0 for main.fibonacci() ./main.go:8
(dlv) breakpoints # 설정된 breakpoint 목록
Breakpoint 1 at 0x104a2f0 for main.fibonacci() ./main.go:8
(dlv) clear 1 # breakpoint 제거
Breakpoint 1 cleared at 0x104a2f0 for main.fibonacci() ./main.go:8
(dlv) clearall # 모든 breakpoint 제거
conditional breakpoint
특정 조건에서만 멈추도록 설정할 수 있다:
(dlv) break main.go:8
Breakpoint 1 set at 0x104a2f0 for main.fibonacci() ./main.go:8
(dlv) condition 1 n == 5 # n이 5일 때만 멈춤
(dlv) continue
> main.fibonacci() ./main.go:8 (hits goroutine(1):1 total:1)
(dlv) print n
5
Node.js에서 Chrome DevTools의 "Edit breakpoint" > "Add conditional breakpoint"와 같은 기능이다. 반복문이나 재귀 함수에서 특정 조건을 추적할 때 유용하다.
변수 검사
print(줄여서 p)로 변수를 확인한다:
(dlv) print n # 변수 값
5
(dlv) print n * 2 # 표현식 평가
10
(dlv) locals # 현재 스코프의 모든 지역 변수
n = 5
(dlv) args # 현재 함수의 인자
n = 5
(dlv) whatis n # 변수 타입
int
struct나 slice도 확인할 수 있다:
(dlv) print user
main.User {
ID: 1,
Name: "Alice",
Tags: []string len: 2, cap: 2, ["admin","editor"],
}
(dlv) print user.Name
"Alice"
(dlv) print user.Tags[0]
"admin"
Chrome DevTools의 Scope 패널에서 변수를 펼쳐보는 것과 같다. 차이점은 delve에서는 CLI로 직접 타이핑해야 한다는 것이다. IDE를 사용하면 이 차이가 사라진다.
IDE 연동
실제 개발에서는 IDE 연동이 훨씬 편하다.
VS Code
Go 확장(공식 golang.go)을 설치하면 delve가 자동으로 연동된다. .vscode/launch.json 설정:
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}"
}
]
}
이후 사용법은 Node.js 디버깅과 거의 동일하다:
- 에디터 왼쪽 여백을 클릭하여 breakpoint 설정
- F5로 디버깅 시작
- 변수 패널에서 값 확인
- call stack 패널에서 호출 스택 확인
- F10(step over), F11(step in), Shift+F11(step out)
launch.json의 type이 "node"에서 "go"로 바뀌는 것 정도가 차이다.
GoLand
GoLand(JetBrains)는 delve를 내장하고 있어 별도 설정이 필요 없다. 함수 옆의 실행 버튼에서 "Debug"를 선택하면 된다. breakpoint, 변수 검사, evaluate expression 등 모든 기능이 GUI로 제공된다.
테스트 디버깅
VS Code에서 테스트 함수 위에 나타나는 "debug test" 링크를 클릭하면 해당 테스트만 디버깅 모드로 실행된다. CLI에서는:
$ dlv test -- -run TestFibonacci
dlv test는 go test와 같은 방식으로 테스트를 컴파일하되, 디버거를 붙여서 실행한다. Node.js에서 Jest를 --inspect로 실행하는 것에 해당한다.
goroutine 디버깅
Go 디버깅에서 Node.js와 가장 크게 다른 부분이다. Node.js는 싱글 스레드이므로 한 시점에 하나의 실행 흐름만 추적하면 된다. Go는 수십, 수백 개의 goroutine이 동시에 실행될 수 있다.
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("worker %d start\n", id)
// 작업 수행
fmt.Printf("worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
}
delve에서 goroutine 관련 명령:
(dlv) goroutines # 모든 goroutine 목록
* Goroutine 1 - User: ./main.go:18 main.main (0x104a3a0)
Goroutine 2 - User: runtime/proc.go:400 runtime.gopark (0x103e5c0)
Goroutine 3 - User: ./main.go:10 main.worker (0x104a2e0)
Goroutine 4 - User: ./main.go:10 main.worker (0x104a2e0)
Goroutine 5 - User: ./main.go:10 main.worker (0x104a2e0)
(dlv) goroutine 3 # goroutine 3으로 전환
Switched from 1 to 3 (thread 12345)
(dlv) bt # 현재 goroutine의 스택 트레이스
0 0x104a2e0 in main.worker at ./main.go:10
1 0x103e700 in runtime.goexit at runtime/asm_arm64.s:1222
(dlv) goroutine 1 # 다시 goroutine 1로 전환
goroutine 간에 자유롭게 전환하면서 각각의 상태, 지역 변수, 스택 트레이스를 확인할 수 있다. VS Code에서도 CALL STACK 패널에 goroutine이 각각 표시된다.
deadlock 디버깅
goroutine이 서로를 기다리며 멈추는 deadlock은 Go에서 흔한 버그다. Go 런타임은 모든 goroutine이 블록되면 자동으로 감지한다:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/path/to/main.go:15 +0x68
goroutine 18 [chan send]:
main.worker()
/path/to/main.go:9 +0x30
하지만 일부 goroutine만 deadlock에 빠진 경우(나머지는 정상 동작)에는 런타임이 감지하지 못한다. 이때 delve로 프로그램을 일시 중단하고 goroutines로 각 goroutine의 상태를 확인하면 어떤 goroutine이 어디서 블록되어 있는지 파악할 수 있다.
fmt.Println 디버깅
현실적으로 가장 많이 쓰이는 디버깅 방법이다. Go 개발자들 사이에서도 fmt.Println을 코드 곳곳에 넣어 값을 확인하는 방식이 여전히 흔하다. Node.js에서 console.log를 쓰는 것과 같다:
func processOrder(order Order) error {
fmt.Printf("DEBUG order: %+v\n", order)
total := calculateTotal(order)
fmt.Printf("DEBUG total: %d\n", total)
err := validateOrder(order)
fmt.Printf("DEBUG validate err: %v\n", err)
return err
}
%+v는 struct의 필드명을 포함하여 출력한다. %v만 쓰면 필드명 없이 값만 나온다. 디버깅 시 %+v가 유용하다.
이 방식이 통하는 이유:
- 설정이 필요 없다. 디버거 설정, launch.json, 확장 설치 없이 바로 쓸 수 있다.
- 빠르다. breakpoint를 설정하고 step 실행하는 것보다 코드에 한 줄 넣고 실행하는 것이 빠를 때가 많다.
- 로그로 남는다. 출력이 터미널에 시간 순서대로 나열되므로 실행 흐름을 파악하기 쉽다.
하지만 한계도 분명하다:
- 커밋 전에 반드시 제거해야 한다. 디버그 출력이 프로덕션에 남으면 문제가 된다.
- 동시성 버그에 약하다. 출력문을 추가하면 타이밍이 바뀌어서 race condition이 사라질 수 있다.
- 복잡한 상태에 부적합하다. 중첩된 struct나 긴 slice를 출력하면 읽기 어렵다.
디버거가 필요한 상황은 명확하다. 동시성 버그, 재현이 어려운 간헐적 문제, 복잡한 상태 추적. 그 외에는 fmt.Println이 충분할 때가 많다. 다만 log 패키지를 쓰면 타임스탬프가 함께 출력되어 조금 더 유용하다:
log.Printf("DEBUG order: %+v", order)
// 2025/01/15 10:30:45 DEBUG order: {ID:1 Items:[...] Total:5000}
원격 디버깅
컨테이너나 원격 서버에서 실행 중인 프로그램을 디버깅할 때 사용한다:
# 원격 서버에서 headless 모드로 delve 실행
$ dlv debug --headless --listen=:2345 --api-version=2
# 로컬에서 접속
$ dlv connect localhost:2345
VS Code에서는 launch.json에 remote 설정을 추가한다:
{
"name": "Remote",
"type": "go",
"request": "attach",
"mode": "remote",
"remotePath": "/app",
"port": 2345,
"host": "127.0.0.1"
}
Node.js에서 node --inspect=0.0.0.0:9229로 원격 디버깅을 여는 것과 같은 패턴이다.
Node.js와 가장 큰 차이는 goroutine이다. 여러 goroutine이 동시에 실행되므로 각각의 상태를 개별적으로 추적해야 한다. delve는 이를 위해 만들어진 도구다. IDE와 연동하면 Node.js에서 VS Code로 디버깅하던 경험을 거의 그대로 가져올 수 있다.