2500억 개의 캐시 엔트리, 1바이트의 승부
Cloudflare의 DNS 서비스인 Big Pineapple는 전 세계 데이터 센터에서 2500억 개 이상의 DNS 캐시 엔트리를 저장합니다. 이 규모에서 엔트리당 1바이트를 줄이는 것은 플릿 전체에서 250GB의 메모리를 절약하는 것과 같습니다. 이 글은 Cloudflare가 어떻게 5번의 구조적 변경을 통해 엔트리당 메모리 사용량을 953바이트에서 420바이트로 줄이고, 동시에 캐시 성능까지 개선했는지 설명합니다.
단순히 메모리만 줄인 것이 아니라, 삽입 처리량은 43% 증가하고 조회 지연 시간은 19% 감소했습니다. 메모리 최적화가 오히려 성능 향상으로 이어진 것입니다. 이는 단순히 데이터 구조를 바꾸는 것을 넘어, CPU 캐시 지역성과 할당 패턴까지 고려한 종합적인 최적화 덕분입니다.
참고: 이 글은 Cloudflare의 공식 블로그에 게시된 원문을 기반으로, 한국 개발자들의 실무 관점에서 재구성했습니다.
캐시 엔트리의 구조와 문제점
Big Pineapple의 캐시는 키-값 쌍으로 구성됩니다. 키는 쿼리된 도메인 정보를, 값은 DNS 응답과 메타데이터를 저장합니다.
// 캐시 키 구조
pub struct CacheKey {
qname: Name, // 쿼리 도메인 이름
qtype: Rtype, // 쿼리 타입 (A, AAAA, TXT 등)
authenticated: bool, // DNSSEC 인증 여부
tag: Vec<u8>, // 태그
}
// 캐시 엔트리 구조
pub struct CacheEntry {
timestamp: UnixTimeStamp,
pub inception: Instant,
pub ttl: Ttl,
pub hits: u32,
pub answers: Vec<Record>, // 응답 섹션
pub authority: Vec<Record>, // 권위 섹션
pub additional: Vec<Record>, // 추가 섹션
pub errors: Vec<Error>,
// ...
}
이 구조에서 가장 큰 문제는 Vec<T>입니다. Vec는 힙에 데이터를 저장하고, 포인터(8바이트), 길이(8바이트), 용량(8바이트)의 3개 필드로 구성됩니다. 하지만 캐시에 저장된 후에는 데이터가 변경되지 않으므로, 용량 필드는 쓸모가 없습니다. 또한 Vec는 미리 용량을 확보해두기 때문에 실제 데이터보다 더 많은 힙 공간을 차지합니다.
이 외에도 여러 가지 비효율이 존재합니다:
- 불필요한 용량 필드:
Vec와String의 용량 필드가 메모리 낭비 - 중복 리스트: 응답, 권위, 추가 섹션이 각각 별도의 리스트로 저장
- 중복 소유자 이름: 대부분의 레코드가 쿼리 도메인과 동일한 소유자를 갖지만, 매번 전체 이름을 저장
- 거대한 Enum: 다양한 DNS 레코드 타입을 하나의
enum으로 관리하지만, 가장 큰 변형의 크기에 맞춰 메모리 할당
5단계 최적화: 메모리와 성능을 동시에 잡다
1단계: Vec<T>를 Box<[T]>로 교체
Box<[T]>는 고정 크기 배열로, 생성 후 크기를 변경할 수 없습니다. 따라서 용량 필드가 필요 없고, 미리 공간을 확보하지 않아도 됩니다. String 역시 Box<str>로 교체합니다.
// 변경 전
pub struct CacheEntry {
// ...
pub answers: Vec<Record>,
pub authority: Vec<Record>,
pub additional: Vec<Record>,
}
// 변경 후
pub struct CacheEntry {
// ...
pub answers: Box<[Record]>,
pub authority: Box<[Record]>,
pub additional: Box<[Record]>,
}
이 변경만으로 엔트리당 64바이트를 절약하고, 힙 메모리의 낭비도 제거합니다. 전체적으로 15테라바이트 이상의 메모리 절약 효과가 있습니다.
2단계: 리스트 통합 및 오프셋 사용
응답, 권위, 추가 섹션을 하나의 리스트로 통합하고, 각 섹션의 시작 위치를 가리키는 오프셋을 저장합니다.
// 변경 전: 3개의 리스트 (각각 포인터 8바이트 + 길이 8바이트)
pub struct CacheEntry {
pub answers: Box<[Record]>,
pub authority: Box<[Record]>,
pub additional: Box<[Record]>,
}
// 변경 후: 1개의 리스트 + 2개의 오프셋
pub struct CacheEntry {
pub records: Box<[Record]>,
pub authority_offset: u16, // 2바이트
pub additional_offset: u16, // 2바이트
}
각 섹션의 레코드 수가 u16 범위를 넘지 않으므로, 2바이트 오프셋으로 충분합니다. 이렇게 하면 엔트리당 28바이트를 절약할 수 있습니다.
3단계: 소유자 이름 생략
대부분의 DNS 레코드는 쿼리한 도메인과 동일한 소유자를 갖습니다. 이 경우 소유자 이름을 저장하지 않고, 조회 시점에 캐시 키에서 복원합니다.
// 변경 전: 모든 레코드가 소유자 이름을 저장
pub struct Record {
owner: Name, // 항상 전체 도메인 이름 저장
class: Class,
ttl: Ttl,
rtype: Rtype,
data: RecordData,
}
// 변경 후: 소유자가 쿼리 도메인과 같을 때 생략
pub struct Record {
owner: Option<Box<Name>>, // None이면 쿼리 도메인 사용
class: Class,
ttl: Ttl,
rtype: Rtype,
data: RecordData,
}
CNAME 등으로 인해 소유자가 다른 경우에만 힙에 전체 이름을 저장합니다. 실제 트래픽의 대부분은 소유자가 쿼리 도메인과 동일하므로, 힙 할당을 크게 줄일 수 있습니다.
4단계: Enum 변형 박싱
DNS 레코드 타입을 나타내는 enum은 가장 큰 변형의 크기에 맞춰 메모리가 할당됩니다. NAPTR 레코드가 136바이트를 차지하므로, A 레코드(4바이트)를 저장해도 144바이트가 소모됩니다.
// 변경 전: 모든 변형이 인라인으로 저장
pub enum RecordData {
A(Ipv4Addr), // 4바이트
Aaaa(Ipv6Addr), // 16바이트
Txt(Txt), // 가변 길이
Naptr(Naptr), // 136바이트 (최대 크기)
// ...
}
// 변경 후: 큰 변형만 힙에 저장
pub enum RecordData {
A(Ipv4Addr), // 인라인 저장
Aaaa(Ipv6Addr), // 인라인 저장
Txt(Box<Txt>), // 힙에 저장
Naptr(Box<Naptr>), // 힙에 저장
// ...
}
A와 AAAA 레코드가 전체 트래픽의 80% 이상을 차지하므로, 이 최적화는 매우 효과적입니다. A 레코드의 경우 120바이트 이상을 절약할 수 있습니다.
5단계: 레코드를 와이어 포맷으로 저장
마지막으로, 파싱된 레코드 대신 DNS 와이어 포맷의 원시 바이트를 저장합니다. 각 레코드를 2바이트 길이 접두사와 함께 연속된 바이트 버퍼에 저장합니다.
// 변경 전: 파싱된 레코드의 리스트
pub struct CacheEntry {
pub records: Box<[Record]>,
}
// 변경 후: 원시 바이트 버퍼
pub struct CacheEntry {
pub records: Box<[u8]>, // 각 레코드는 2바이트 길이 + 원시 데이터
}
이 방식의 장점은 다음과 같습니다:
- 메모리 절약: 각 레코드의 enum 오버헤드와 개별 힙 할당 제거
- CPU 캐시 지역성 향상: 데이터가 연속적으로 저장되어 캐시 라인 효율 증가
- 응답 생성 가속화: A, AAAA, TXT, DNSSEC 레코드는 바이트를 직접 복사만 하면 됨
CNAME, NS, MX, SOA처럼 도메인 이름을 포함하는 레코드만 파싱하여 이름 압축을 적용합니다. 이 최적화만으로 조회 지연 시간이 5% 감소했습니다.
최적화 결과: 메모리와 성능의 동시 개선
벤치마크 결과, 5가지 최적화를 통해 엔트리당 메모리 사용량이 953바이트에서 420바이트로 56% 감소했습니다. 프로덕션 환경에서도 p99 기준 메모리 사용량이 9.3GB에서 5.3GB로 43% 감소했습니다.
| 지표 | 최적화 전 | 최적화 후 | 변화율 |
|---|---|---|---|
| 엔트리당 메모리 | 953 바이트 | 420 바이트 | -56% |
| 엔트리당 할당량 | 1.1 KB | 461 바이트 | -58% |
| 캐시 삽입 처리량 | 625,000 엔트리/s | 893,000 엔트리/s | +43% |
| 캐시 조회 지연 시간 | 828 ns | 670 ns | -19% |
한국 개발 생태계에서의 적용 맥락
이번 Cloudflare 사례는 단순히 DNS 캐시에만 적용되는 것이 아니라, 대규모 트래픽을 처리하는 모든 백엔드 시스템에 중요한 시사점을 줍니다.
국내 서비스들은 특히 이벤트성 트래픽 폭주에 대비해야 하는 경우가 많습니다. 예를 들어, 티켓 예매나 한정판 판매처럼 순간적으로 수십 배의 트래픽이 몰리는 상황에서 메모리 효율은 서비스 안정성과 직결됩니다. 이번 사례의 원칙인 "데이터 구조를 비즈니스 로직에 맞게 최적화"하는 접근법은 참고할 만합니다.
또한, 국내 SI 환경에서는 레거시 시스템을 유지보수하면서 성능을 개선해야 하는 경우가 많습니다. 이번 사례처럼 점진적인 5단계 최적화는 큰 위험 부담 없이 적용할 수 있는 좋은 전략입니다.
이 기술의 한계 또는 주의사항
이 최적화 기법이 모든 상황에 적용 가능한 것은 아닙니다. 몇 가지 주의사항이 있습니다.
- 읽기 전용 데이터에만 유효:
Box<[T]>로 교체하면 데이터를 수정할 수 없습니다. 캐시처럼 저장 후 변경되지 않는 데이터에만 적용 가능합니다. - 복잡성 증가: 레코드를 와이어 포맷으로 저장하면, 조회 시 파싱이 필요합니다. 레코드 수가 많은 경우 오히려 성능이 저하될 수 있습니다.
- 도메인 특화 최적화: 이 최적화는 DNS 캐시의 특성을 깊이 이해한 상태에서 적용되었습니다. 다른 시스템에 적용할 때는 데이터 특성을 먼저 분석해야 합니다.
- 유지보수 비용: 구조가 복잡해지면 코드 이해와 유지보수가 어려워질 수 있습니다. 충분한 문서화와 테스트가 필요합니다.
결론: 메모리 최적화는 성능 최적화다
Cloudflare의 사례는 메모리 최적화가 단순히 용량 절약을 넘어 성능 향상으로 이어질 수 있음을 보여줍니다. 핵심은 다음과 같습니다.
- 데이터 구조를 비즈니스 로직에 맞게 설계하라. 범용 구조보다 특화된 구조가 더 효율적입니다.
- 할당 패턴을 최적화하라. 힙 할당을 줄이고 CPU 캐시 지역성을 높이면 성능이 향상됩니다.
- 작은 변경이 큰 효과를 만든다. 엔트리당 1바이트 절약이 250GB 메모리 절약으로 이어집니다.
이번 최적화로 확보한 100TB의 메모리는 캐시 용량을 늘려 히트율을 높이는 데 재투자될 예정입니다. 이는 전체 DNS 쿼리 볼륨을 줄여 더 빠른 응답을 제공할 것입니다.
메모리 최적화와 관련된 더 깊은 내용을 원하시면 메타의 FFmpeg 포크 포기 사례도 함께 읽어보세요. 대규모 시스템에서 기술 부채를 관리하는 방법에 대한 통찰을 얻을 수 있습니다.
함께 보면 좋은 글
