본문으로 건너뛰기

실전 고수 팁 — 포인터와 메서드

리시버 타입 선택 기준

포인터 리시버와 값 리시버 선택은 Go 코드 품질에 직접 영향을 미칩니다. 아래 기준을 따르면 실수를 줄일 수 있습니다.

포인터 리시버를 사용해야 하는 경우

package main

import (
"fmt"
"sync"
)

// 1. 상태를 변경하는 메서드
type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(item T) { // 포인터 리시버: 상태 변경
s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) { // 포인터 리시버: 상태 변경
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}

func (s Stack[T]) Len() int { // 값 리시버: 읽기 전용
return len(s.items)
}

// 2. 대형 구조체 — 복사 비용 절감
type LargeData struct {
data [1024]byte // 1KB
meta [128]byte
}

func (d *LargeData) Process() string { // 복사 없이 포인터로 전달
return fmt.Sprintf("processed %d bytes", len(d.data))
}

// 3. 동기화 프리미티브 포함 구조체 — 반드시 포인터 리시버
type SafeCounter struct {
mu sync.Mutex // Mutex는 복사하면 안됨
count int
}

func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}

func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}

func main() {
s := &Stack[int]{}
s.Push(1)
s.Push(2)
s.Push(3)
fmt.Println("스택 크기:", s.Len())

for {
v, ok := s.Pop()
if !ok {
break
}
fmt.Println("Pop:", v)
}

sc := &SafeCounter{}
sc.Increment()
sc.Increment()
fmt.Println("카운터:", sc.Value())
}

값 리시버를 사용하는 경우

package main

import (
"fmt"
"math"
)

// 1. 작고 불변인 타입 — 복사 비용이 미미한 경우
type Vector2D struct {
X, Y float64
}

func (v Vector2D) Length() float64 { // 읽기 전용
return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func (v Vector2D) Add(other Vector2D) Vector2D { // 새 값 반환
return Vector2D{v.X + other.X, v.Y + other.Y}
}

func (v Vector2D) Scale(factor float64) Vector2D {
return Vector2D{v.X * factor, v.Y * factor}
}

// 2. 기본 타입 래퍼
type Celsius float64

func (c Celsius) ToFahrenheit() float64 {
return float64(c)*9/5 + 32
}

func main() {
v1 := Vector2D{3, 4}
v2 := Vector2D{1, 2}

fmt.Println("길이:", v1.Length()) // 5
fmt.Println("합:", v1.Add(v2)) // {4 6}
fmt.Println("스케일:", v1.Scale(2)) // {6 8}

// 값 리시버는 메서드 체이닝 시 불변성을 보장
result := v1.Scale(2).Add(v2).Scale(0.5)
fmt.Println("체이닝 결과:", result)
}

포인터 연산과 GC

package main

import (
"fmt"
"runtime"
)

// Go의 escape analysis: 컴파일러가 자동으로 스택/힙 결정
func stackAlloc() int {
x := 42 // 포인터 없이 사용 → 스택 할당
return x
}

func heapAlloc() *int {
x := 42 // 포인터로 반환 → 힙 할당 (escape)
return &x
}

// GC 압력을 줄이는 패턴: 구조체 재사용
type Request struct {
Method string
Path string
Body []byte
}

var requestPool = make(chan *Request, 10)

func getRequest() *Request {
select {
case r := <-requestPool:
// 풀에서 재사용
return r
default:
return &Request{}
}
}

func recycleRequest(r *Request) {
r.Method = ""
r.Path = ""
r.Body = r.Body[:0]
select {
case requestPool <- r:
default:
// 풀이 가득 찬 경우 GC가 처리하도록 방치
}
}

func processRequest(r *Request) string {
return fmt.Sprintf("%s %s (%d bytes)", r.Method, r.Path, len(r.Body))
}

func printMemStats(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("[%s] Alloc: %d KB, NumGC: %d\n",
label, m.Alloc/1024, m.NumGC)
}

func main() {
printMemStats("시작")

// 풀을 사용한 요청 처리
for i := 0; i < 100; i++ {
r := getRequest()
r.Method = "GET"
r.Path = "/api/users"
r.Body = append(r.Body, []byte("body data")...)

result := processRequest(r)
_ = result

recycleRequest(r)
}

runtime.GC()
printMemStats("100회 요청 후")
}

인터페이스와 포인터의 함정

package main

import "fmt"

type Animal interface {
Sound() string
Name() string
}

type Dog struct {
name string
}

// 포인터 리시버로 인터페이스 구현
func (d *Dog) Sound() string { return "멍멍" }
func (d *Dog) Name() string { return d.name }

type Cat struct {
name string
}

// 값 리시버로 인터페이스 구현
func (c Cat) Sound() string { return "야옹" }
func (c Cat) Name() string { return c.name }

func describe(a Animal) {
fmt.Printf("%s이(가) %s라고 울어요\n", a.Name(), a.Sound())
}

func main() {
// 포인터 리시버: *Dog만 인터페이스 구현
dog := &Dog{name: "바둑이"} // 반드시 포인터
describe(dog)

// 값 리시버: Cat과 *Cat 모두 인터페이스 구현
cat1 := Cat{name: "나비"}
cat2 := &Cat{name: "콩이"}
describe(cat1) // OK
describe(cat2) // OK — Go가 자동으로 역참조

// 인터페이스 nil 함정
var a Animal
fmt.Println(a == nil) // true

var d *Dog = nil
a = d // *Dog 타입이지만 값이 nil
fmt.Println(a == nil) // false! — 인터페이스가 (*Dog, nil)을 보유
// a.Sound() // 패닉!

// 올바른 nil 체크
if d != nil {
a = d
}
}

함수형 옵션 패턴 — 실전 Best Practices

package main

import (
"errors"
"fmt"
"time"
)

type HTTPClient struct {
baseURL string
timeout time.Duration
maxRetries int
headers map[string]string
rateLimiter *time.Ticker
}

type ClientOption func(*HTTPClient) error

// 옵션 함수는 패키지 수준에서 제공 — 사용자가 직접 struct 건드리지 않음
func WithBaseURL(url string) ClientOption {
return func(c *HTTPClient) error {
if url == "" {
return errors.New("baseURL은 비어있을 수 없습니다")
}
c.baseURL = url
return nil
}
}

func WithClientTimeout(d time.Duration) ClientOption {
return func(c *HTTPClient) error {
if d <= 0 {
return errors.New("타임아웃은 양수여야 합니다")
}
c.timeout = d
return nil
}
}

func WithMaxRetries(n int) ClientOption {
return func(c *HTTPClient) error {
if n < 0 {
return errors.New("재시도 횟수는 0 이상이어야 합니다")
}
c.maxRetries = n
return nil
}
}

func WithHeader(key, value string) ClientOption {
return func(c *HTTPClient) error {
c.headers[key] = value
return nil
}
}

func WithRateLimit(requestsPerSecond int) ClientOption {
return func(c *HTTPClient) error {
if requestsPerSecond <= 0 {
return errors.New("초당 요청 수는 양수여야 합니다")
}
interval := time.Second / time.Duration(requestsPerSecond)
c.rateLimiter = time.NewTicker(interval)
return nil
}
}

func NewHTTPClient(opts ...ClientOption) (*HTTPClient, error) {
c := &HTTPClient{
timeout: 30 * time.Second,
maxRetries: 3,
headers: make(map[string]string),
}
c.headers["Content-Type"] = "application/json"

for _, opt := range opts {
if err := opt(c); err != nil {
return nil, fmt.Errorf("클라이언트 설정 오류: %w", err)
}
}

if c.baseURL == "" {
return nil, errors.New("baseURL은 필수입니다")
}

return c, nil
}

func (c *HTTPClient) Get(path string) string {
if c.rateLimiter != nil {
<-c.rateLimiter.C // 속도 제한
}
return fmt.Sprintf("GET %s%s (timeout: %v, retries: %d)",
c.baseURL, path, c.timeout, c.maxRetries)
}

func main() {
client, err := NewHTTPClient(
WithBaseURL("https://api.example.com"),
WithClientTimeout(10*time.Second),
WithMaxRetries(5),
WithHeader("Authorization", "Bearer token123"),
WithHeader("X-API-Version", "2"),
)
if err != nil {
fmt.Println("에러:", err)
return
}

fmt.Println(client.Get("/users"))

// 필수 옵션 누락
_, err = NewHTTPClient(
WithClientTimeout(5 * time.Second),
)
fmt.Println("에러:", err)
}

포인터 사용 시 흔한 실수와 해결책

package main

import "fmt"

// 실수 1: 루프 변수 포인터
func wrongLoop() []*int {
result := make([]*int, 3)
for i := 0; i < 3; i++ {
result[i] = &i // 모두 같은 i를 가리킴!
}
return result
}

func correctLoop() []*int {
result := make([]*int, 3)
for i := 0; i < 3; i++ {
i := i // 새 변수 i를 선언해 루프 변수 캡처 방지
result[i] = &i
}
return result
}

// 실수 2: nil 포인터 역참조
type Node struct {
Val int
Next *Node
}

func safeNext(n *Node) *Node {
if n == nil {
return nil
}
return n.Next
}

// 실수 3: 값을 복사하면 포인터가 공유됨
type Config struct {
Options map[string]string // 맵은 참조 타입!
}

func deepCopy(c Config) Config {
newCfg := Config{
Options: make(map[string]string, len(c.Options)),
}
for k, v := range c.Options {
newCfg.Options[k] = v
}
return newCfg
}

func main() {
// 실수 1 시연
wrong := wrongLoop()
correct := correctLoop()
fmt.Print("잘못된 루프: ")
for _, p := range wrong {
fmt.Print(*p, " ") // 3 3 3 (모두 같음)
}
fmt.Println()

fmt.Print("올바른 루프: ")
for _, p := range correct {
fmt.Print(*p, " ") // 0 1 2
}
fmt.Println()

// 실수 3: 얕은 복사 함정
cfg1 := Config{Options: map[string]string{"key": "value"}}
cfg2 := cfg1 // 얕은 복사 — Options 맵 공유
cfg2.Options["key"] = "changed"
fmt.Println("얕은 복사 후 cfg1:", cfg1.Options["key"]) // changed!

cfg3 := Config{Options: map[string]string{"key": "value"}}
cfg4 := deepCopy(cfg3) // 깊은 복사
cfg4.Options["key"] = "changed"
fmt.Println("깊은 복사 후 cfg3:", cfg3.Options["key"]) // value (영향 없음)
}

성능: 포인터 vs 값 전달 벤치마크 결론

현업에서 바로 쓸 수 있는 판단 기준입니다.

구조체 크기   | 권장
-------------|-------
< 64 바이트 | 값 전달 (포인터 오버헤드 없음)
64~256 바이트| 케이스 바이 케이스
> 256 바이트 | 포인터 전달
package main

import "fmt"

// 작은 구조체: 값 전달이 더 빠를 수 있음
type SmallPoint struct {
X, Y float64 // 16 bytes
}

func processSmall(p SmallPoint) float64 { // 값 전달
return p.X + p.Y
}

// 큰 구조체: 포인터 전달이 효율적
type LargeConfig struct {
Fields [100]string // 매우 큰 구조체
}

func processLarge(c *LargeConfig) int { // 포인터 전달
return len(c.Fields)
}

func main() {
sp := SmallPoint{1.0, 2.0}
fmt.Println(processSmall(sp))

lc := &LargeConfig{}
fmt.Println(processLarge(lc))

// 실무 팁: 의심스러우면 포인터를 쓰되,
// pprof로 실제 병목을 확인 후 최적화
fmt.Println("핵심: 먼저 정확하게, 그 다음 빠르게")
}

핵심 규칙

  1. 상태 변경 메서드 → 포인터 리시버
  2. Mutex 등 동기화 포함 구조체 → 포인터 리시버 필수
  3. 한 타입의 모든 메서드를 같은 리시버 타입으로 통일
  4. 인터페이스 구현 시 메서드 셋 규칙 숙지 (T는 값 리시버만, *T는 모두)
  5. 루프 변수 포인터 저장 시 반드시 새 변수 캡처
  6. 맵/슬라이스를 포함한 구조체 복사 시 깊은 복사 필요
  7. nil 인터페이스 vs nil 포인터를 혼동하지 말 것