세줄 요약

  1. GeoIP DB를 읽는 embed.FS.ReadFile에서 heap 비중이 큰 것을 발견
  2. 외부 파일을 mmap으로 읽는 방법과 []byte로 직접 embed하는 방법을 비교, 두 방식 모두 DB 전체의 heap 복사를 없앨 수 있는 것을 확인
  3. 기존 배포 구조를 유지할 수 있는 직접 embed 선택, 메모리 사용 개선 확인

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

pyroscope에서 발견한 메모리 병목

embed.FS.ReadFile은 IP 주소로 지역과 좌표 정보를 보정하는 GeoIP 라이브러리의 메서드로, 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는 현재 프로그램이 힙 메모리 상에서 사용 중인 실제 메모리 양을 의미하며 가비지 컬렉터(GC)에 의해 해제되지 않고 라이브(Live) 상태로 살아있는 객체들이 차지하는 메모리 크기의 총합을 나타냅니다. Flame graph를 확대해보면 서버 초기화 경로에 embed.FS.ReadFile이 큰 비중을 차지하기에 메모리를 계속 보유하는 할당 경로에 GeoIP 초기화가 집중돼 있다는 점을 주목했습니다. (프로그램 시작 이후 누적 할당된 총 메모리 크기인 alloc_space와는 별개로 alloc_space는 불필요한 일시적 메모리 할당(GC 유발 코드)을 탐지하는 용도입니다.) 호출 경로를 따라가면 애플리케이션 초기화에서 CreateGeoDB를 거쳐 embed.FS.ReadFile로 이어졌습니다. 요청을 처리할 때마다 DB가 쌓이는 메모리 누수라기보다 각 프로세스가 공통으로 부담하는 기본 메모리 비용에 가까웠습니다.

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

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

go
//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부터 지원)

예시 코드

go
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 전체가 복사됩니다.

go
// 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가 관리합니다.

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

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

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

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

구현과 문제

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

go
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로 정했습니다.

dockerfile
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가 정상적으로 동작하면 파일이 누락될 일이 없겠지만, 이 작업의 컨텍스트를 모르는 개발자나 ops팀이 파일을 설정을 건드리는 순간 fallback으로 기존 ReadFile() 복사가 다시 발생합니다. 즉 기존처럼 heap 비중이 높아지는 문제는 여전히 존재하는 셈이었습니다.

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

해결안 2. 복사 없이 embed

embed.FS가 꼭 필요했을까

Go의 embed 기능은 string, []byte 변수에도 사용할 수 있습니다. 이번 개선에서 필요한 것은 MMDB(main memory DB) 파일을 읽는 geoip2.FromBytes 함수가 바이트 슬라이스를 그대로 사용하는 것으로, 처음부터 DB를 []byte형태로 embed하면 ReadFile이라는 복사 단계를 생략하고 (불필요한 heap memeory 점유도 없이) geoip2를 사용할 수 있었습니다.

go
//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 방식을 택하는 방향으로 최종 구현을 진행했습니다.

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

결과 — 직접 embed로 복사 제거

기존

go
//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
//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
}

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 방식은 실제 운영 환경에 배포해보며 실험하기엔 리스크가 크다고 판단했습니다. 도커 파일을 수정과 더불어 파일 경로가 늘 고정된다는 가정이 뒷받침 돼야 했죠. go embed 방식 역시 바이트 슬라이스가 불변 상태로 지켜져야 한다는 가정이 보장돼야 했습니다. AI의 도움으로 두 방법 모두 빠르게 검증할 수 있었지만 선택은 결국 개발자의 몫이었습니다. 결국 운영과 유지보수 관점에서 적절한 방법을 택했고, 선택을 뒷받침한건 mmap보다 embed가 낫다는 가정이 아닌 동일한 결과를 유지하면서도 필요한 효과를 얻을 수 있다는 측정 결과였습니다.

Reference