2025-12-18
**포인터(Pointer)**는 프로그래밍에서 메모리 주소를 직접 다루는 강력한 도구입니다. Go 언어의 포인터는 C 언어처럼 명시적이지만, 포인터 연산을 금지하고 **가비지 컬렉션(GC)**과 결합함으로써 안전성과 성능의 균형을 이룹니다.
이 글에서는 포인터의 기본 문법을 넘어, 값 복사(Value Copy)와 포인터 전달(Reference Passing)의 성능 차이, nil 포인터로 인한 Panic 방지 패턴, 그리고 Go 컴파일러의 Escape Analysis가 메모리 할당을 결정하는 원리까지 실무적으로 다룹니다.
포인터는 값 자체가 아닌, 값이 저장된 메모리 주소를 저장하는 변수입니다. Go에서 포인터는 *T 타입으로 선언하며, 다음 두 연산자를 사용합니다.
| 연산자 | 이름 | 역할 |
|---|---|---|
& | Address-of | 변수의 메모리 주소를 반환 |
* | Dereference | 포인터가 가리키는 주소의 실제 값을 반환 |
var a int = 42
var p *int = &a // p는 a의 메모리 주소를 저장
fmt.Println(p) // 출력: 0xc0000140a0 (메모리 주소)
fmt.Println(*p) // 출력: 42 (포인터가 가리키는 실제 값)
nil포인터를 선언만 하고 초기화하지 않으면 nil 상태가 됩니다.
var p *int
fmt.Println(p == nil) // 출력: true
nil 포인터를 역참조(*p)하려고 하면 **런타임 패닉(Panic)**이 발생합니다.
var p *int
// *p = 10 // 🔴 Panic: invalid memory address or nil pointer dereference
Go 언어에서 함수 호출 시 기본적으로 **값 복사(Pass by Value)**가 일어납니다. 이는 원본 데이터를 보호하지만, 큰 데이터 구조체를 다룰 때는 성능 문제를 야기할 수 있습니다.
func updateValue(val int) {
val = 100 // 복사본을 수정
}
func main() {
x := 50
updateValue(x)
fmt.Println(x) // 출력: 50 (원본은 변경 안 됨)
}
함수 내부의 val은 x의 복사본이므로, 수정해도 원본 x에는 영향을 주지 않습니다.
func updateValue(val *int) {
*val = 100 // 포인터가 가리키는 원본을 수정
}
func main() {
x := 50
updateValue(&x)
fmt.Println(x) // 출력: 100 (원본이 변경됨)
}
포인터를 전달하면 함수가 원본 메모리 주소에 직접 접근하여 값을 변경할 수 있습니다.
구조체가 크면 값 복사 비용이 커집니다.
type LargeStruct struct {
Data [1000]int
}
// ❌ 값 복사: 구조체 전체(8000 바이트)를 복사
func processByValue(s LargeStruct) {
// ...
}
// ✅ 포인터 전달: 주소(8 바이트)만 복사
func processByPointer(s *LargeStruct) {
// ...
}
🛠️ 실무 Best Practice
- 작은 값 타입(int, bool 등): 값 복사 사용 (간결하고 안전)
- 큰 구조체나 배열: 포인터 전달 (성능 최적화)
- 함수가 값을 수정해야 할 때: 포인터 필수
- 읽기 전용이지만 큰 데이터: 포인터 전달 후 수정 금지 규칙 적용
C 언어에서는 포인터에 정수를 더하거나 빼서 메모리를 순회할 수 있습니다. Go는 이를 완전히 금지합니다.
// C 언어
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;
p++; // ✅ 허용: 다음 원소를 가리킴
// Go 언어
arr := [5]int{10, 20, 30, 40, 50}
p := &arr[0]
// p++ // ❌ 컴파일 에러: invalid operation
대신 Go는 Slice를 통해 안전하게 배열을 순회합니다.
arr := []int{10, 20, 30, 40, 50}
for i, v := range arr {
fmt.Printf("arr[%d] = %d\n", i, v)
}
nil 포인터 역참조는 Go에서 가장 흔한 런타임 에러입니다.
func printValue(p *int) {
// ❌ 위험: nil 체크 없이 역참조
// fmt.Println(*p)
// ✅ 안전: nil 체크 후 역참조
if p != nil {
fmt.Println(*p)
} else {
fmt.Println("포인터가 nil입니다.")
}
}
func main() {
var p *int // nil 포인터
printValue(p) // 출력: 포인터가 nil입니다.
x := 42
printValue(&x) // 출력: 42
}
포인터는 “값이 있을 수도, 없을 수도 있다”는 옵셔널(Optional) 개념을 표현하는 데 사용됩니다.
type User struct {
Name string
Email *string // 이메일은 선택 사항 (nil 가능)
}
func main() {
user1 := User{Name: "Alice"} // Email은 nil
email := "bob@example.com"
user2 := User{Name: "Bob", Email: &email}
// 이메일 출력 함수
printEmail := func(u User) {
if u.Email != nil {
fmt.Println("Email:", *u.Email)
} else {
fmt.Println("Email: (없음)")
}
}
printEmail(user1) // 출력: Email: (없음)
printEmail(user2) // 출력: Email: bob@example.com
}
new() 함수: 포인터를 반환하는 메모리 할당new(T) 함수는 타입 T의 Zero Value로 초기화된 메모리를 힙에 할당하고, 그 포인터를 반환합니다.
p := new(int)
fmt.Println(p) // 출력: 0xc0000140a0 (메모리 주소)
fmt.Println(*p) // 출력: 0 (int의 Zero Value)
*p = 42
fmt.Println(*p) // 출력: 42
new()와 리터럴의 차이// 방법 1: new() 사용
p1 := new(int)
*p1 = 42
// 방법 2: 변수 선언 후 주소 얻기 (더 일반적)
x := 42
p2 := &x
대부분의 경우 방법 2가 더 직관적이고 Go스럽습니다. new()는 주로 구조체를 힙에 할당할 때 사용됩니다.
type Point struct {
X, Y int
}
p := new(Point) // *Point 타입, Zero Value로 초기화
p.X = 10
p.Y = 20
Go의 가비지 컬렉터(GC)는 더 이상 참조되지 않는 메모리를 자동으로 해제합니다. 그러나 포인터가 계속 살아있으면 GC가 메모리를 회수할 수 없습니다.
type Node struct {
Data int
Next *Node
}
var head *Node
func addNode(data int) {
newNode := &Node{Data: data}
newNode.Next = head
head = newNode
}
func main() {
for i := 0; i < 1000000; i++ {
addNode(i) // 백만 개의 노드가 메모리에 계속 쌓임
}
// head 포인터가 모든 노드를 참조하므로 GC가 회수 불가
}
nil 할당// 연결 리스트를 모두 해제
head = nil // 이제 GC가 모든 노드를 회수 가능
🛠️ 실무 Best Practice
- 긴 수명 객체(캐시, 싱글톤 등): 사용 완료 후 명시적으로
nil할당- 순환 참조: 명시적으로 참조를 끊어야 GC가 회수 가능
- defer를 활용한 리소스 해제:
defer ptr = nil로 함수 종료 시 자동 해제
Go 컴파일러는 Escape Analysis를 수행하여, 변수를 스택에 할당할지 힙에 할당할지 자동으로 결정합니다.
함수 내부에서만 사용되는 변수는 스택에 할당됩니다. 함수가 종료되면 즉시 메모리가 해제되므로 매우 빠릅니다.
func localVariable() {
x := 42 // 스택에 할당
fmt.Println(x)
} // 함수 종료 시 x는 자동으로 해제됨
함수 외부로 포인터를 반환하면, 해당 변수는 힙으로 **Escape(탈출)**합니다.
func createPointer() *int {
x := 42 // 함수 내부 변수지만...
return &x // 포인터를 반환하므로 x는 힙으로 Escape
}
func main() {
p := createPointer()
fmt.Println(*p) // 출력: 42
}
컴파일러는 x가 함수 밖에서도 사용된다는 것을 감지하고, 힙에 할당하여 GC가 관리하도록 합니다.
go build -gcflags="-m" your_file.go
출력 예시:
./your_file.go:2:6: moved to heap: x
💡 성능 최적화 팁
- 불필요한 포인터 반환 회피: 가능하면 값 복사를 사용하여 스택 할당 유도
- 큰 구조체는 어차피 힙: 포인터 전달이 더 효율적
- 프로파일링으로 확인:
go tool pprof로 힙 할당 비용 측정
type Counter struct {
Count int
}
func (c *Counter) Increment() { // 포인터 리시버
c.Count++
}
func main() {
c := Counter{}
c.Increment()
fmt.Println(c.Count) // 출력: 1
}
type Config struct {
Port int
Timeout *int // nil이면 기본값 사용
}
func (c *Config) GetTimeout() int {
if c.Timeout != nil {
return *c.Timeout
}
return 30 // 기본값
}
func findUser(id int) (*User, error) {
if id <= 0 {
return nil, errors.New("invalid ID")
}
// 사용자 조회 로직...
return &User{Name: "Alice"}, nil
}
Go 언어의 포인터는 C의 강력함과 메모리 안전성의 균형을 이룬 설계입니다. 포인터 연산을 금지하고 가비지 컬렉션과 결합함으로써, 개발자는 저수준 메모리 제어의 이점을 누리면서도 메모리 안전성을 확보할 수 있습니다.
오늘 배운 핵심을 요약하면:
&로 얻고 *로 역참조한다.nil 할당으로 메모리 누수 방지.다음 글에서는 포인터 개념을 확장하여 Go 언어의 **구조체(Struct)**와 **메서드(Method)**를 다루며, 포인터 리시버와 값 리시버의 차이를 실무적으로 분석하겠습니다.