26장. 대용량 데이터와 메모리 효율
지금까지는 “동작하는 코드” 를 목표로 했다. 이번 장부터는 한 단계 더 나아간다. “같은 일을 더 적은 메모리로, 더 빠르게 하는 방법” 을 알아본다.
목표:
- 슬라이스가 메모리에서 어떻게 동작하는지 이해하기
- 값 전달과 포인터 전달을 적절히 고르기
- 큰 데이터를 한 번에 메모리에 다 올리지 않는 패턴 익히기
- 자주 쓰는 최적화 기법을 손에 익히기
testing.B와pprof로 성능을 직접 재 보기
26.1 슬라이스의 메모리 동작 깊이 이해
11장에서 슬라이스를 처음 배웠다. 여기서는 “그 슬라이스가 메모리에서 실제로 어떤 일을 하는지” 를 들여다본다.
슬라이스는 배열을 가리키는 “창문”
슬라이스는 그 자체로 데이터를 들고 있지 않다. 실제 데이터는 따로 있는 배열에 들어 있고, 슬라이스는 그 배열의 일부를 보는 창문이다.
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4] // s 는 arr 의 일부를 본다
슬라이스 내부에는 세 가지 정보가 있다.
| 필드 | 의미 |
|---|---|
| 포인터 | 어느 배열의 어디부터 보는지 |
| 길이 (len) | 지금 보고 있는 원소 개수 |
| 용량 (cap) | 가리키는 배열의 남은 자리 포함 크기 |
이 세 값만 들고 다닌다. 그래서 슬라이스 자체는 매우 가볍다.
append 가 일으키는 재할당
append 는 슬라이스 끝에 원소를 붙인다.
하지만 그 동작이 늘 단순하지는 않다.
- 용량(cap)에 여유가 있으면 같은 배열 끝에 그대로 적어 넣는다
- 용량이 꽉 찼다면 더 큰 새 배열을 만들고 전부 복사한 뒤 거기에 추가한다
이때의 복사가 곧 재할당 비용이다. 크기가 커질수록 무시 못 할 수준이 된다.
growth 전략
새 배열을 만들 때 얼마나 크게 잡을까. Go 의 대략적인 규칙은 이렇다.
- 작은 슬라이스는 약 2배씩 키운다
- 어느 정도 커지면 1.25배 같은 작은 비율로 키운다
정확한 수치는 버전마다 다르다. 중요한 건 “한 번에 적당히 크게 잡아서 재할당 횟수를 줄인다” 는 점이다.
cap 을 미리 잡기
크기를 미리 알고 있다면
make 에 용량을 지정해 주는 게 좋다.
// 나쁜 예: cap 이 계속 모자라 재할당이 반복된다
s := []int{}
for i := 0; i < 10000; i++ {
s = append(s, i)
}
// 좋은 예: 한 번에 충분히 잡아 둔다
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i)
}
같은 코드처럼 보이지만 재할당 횟수와 복사 비용이 크게 다르다.
부분 슬라이스가 거대한 배열을 붙잡는 함정
슬라이스는 원본 배열을 참조한다. 그래서 작은 부분만 뽑아 써도 배열 전체가 메모리에 남아 있다.
func firstFew(data []byte) []byte {
return data[:10] // 앞 10바이트만 반환
}
10바이트만 반환했다고 안심하면 안 된다.
반환된 슬라이스가 살아 있는 동안
원본 data 전체가 GC 되지 않는다.
수백 MB 짜리 파일에서 앞 10바이트만 들고 있어도 그 파일 전체가 메모리에 남는 셈이다.
copy 로 메모리 끊어내기
원본을 놓아주려면 새 슬라이스에 복사해 둔다.
func firstFew(data []byte) []byte {
out := make([]byte, 10)
copy(out, data[:10])
return out
}
이제 out 은 자기만의 작은 배열을 갖는다.
원본 data 는 GC 대상이 된다.
26.2 값 전달 vs 포인터 전달
14장에서 포인터를 배웠다. “큰 구조체는 포인터로 넘기는 게 좋다” 고 대충 이야기했었다. 여기서는 그 “좋다” 의 진짜 의미를 본다.
큰 구조체 복사 비용
값으로 넘기면 구조체 전체가 복사된다. 필드가 적을 때는 무시할 수준이지만 큰 구조체에서는 다르다.
type Big struct {
data [1 << 14]byte // 약 16KB
}
func process(b Big) { ... } // 호출마다 16KB 복사
만 번 호출하면 160MB 어치의 복사가 일어난다.
포인터 전달이 항상 좋은 건 아닌 이유
“그럼 늘 포인터로 넘기면 되겠네?” 라고 생각할 수 있다. 사실은 그렇지 않다.
포인터를 넘기는 순간 컴파일러는 그 데이터가 함수 바깥에서도 참조될 수 있다고 판단한다. 그러면 그 값은 힙(heap) 에 할당된다.
힙 할당은 다음과 같은 비용을 낳는다.
- 할당 자체가 스택 할당보다 느리다
- GC 가 추적해야 할 객체가 늘어난다
- 자주 일어나면 GC 부담이 커진다
스택과 힙, escape analysis
Go 는 컴파일 시점에 “이 값이 함수 바깥으로 새 나가는가” 를 분석한다. 이를 escape analysis 라고 부른다.
- 안 새 나가면 → 스택에 둔다 (싸다, GC 무관)
- 새 나가면 → 힙으로 올린다 (할당 비용 + GC)
같은 코드라도 함수 바깥으로 포인터를 반환하면 힙으로 가고, 함수 안에서만 쓰면 스택에 머문다.
이 분석은 자동이라 우리가 직접 할 일은 없다. 다만 “포인터를 자주 넘기면 GC 부담이 늘 수 있다” 는 감각은 갖고 있는 게 좋다.
언제 값, 언제 포인터
대략의 직관 가이드.
| 상황 | 권장 |
|---|---|
| 작은 구조체 (필드 몇 개) | 값 |
| 큰 구조체 | 포인터 |
| 함수 안에서 값을 수정해야 함 | 포인터 |
| 동시성에서 공유 | 포인터 (락과 함께) |
| 메서드 리시버가 섞이는 게 헷갈림 | 한 타입은 한쪽으로 통일 |
확신이 없다면 처음엔 값으로 시작하고, 프로파일링으로 병목이 보이면 포인터로 바꿔도 늦지 않다.
26.3 대용량 데이터 처리 패턴
가장 흔한 함정은 이거다. “파일 전체를 메모리에 읽고 처리한다.” 파일이 1GB 라면 메모리가 1GB 필요해진다.
한 번에 다 메모리에 올리지 않기
대용량 데이터는 흘려보내며 처리한다. 한 번에 한 조각씩 읽고, 처리하고, 버린다.
핵심 도구는 세 가지다.
io.Reader/io.Writer인터페이스- 채널로 만든 파이프라인
bufio.Scanner같은 줄 단위 도구
DB 결과를 페이징/커서/배치로 가져오기
100만 행짜리 테이블을 한 번에 가져오면 안 된다. 나눠서 가져온다.
// 의사 코드: 페이징
pageSize := 1000
offset := 0
for {
rows := db.Query(
"SELECT ... LIMIT ? OFFSET ?",
pageSize, offset,
)
if len(rows) == 0 {
break
}
for _, r := range rows {
process(r)
}
offset += pageSize
}
실제 DB 라이브러리에서는 보통 커서(cursor) 나 배치 fetch 를 지원한다. 한 줄씩 스트림으로 받을 수도 있다.
// 의사 코드: 커서 방식
rows := db.QueryStream("SELECT ...")
defer rows.Close()
for rows.Next() {
var r Row
rows.Scan(&r)
process(r)
}
어느 쪽이든 핵심은 같다. “전부 메모리에 올리지 말고 한 조각씩 받아 처리한다.”
io.Reader 로 스트림 처리
파일이든 네트워크든 압축 파일이든
Go 에서는 io.Reader 라는 통일된 모양으로 다룬다.
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
if n > 0 {
process(buf[:n])
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
buf 는 4KB 한 덩어리만 차지한다.
파일이 아무리 커도 메모리 사용량이 일정하다.
채널로 파이프라인 만들기
25장에서 본 파이프라인 패턴이 대용량 처리에도 그대로 쓰인다.
in := readLines("big.log") // 단계 1
parsed := parseLines(in) // 단계 2
filtered := filterErrors(parsed) // 단계 3
for e := range filtered {
saveError(e)
}
각 단계는 한 줄씩 흘려보낸다. 전체를 모았다가 다음 단계로 넘기는 게 아니다. 중간 어디서도 큰 메모리가 쌓이지 않는다.
bufio.Scanner 로 줄 단위 처리
텍스트 파일을 줄 단위로 다룰 때 표준 도구다.
f, err := os.Open("big.log")
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
process(line)
}
if err := sc.Err(); err != nil {
return err
}
내부적으로 버퍼 한 덩어리만 들고 파일을 한 줄씩 흘려본다. 1GB 짜리 로그도 메모리 부담 없이 훑을 수 있다.
기본 줄 길이 제한이 있다. 매우 긴 줄을 만나면
sc.Buffer(...)로 키워야 한다.
26.4 자주 쓰이는 최적화 기법
언어 차원에서 자주 쓰는 손쉬운 기법들이다. “이걸 안 했다면 손해” 정도의 수준이다.
strings.Builder 로 문자열 누적
문자열은 불변이다.
s = s + "abc" 를 할 때마다
새 문자열을 만들고 옛 것을 버린다.
반복문 안에서 하면 매우 비싸다.
// 나쁜 예
s := ""
for i := 0; i < 10000; i++ {
s += "x"
}
대신 strings.Builder 를 쓴다.
내부적으로 버퍼를 키워 가며 쌓는다.
// 좋은 예
var b strings.Builder
for i := 0; i < 10000; i++ {
b.WriteString("x")
}
s := b.String()
문자열을 많이 이어 붙여야 한다면 항상 이쪽이다.
맵 미리 크기 지정
맵도 슬라이스처럼 내부적으로 성장한다. 크기를 알고 있다면 초기 크기를 지정한다.
m := make(map[string]int, 1000)
성장에 따른 재해싱 비용이 줄어든다.
슬라이스 미리 크기 지정
26.1 에서 본 패턴이다. 한 번 더 강조한다.
out := make([]int, 0, len(in))
for _, v := range in {
out = append(out, v*2)
}
len(in) 만큼 결과가 나올 게 뻔하다면
미리 잡아 두는 게 깔끔하다.
sync.Pool 로 객체 재사용
같은 모양의 임시 버퍼를 초당 수천 번 만들었다가 버린다면 GC 가 비명을 지른다.
sync.Pool 은 그런 객체를 재활용한다.
var bufPool = sync.Pool{
New: func() any {
return make([]byte, 4096)
},
}
func handle() {
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf)
// buf 를 사용
}
Get()으로 가져와서 쓰고Put()으로 반납
풀에서 꺼낸 객체는 이전 사용 흔적이 남아 있을 수 있다. 필요하면 직접 초기화해야 한다.
모든 임시 객체에 쓸 필요는 없다. 프로파일링으로 GC 비용이 크다고 확인됐을 때 쓴다.
26.5 성능 측정
“느린 것 같다” 는 감각만으로 최적화하면 안 된다. 측정으로 확인한 뒤에 손댄다.
Go 는 표준 라이브러리에 벤치마크 도구가 있다.
testing.B 로 벤치마크 작성
테스트 파일(_test.go)에
BenchmarkXxx 로 시작하는 함수를 적는다.
package main
import "testing"
func BenchmarkConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 1000; j++ {
s += "x"
}
_ = s
}
}
func BenchmarkBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < 1000; j++ {
sb.WriteString("x")
}
_ = sb.String()
}
}
b.N 은 Go 가 자동으로 정한다.
함수가 빠르면 더 많이 돌리고,
느리면 적게 돌려서
의미 있는 측정 시간을 채운다.
실행
go test -bench=. -benchmem
-bench=.모든 벤치마크 실행-benchmem메모리 할당 정보도 함께
결과 읽는 법
출력은 대략 이렇게 생겼다.
BenchmarkConcat-8 1000 1234567 ns/op 55432 B/op 999 allocs/op
BenchmarkBuilder-8 30000 45678 ns/op 2048 B/op 5 allocs/op
| 항목 | 의미 |
|---|---|
1000 | 반복 횟수 (b.N) |
ns/op | 한 번 실행에 든 나노초 |
B/op | 한 번 실행이 할당한 바이트 |
allocs/op | 한 번 실행이 일으킨 할당 횟수 |
위 예에서 Builder 가 약 27배 빠르고
할당 횟수는 200분의 1 수준이다.
“문자열 누적엔 Builder” 라는 판단의 근거가 이런 숫자다.
26.6 pprof 맛보기
벤치마크는 “어떤 함수가 빠른가” 를 알려준다.
pprof 는 한 단계 더 들어간다.
“내 프로그램이 시간을 어디서 쓰고 있는가” 를 본다.
CPU 프로파일 켜기
벤치마크에 옵션을 붙이면 프로파일 파일이 생긴다.
go test -bench=. -cpuprofile=cpu.out
서버라면 net/http/pprof 패키지를 임포트해서
런타임에 프로파일을 받을 수도 있다.
분석
go tool pprof cpu.out
대화형 모드로 들어간다.
(pprof) top
(pprof) list functionName
top— 시간을 많이 쓴 함수 상위 목록list— 함수 안 어느 줄에서 시간을 쓰는지 표시
메모리 프로파일
go test -bench=. -memprofile=mem.out
go tool pprof mem.out
명령은 거의 같다. “어떤 함수가 메모리를 많이 할당하는지” 를 본다.
깊은 분석은 별도 학습
pprof 는 도구가 깊다.
플레임 그래프, 차이 비교, 웹 UI 등 기능이 많다.
이 책에서는 “이런 게 있다, 필요할 때 찾으면 된다” 정도까지만 안내한다. 실무에서 마주칠 때 공식 문서나 별도 자료로 익히면 충분하다.
26.7 정리
- 슬라이스는 배열을 가리키는 창문이다
append는 cap 부족 시 재할당을 일으킨다make([]T, 0, cap)으로 미리 잡으면 비용을 줄인다- 부분 슬라이스가 큰 배열을 붙잡는 함정에 주의
- 필요하면
copy로 메모리를 끊어 낸다
- 값/포인터 전달은 trade-off
- 큰 구조체는 포인터, 작은 구조체는 값
- 포인터는 힙 할당과 GC 부담을 늘릴 수 있다
- 대용량 데이터는 흘려보내며 처리한다
io.Reader,bufio.Scanner, 채널 파이프라인
- 손쉬운 최적화 기법
strings.Builder, 컨테이너 크기 미리 지정,sync.Pool
- 최적화 전에 측정한다
testing.B로 벤치마크pprof로 병목 분석
성능은 감으로 손대지 않는다. “이게 느리네” 가 아니라 “이 함수가 전체 시간의 70%를 쓰고 있네” 라는 근거가 생긴 뒤에 손대도 늦지 않다.
다음 장부터는 표준 라이브러리를 본격적으로 둘러본다. 27장은 문자열 다루기 심화부터 출발한다.