고 고급 #5 unsafe와 cgo — 안전 영역 밖으로
#4 reflect에서 런타임에 타입을 다루는 도구를 봤다면 — 이번엔 타입 시스템 바깥으로 한 발 나가는 두 도구. unsafe와 cgo.
이 글의 목적은 — 둘이 존재하는 이유와 언제 등장하는지를 파악하는 데 있습니다. 직접 쓸 일은 거의 없지만, 라이브러리 안쪽에서 가끔 보입니다.
unsafe.Pointer — Go의 void* #
Go는 일반적으로 — 포인터 산술이나 임의의 타입 변환을 막습니다. *int를 *float64로 바꿀 수 없고, 포인터에 1을 더해 다음 메모리로 갈 수도 없습니다.
unsafe.Pointer는 — 그 안전 장치를 우회하는 도구.
import "unsafe"
x := int32(42)
p := unsafe.Pointer(&x) // *int32 → unsafe.Pointer
y := (*float32)(p) // unsafe.Pointer → *float32
fmt.Println(*y) // 32-bit 메모리를 float32로 해석C의 void*와 비슷한 역할입니다. 타입 정보 없이 포인터만.
unsafe의 네 가지 규칙 #
Go가 공식적으로 유효하다고 보장하는 변환은 4가지 패턴:
1) *T1 ↔ *T2 변환
#
var x int64 = 0x0102030405060708
p := (*[8]byte)(unsafe.Pointer(&x)) // int64 메모리를 byte 배열로같은 메모리 영역을 다른 타입으로 해석합니다. 단, 정렬과 크기가 호환돼야 합니다.
2) unsafe.Pointer ↔ uintptr
#
addr := uintptr(unsafe.Pointer(&x))
fmt.Printf("%x\n", addr)주소를 정수로. 단 — uintptr은 GC가 따라가지 않으므로 잠깐만 사용. 오래 들고 있으면 객체가 GC 되어 dangling.
3) struct 필드 주소 — unsafe.Offsetof
#
type S struct {
A int32
B int32
}
s := S{}
addrA := unsafe.Pointer(&s)
addrB := unsafe.Pointer(uintptr(addrA) + unsafe.Offsetof(s.B))거의 — (&s).B와 같은 결과지만, 동적인 경우(reflect와 비슷)에서 쓰입니다.
4) unsafe.Slice / unsafe.String (Go 1.17+, 1.20+)
#
arr := [5]int{1, 2, 3, 4, 5}
s := unsafe.Slice(&arr[0], 5) // []int{1,2,3,4,5}
bytes := []byte("hello")
str := unsafe.String(&bytes[0], len(bytes)) // 복사 없이 string으로 해석C 인터페이스나 zero-copy 변환에 어울립니다.
unsafe가 정당한 경우 #
- byte slice ↔ string 변환 (GC 비용을 피하고 싶을 때)
- struct의 메모리 레이아웃을 조사하는 디버깅 도구
- C와 데이터를 주고받을 때 (cgo와 함께)
- 매우 핫한 경우에 — reflect보다 빠른 직렬화 라이브러리 (예:
easyjson같은 일부 구현)
func bytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}string이 immutable 인 점을 우회 — 위험하지만 매우 빠름. 라이브러리 안쪽에서만, 그것도 베타테스트 충분히 한 코드에서만 보이는 패턴입니다.
unsafe가 깨지는 경우 #
// uintptr만 들고 있고 unsafe.Pointer는 잃음
addr := uintptr(unsafe.Pointer(p))
runtime.GC() // p가 GC 될 수 있음
ptr := unsafe.Pointer(addr) // ✗ 이미 죽은 주소일 수 있음uintptr은 숫자로 취급돼 GC 추적 안 됨. 항상 unsafe.Pointer 형태로 들고 있어야 안전.
go vet -unsafeptr — 검사 도구
#
go vet이 — 위 네 패턴에 해당하지 않는 unsafe 사용을 일부 감지해 줍니다. unsafe를 쓴다면 항상 vet 통과를 확인.
go vet ./...cgo — Go와 C의 다리 #
Go 코드에서 C 함수를 부를 수 있게 해 주는 도구. 표준 패키지가 아니라 — Go 컴파일러 빌드 시스템의 일부입니다.
package main
/*
#include <stdio.h>
void greet() {
printf("Hello from C\n");
}
*/
import "C"
func main() {
C.greet()
}핵심 두 가지:
- 주석 안에 C 코드 — 컴파일러가 C 컴파일러로 빌드
import "C"— 매직 임포트, 다른 import와 같은 줄에 둘 수 없음
데이터 주고받기 #
import "C"
func cstring(s string) *C.char {
return C.CString(s) // Go string → C string (복사)
}
func gostring(p *C.char) string {
return C.GoString(p) // C string → Go string (복사)
}경계를 통과할 때는 거의 항상 — 메모리 복사가 일어납니다. Go GC와 C의 메모리 관리는 분리돼 있어, 같은 메모리를 안전하게 공유하기 어렵습니다.
cgo의 비용 #
호출 한 번에 — 보통 수백 나노초의 오버헤드가 발생합니다.
- goroutine 스케줄러 잠시 떠남 — C 함수가 끝날 때까지 OS 스레드를 점유
- 메모리 복사 — 문자열, 슬라이스 모두
- 빌드 환경 복잡 — C 컴파일러 필요, 크로스 컴파일 어려움
- race detector / pprof와 잘 안 어울림
일반 Go 함수 호출: ~1 ns
cgo 호출: ~100-300 ns핫 루프에서 cgo를 쓰면 — Go의 런타임 스케줄링이 망가집니다. 큰 작업을 한 번에 cgo로 넘기는 식이 좋습니다(루프를 C 안쪽에).
cgo가 어울리는 경우 #
- 기존 C 라이브러리 사용이 정말 필요할 때 (예: 특정 SDK, native binding)
- OS 시스템 콜 직접 사용
- 성능이 결정적이고 — 순수 Go 구현이 없는 경우
대부분의 경우는 — 순수 Go 구현이나 네트워크 IPC가 더 깔끔합니다. cgo는 정말 다른 길이 없을 때 마지막 선택지입니다.
cgo 사례 — SQLite #
mattn/go-sqlite3 — Go의 가장 유명한 cgo 의존 라이브러리. SQLite는 C 라이브러리고, Go로 다시 짜는 비용이 너무 커서 cgo로 묶었습니다.
대안으로 — 순수 Go 구현(modernc.org/sqlite)도 있는데, 자동 변환된 코드라 호환성 trade-off 있음.
cgo의 GOEXPERIMENT — purego
#
최근에는 — cgo 없이 동적 라이브러리를 부르는 길도 생기고 있습니다(purego 같은 라이브러리). 다만 모든 케이스를 커버하진 못해서 — 여전히 cgo가 표준입니다.
unsafe와 cgo의 겹침 #
cgo로 받은 *C.char 같은 포인터는 — 보통 unsafe.Pointer로 변환해서 쓰입니다. 두 도구가 종종 같이 등장.
buf := C.malloc(C.size_t(100))
defer C.free(buf)
slice := unsafe.Slice((*byte)(buf), 100) // C 메모리를 Go slice로 본다C의 malloc/free는 — Go GC가 모르는 메모리. 명시적으로 free 해야.
“안 쓰는 게 정답” 인 이유 #
| 비용 | 영향 |
|---|---|
| 빌드 환경 복잡 | 크로스 컴파일이 어려워짐, CI가 무거워짐 |
| 디버깅 어려움 | race detector, pprof가 정상 동작 안 함 |
| 메모리 안전성 | Go의 큰 장점인 GC 안전성을 잃음 |
| 가독성 | 다른 사람이 읽기 어려움 |
“cgo is not Go.” — Go proverbs
표준 라이브러리도 — cgo 의존이 있는 부분(예: 일부 시스템 콜)을 순수 Go로 다시 쓰는 작업을 진행 중입니다. Go 팀의 방향성도 cgo 회피.
결정 트리 #
unsafe / cgo를 검토하기 전에 — 다음을 먼저 확인:
- 순수 Go 라이브러리가 있는가? → 있으면 그것
- 표준 라이브러리로 풀리는가? → 풀리면 그것
- 네트워크 IPC로 분리 가능한가? → 가능하면 별도 프로세스
- 그래도 안 되면 → cgo (또는 unsafe)
마무리 #
이번 글에서 정리한 내용:
unsafe.Pointer— 타입 시스템 우회, C의void*와 유사- 네 가지 정당한 패턴 — 같은 크기/정렬 변환, uintptr 일시 변환, Offsetof, Slice/String
uintptr은 GC 추적 안 됨 — 항상unsafe.Pointer로 들고- cgo — Go에서 C 함수 부르기, 호출당 수백 ns
- 빌드 환경 복잡, 디버깅 어려움 — 회피가 기본
- 결정 트리 — 순수 Go → 표준 → IPC → 그 다음에 cgo
다음 글(#6 프로파일링)에서는 — Go의 표준 도구로 성능을 측정하고 개선하는 법. pprof와 benchmark, race detector까지 정리합니다.