고 고급 #5 unsafe와 cgo — 안전 영역 밖으로

5 분 소요

#4 reflect에서 런타임에 타입을 다루는 도구를 봤다면 — 이번엔 타입 시스템 바깥으로 한 발 나가는 두 도구. unsafecgo.

이 글의 목적은 — 둘이 존재하는 이유언제 등장하는지를 파악하는 데 있습니다. 직접 쓸 일은 거의 없지만, 라이브러리 안쪽에서 가끔 보입니다.

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.Pointeruintptr #

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+) #

포인터에서 slice 만들기
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 같은 일부 구현)
byte→string 무복사
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 컴파일러 빌드 시스템의 일부입니다.

cgo 기본
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로 변환해서 쓰입니다. 두 도구가 종종 같이 등장.

cgo + unsafe
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를 검토하기 전에 — 다음을 먼저 확인:

  1. 순수 Go 라이브러리가 있는가? → 있으면 그것
  2. 표준 라이브러리로 풀리는가? → 풀리면 그것
  3. 네트워크 IPC로 분리 가능한가? → 가능하면 별도 프로세스
  4. 그래도 안 되면 → 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까지 정리합니다.

X