← Blog
Go embed: 최소 변경으로 불필요한 메모리 사용 줄이기
gogeoipmemorypyroscopedatadog

Go embed: 최소 변경으로 불필요한 메모리 사용 줄이기

Elric Lim
Elric Lim

Server Engineer

세줄 요약

  1. 외부 파일(GeoIP DB)을 읽는 부분에서 heap 비중이 큰 것을 발견
  2. GeoIP DB를 mmap으로 읽는 방법과 []byte로 직접 embed하는 방법을 비교, 두 방식 모두 heap 복사 제거 가능 확인
  3. 기존 배포 구조를 유지할 수 있는 직접 embed 선택하여 불필요한 메모리 차지 포인트 개선

버즈빌의 광고·리워드 API 서버(이하 버즈스크린)는 베네핏허브 지면을 제공하는 서버로, 500여 개 매체사에 베네핏허브 지면과 서버 간 광고 할당 API를 제공하며, 광고 공급 경로의 앞단에서 트래픽을 처리합니다. Pyroscope를 사용해 버즈스크린 서버의 메모리 개선 포인트를 점검하던 중 아래 이미지처럼 `embed.FS.ReadFile`에서 큰 비중을 차지하는 것을 확인했습니다.

pyroscope에서 발견한 메모리 병목

embed.FS.ReadFile은 Go 표준 라이브러리의 메서드로 기존 코드에서는 해당 메서드로 GeoLite2 City DB를 읽어 reader를 생성했습니다. 매 요청마다 메모리를 추가 생성하는 구조가 아님에도 memory profile에서 embed.FS.ReadFile이 큰 비중을 차지하는 것의 원인 파악이 필요했습니다.

이번 포스트에서는 GeoIP 라이브러리의 메모리 비중을 개선하기 위해 탐색한 방법을 비교하고, 최종적으로 어떤 선택으로 서버 메모리 최적화를 달성했는지 공유합니다.

문제 — GeoIP DB가 차지하던 메모리

CPU profile 대신 보유 heap을 보기

처음 위 문제를 파악한 올해 7월 당시, Pyroscope inuse_space 조회 결과에서 전체 39.11GB 중 embed.FS.ReadFile의 값은 11.72GB(약 29.96%)로 기록돼 있었습니다.

inuse_space는 프로파일 수집 시점에 살아 있던 Go heap 객체의 추정 바이트 수로, 가비지 컬렉터(GC)에 의해 해제되지 않고 라이브(Live) 상태로 살아있는 객체들이 차지하는 메모리 크기의 총합을 나타냅니다. Flame graph를 확대해보면, 프로세스 초기화 과정에서 호출된 embed.FS.ReadFile이 보유 heap의 큰 비중을 차지하고 있었습니다. (프로그램 시작 이후 누적 할당된 총 메모리 크기인 alloc_space와는 별개로 alloc_space는 불필요한 일시적 메모리 할당(GC 유발 코드)을 탐지하는 용도입니다.) 호출 경로를 따라가면 애플리케이션 초기화에서 CreateGeoDB를 거쳐 embed.FS.ReadFile로 이어졌습니다. 요청을 처리할 때마다 DB가 쌓이는 메모리 누수라기보다 각 프로세스가 공통으로 부담하는 기본 메모리 비용에 가까웠습니다.

원인 파악: 바이너리에 있는 데이터를 다시 복사하는 문제

기존 초기화 코드는 다음과 같습니다.

//go:embed geodb
var fs embed.FS

func CreateGeoDB() (*geoip2.Reader, func() error, error) {
    data, err := fs.ReadFile("geodb/GeoLite2-City.mmdb")
    // ...
    db, err := geoip2.FromBytes(data)
    // ...
    return db, db.Close, nil
}

여기서 go:embed는 파일 내용을 Go 프로그램에 빌드 시점에 포함하는 기능입니다. (Go 1.16부터 지원)

예시 코드

import _ "embed"

//go:embed config.json
var config []byte

위 코드의 경우 실행 파일 안에 config.json 내용이 들어갑니다. go:embed는 파일 내용을 실행 파일에 포함시키므로 애플리케이션이 런타임에 별도 파일을 열어 읽을 필요는 없습니다. 바이너리에 포함된 데이터에 접근 시 OS가 메모리에 매핑하며 해당 페이지가 아직 page cache에 없다면 OS 수준의 디스크 I/O가 발생할 수 있습니다.

go:embed로 파일을 바이너리에 넣었으니 그 데이터를 그대로 읽을 것처럼 보이지만, ReadFile은 파일 내용을 바이트 슬라이스([]byte)로 반환하며 이 과정에서 GeoLite2 City DB 전체가 복사됩니다.

// ReadFile 내부 구현의 핵심
return []byte(ofile.f.data), nil

그다음 geoip2.FromBytes는 어떻게 동작할까요? 전달받은 바이트 슬라이스를 다시 복사하지 않고 reader 내부에서 참조합니다. 메타데이터와 검색 트리, 데이터 영역도 이 버퍼를 바탕으로 구성합니다.

reader가 계속 참조하고 있기 때문에 CreateGeoDB가 종료돼도 복사한 배열은 회수되지 않습니다. DB를 바이너리에 포함한 비용에 더해 DB 크기만큼의 heap 복사본을 프로세스마다 하나씩 유지하고 있는 형태입니다. 즉 서버 초기화 과정의 memory profile에서 embed.FS.ReadFile이 큰 비중을 차지하는 이유는 비교적 큰 용량의 DB를 원본과 더불어 복사본을 함께 생성하기 때문이었습니다.

해결안 1. 파일을 mmap으로 읽기

첫 번째 후보는 GeoIP DB를 바이너리 밖의 파일로 두고 이를 mmap으로 읽는 방식이었습니다. 구현은 geoip2.Open(path)을 호출하는 것으로 충분했습니다. 사용 중인 라이브러리는 일반적인 Unix 환경에서 MMDB 파일을 mmap으로 엽니다. mmap은 파일 전체를 Go의 []byte로 읽어 들이는 대신 파일을 프로세스의 가상 주소 공간에 연결하는 OS 기능으로, reader는 연결된 주소를 통해 DB를 조회하고 실제 페이지를 메모리에 올리고 비우는 일은 운영체제와 page cache가 관리합니다.

기존 방식과의 차이는 다음과 같습니다.

기존 방식
바이너리에 포함된 DB → ReadFile() → Go heap에 DB 전체 복사본 생성 → reader

mmap 방식
컨테이너의 DB 파일 별도 생성 → 가상 메모리에 파일 매핑 → reader

따라서 mmap 방식은 DB 전체 크기만큼의 Go heap 배열을 새로 만들지 않습니다. mmap 방식은 DB가 여전히 프로세스 메모리와 OS file cache를 사용하며, DB 데이터 자체가 아니라 ReadFile()이 만들던 추가 Go heap 복사본을 줄이는 것을 의도했습니다.

구현과 문제

파일 기반 reader를 열고, 파일이 없는 환경에서는 기존 embed 방식을 사용하도록 구현했습니다.

path := geoDBPath()
if _, err := os.Stat(path); err == nil {
    db, err := geoip2.Open(path) // MMDB 파일을 mmap으로 연결
    if err != nil {
        return nil, nil, fmt.Errorf("open geo db file: %w", err)
    }
    return db, db.Close, nil
}

// 파일이 없으면 기존 방식으로 동작
data, err := geodbFS.ReadFile(embeddedGeoDBPath)
db, err := geoip2.FromBytes(data)

운영 컨테이너에는 DB 파일도 함께 넣어야 했습니다. 환경변수 BUZZSCREEN_GEODB_PATH로 경로를 바꿀 수 있게 했고, 기본값은 /usr/share/buzzscreen/geodb/GeoLite2-City.mmdb로 정했습니다.

COPY --from=builder /app/components/buzzscreen/infra/database/geodb/GeoLite2-City.mmdb /usr/share/buzzscreen/geodb/GeoLite2-City.mmdb

다만 메모리 복사를 없애기 위해 별도 DB 파일과 배포 규칙까지 추가하는 것은 과도해 보였습니다. 오버 엔지니어링이란 생각이 들었고 다른 방법을 모색하기 시작했습니다.

mmap이 남기는 운영 비용

mmap은 heap 복사를 없애는 좋은 방법이었지만 애플리케이션의 배포 방식도 변경해버립니다.

항목기존 방식mmap 방식
배포물바이너리 하나바이너리와 별도 MMDB 파일
초기화바이너리에서 DB 읽기파일 경로 확인 후 DB 열기
파일 누락해당 없음기존 embed 방식으로 fallback
heap 복사 제거불가파일이 있을 때 가능

Docker copy가 정상적으로 동작하면 파일이 누락될 일이 없겠지만, 이 작업의 맥락을 모르는 개발자나 운영자가 파일 설정을 변경하면 기존 방식으로 fallback할 수 있습니다. 즉 기존처럼 heap 비중이 높아지는 문제는 여전히 존재하는 셈이었습니다.

외부 파일을 독립적으로 교체해야 하거나 DB와 애플리케이션의 배포 주기를 분리해야 한다면 mmap은 좋은 선택이지만, 당시 목표는 배포 방식을 바꾸는 것이 아니라 현재 초기화의 불필요한 복사를 없애는 것이었습니다. 운영 방식을 변경하지 않으면서 같은 효과를 낼 수 있는 방법을 탐색해야 했습니다.

해결안 2. 복사 없이 embed

embed.FS가 꼭 필요했을까

Go의 embed 기능은 string, []byte 변수에도 사용할 수 있습니다. geoip2.FromBytes는 전달받은 MMDB 바이트 슬라이스를 직접 참조하므로 처음부터 DB를 []byte형태로 embed하면 ReadFile의 복사 단계를 생략할 수 있습니다.

//go:embed geodb/GeoLite2-City.mmdb
var

위 처럼 준비된 mmdb 데이터를 바이트 슬라이스에 바로 embed하면 빌드 시점에 GeoLite2-City.mmdb 파일을 바이너리에 포함하고, 실행 시 geoDBData가 그 내용을 가리키는 바이트 슬라이스로 준비됩니다. 즉 reader 생성 시 DB 전체를 동적 heap에 복사할 필요가 없습니다. 단 바이트 슬라이스는 얼마든지 코드 상에서 수정이 가능하기 때문에 geoDBData []byte가 반드시 수정되지 않는다는 계약을 주석으로 추가 명시했습니다.

동일한 데이터로 세 가지 방식 비교

이를 확인하기 위해 로컬에서 운영 코드와 분리된 실험을 진행했습니다.

DB를 읽는 경로전체 DB의 Go heap 복사
기존 방식embed.FS → ReadFile → FromBytes발생
직접 embed[]byte embed → FromBytes불필요
mmap별도 파일 → Open → mmap불필요

세 방식을 하나의 바이너리에 넣어 실행하면 여러 embedded DB가 측정에 섞일 수 있습니다. 그래서 같은 원본 DB로 각각 별도의 바이너리를 만들고 별도 프로세스에서 실행했습니다. reader 생성 전 강제 GC 이후를 baseline으로 잡고, 생성 후 다시 GC한 heap 증가량을 비교했습니다. reader가 측정 도중 회수되지 않도록 runtime.KeepAlive도 사용했습니다. 메모리 사용률 개선보다 동일한 기능을 보장하는지 먼저 검토했습니다. 세 방식의 DB SHA256, 메타데이터, 동일 IP 조회에 대한 전체 City 구조체와 오류 결과 hash를 비교했고, 좌표·지역·우편번호 등 반환 데이터가 함께 유지되는지 검증했습니다.

아래는 실험에서 기록한 중앙값입니다.

방식reader 생성 후 보유 heap 증가reader 생성 시간
embed.FS + ReadFile72,107,496 B46.49 ms
직접 []byte embed1,512 B0.045 ms
파일 mmap1,800 B0.403 ms

Go 1.26.8 / GOMAXPROCS=2 환경에서 실험을 진행

DB 크기는 72MB(72,100,729B)였으며 모든 반복에서 DB와 조회 결과 hash가 일치했고 직접 embed와 mmap 방식 모두 기존 대비 약 68.8MiB의 heap 증가를 없앴습니다. 두 방식의 개선 효과가 비슷했기 때문에 운영·유지보수 비용이 더 작은 embed 방식을 선택했습니다.

판단 기준파일 mmap직접 embed
전체 DB의 heap 복사 제거가능가능
runtime 이미지의 별도 DB 파일필요불필요
파일 경로·누락 처리필요불필요
기존 바이너리 배포 방식 유지별도 파일 계약 추가가능
애플리케이션에서 지킬 계약매핑과 reader 수명 관리공유 바이트 수정 금지

결과 — 직접 embed로 복사 제거

기존

//go:embed geodb
var fs embed.FS

func CreateGeoDB() (*geoip2.Reader, func() error, error) {
    data, err := fs.ReadFile("geodb/GeoLite2-City.mmdb")
    // 오류 처리 생략
    db, err := geoip2.FromBytes(data)
    // 오류 처리 생략
    return db, db.Close, nil
}

개선

//go:embed geodb/GeoLite2-City.mmdb
var geoDBData []byte

func CreateGeoDB() (*geoip2.Reader, func() error, error) {
    db, err := geoip2.FromBytes(geoDBData)
    if err != nil {
        return nil, nil, fmt.Errorf("open geo db: %w", err)
    }
    return db, db.Close, nil
}

위 개선 코드의 //go:embed geodb/GeoLite2-City.mmdb 부분이 새롭게 변경한 부분입니다. MMDB 파일 하나만 embed하도록 개선했고 기존 reader 반환 방식과 파싱 오류 처리, 종료 시 closer를 호출하는 사용 방식은 유지했습니다.

Pyroscope에서 사라진 할당 경로

embed 방식으로 개선하여 production에 배포했고 프로파일을 다시 조회했습니다.

구분확인한 loader
배포 전embed.FS.ReadFile
배포 후직접 []byte embed

배포 전 memory:inuse_space:bytes:space:bytes profile에서 embed.FS.ReadFile은 flat 기준 43.17%를 차지했고, 호출 스택을 따라가면 database.CreateGeoDB → embed.FS.ReadFile 경로를 확인할 수 있었습니다.

배포 후 동일한 필터 조회에서 CreateGeoDB → embed.FS.ReadFile 표본은 관측되지 않았습니다. 즉 GeoIP의 복사 할당 경로가 프로파일에서 사라진 것을 확인했으며, 이는 GeoIP DB의 모든 물리 메모리가 사라졌다는 뜻은 아닙니다.

pyroscope에서 발견한 메모리 병목 위의 그래프는 버즈스크린 API 서버의 메모리 절감을 나타내고, 아래 그래프는 버즈스크린 컨슈머의 메모리 절감을 보여줍니다. 컨슈머에서 개선 효과가 더 크게 보이는 이유는 GeoIP DB를 두 번 초기화했기 때문인데, 서로 다른 두 개의 초기화 factory 코드에서 각각 DB 전체를 복사하던 부분을 수정했습니다. 그 결과 상대적으로 더 크게 메모리 절감 효과를 낼 수 있었습니다. pyroscope에서 발견한 메모리 병목

지표변경 전 평균변경 후 평균관측 차이
Go live heap173.9 MiB101.9 MiB−41.4%
Go runtime 메모리: total − heap released437.0 MiB269.7 MiB−38.3%

마무리

이번 개선에서는 mmap과 직접 embed 두 가지 방식을 검토했습니다. mmap은 heap 복사를 제거할 수 있지만 Dockerfile 수정과 별도 파일 배포가 필요하고 파일 경로도 안정적으로 유지해야 했습니다. 상대적으로 리스크가 큰 변경이 될 것으로 판단했습니다. 직접 embed 방식에도 공유된 바이트 슬라이스를 수정하지 않아야 한다는 조건이 있었지만 기존의 단일 바이너리 배포 구조를 유지할 수 있어 mmap보다 운영 부담이 작았습니다. AI의 도움으로 두 방법 모두 빠르게 검증할 수 있었지만 선택은 결국 개발자의 몫이었습니다. 결국 운영과 유지보수 관점에서 적절한 방법을 택했고, 선택을 뒷받침한건 mmap보다 embed가 낫다는 가정이 아닌 동일한 결과를 유지하면서도 필요한 효과를 얻을 수 있다는 측정 결과였습니다.

Reference

Related posts