들어가며
현재까지 구축한 데이터베이스로는 서버를 종료하거나 장애가 발생하면 저장된 데이터는 전부 사라진다. 이번 포스팅에서는 메모리의 현재 상태를 바이너리 파일로 기록하는 SAVE 명령어를 구현한다. 이 과정에서 바이너리 인코딩, 직렬화 포맷 설계, 파일 I/O의 핵심 개념을 다뤄보자.
영속성과 스냅샷
데이터베이스에서 영속성(Persistence)이란, 데이터가 프로세스의 생명주기를 넘어서 유지되는 성질이다. 인메모리 데이터베이스는 기본적으로 영속성이 없다. 메모리는 휘발성 저장장치이기 때문이다.
레디스는 두 가지 방식으로 영속성을 제공한다.
- RDB (Redis Database) - 특정 시점의 메모리 상태를 통째로 파일에 덤프하는 스냅샷 방식이다. 파일이 작고 로딩이 빠르지만 마지막 스냅샷 이후의 데이터는 유실될 수 있다.
- AOF (Append Only File) - 모든 쓰기 명령을 로그 파일에 순서대로 기록하는 저널링 방식이다. 데이터 유실이 거의 없지만 파일 크기가 크고 로딩이 느리다.
필자는 RDB 방식을 구현할 생각이다. SAVE 명령을 실행하면 메모리의 모든 데이터를 바이너리 파일에 기록한다.
그리고 레디스의 SAVE 명령은 블로킹으로 동작한다. 저장이 완료될 때까지 서버가 다른 요청을 처리하지 않는다. 전체 데이터를 순회하는 동안 다른 고루틴이 데이터를 변경하면 일관성이 깨질 수 있기 때문이다. Redis는 비동기 저장을 위해 BGSAVE도 제공하는데 이는 fork() 시스템 콜로 자식 프로세스를 만들고 Copy-on-Write를 활용하는 방식이다. 필자는 SAVE만 다룰예정이다.
바이너리 인코딩
텍스트 포맷 vs 바이너리 포맷
데이터를 파일에 저장하려면 메모리의 자료구조를 바이트 시퀀스로 변환하는 직렬화 작업이 필요하다.
데이터베이스 스냅샷에는 JSON 같은 텍스트 포맷보다는 바이너리 포맷이 더 적합하다.
키 name에 redis 라는 밸류를 저장한다고 가정하면, JSON으로 표현하면 {"key":"name","value":"redis"} 31바이트가 필요하다. 바이너리로 표현하면 타입(1) + 키길이(4) + 키(4) + 값길이(4) + 값(5) + TTL(1) = 19바이트로 충분하다.
바이너리 포맷의 이점을 정리하면 이렇다.
공간 효율 - 정수
12345를 텍스트로 저장하면 5바이트(12345), uint32 바이너리로 저장하면 항상 4바이트이다. 데이터가 커질수록 차이가 벌어진다.파싱 속도 - 고정 크기 필드를 오프셋 계산만으로 바로 읽을 수 있어 빠르다. 반면 텍스트 포맷은 따옴표, 쉼표, 공백, 구분자 등을 순차적으로 찾아가며 파싱해야 해서 일반적으로 오버헤드가 더 크다.
타입 보존 - 바이트 자체가 타입 정보를 포함한다. 텍스트에서
123이 문자열인지 숫자인지 구분하려면 별도의 컨텍스트가 필요하지만 바이너리 포맷에서는 타입 바이트(0x00=String, 0x01=List)가 이를 명확히 한다.
Endianness
uint32, int64 같은 멀티바이트 정수를 메모리나 파일에 저장할 때, 바이트를 어떤 순서로 배치하느냐의 문제가 Endianness다.
0x12345678이라는 4바이트 정수를 예로 들면 두 가지 방식이 있다.
| 주소 | Big Endian | Little Endian |
|---|---|---|
| 0x00 | 0x12 (MSB) | 0x78 (LSB) |
| 0x01 | 0x34 | 0x56 |
| 0x02 | 0x56 | 0x34 |
| 0x03 | 0x78 (LSB) | 0x12 (MSB) |
Big Endian은 최상위 바이트(MSB, Most Significant Byte)를 가장 낮은 주소에 저장한다. 사람이 숫자를 읽는 순서(왼쪽 -> 오른쪽)와 동일해서 직관적이다.
Little Endian은 최하위 바이트(LSB, Least Significant Byte)를 먼저 저장한다. x86/x64 같은 인텔 아키텍처가 이 방식을 사용한다.
네트워크 프로토콜에서는 Big Endian을 표준으로 사용하며, 이를 Network Byte Order라고 부른다. TCP/IP의 기반 문서인 RFC 791에서 이 규약을 정의한 이래로 플랫폼에 관계없이 네트워크에서 주고받는 데이터는 Big Endian으로 통일되었다. 현재 내 서버의 포맷도 Big Endian을 사용하겠다.
go에서는 아래의 코드 예제와 같이 encoding/binary 패키지가 Endianness 변환을 처리한다.
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, 12345)
// buf = [0x00, 0x00, 0x30, 0x39]
Magic Bytes
바이너리 파일의 첫 몇 바이트에 고정 문자열을 넣어 파일 형식을 식별하는 기법이다. 파일을 열었을 때 이 바이트들을 먼저 확인하면 포맷이 올바른지 파싱을 시작하기 전에 즉시 판단할 수 있다.
| 포맷 | Magic Bytes | 용도 |
|---|---|---|
| ELF | \x7fELF | Linux 실행 파일 |
| PNG | \x89PNG\r\n\x1a\n | 이미지 |
%PDF | 문서 | |
| Redis RDB | REDIS | Redis 스냅샷 |
| MINIDB | MINIDB | 구현할 서버에서의 포맷 |
운영체제의 file 명령어도 이 Magic Bytes를 기반으로 파일 타입을 판단한다. 확장자가 .jpg여도 Magic Bytes가 png라면 실제로는 png 파일인 것이다.
Length-Prefixed 문자열
RESP 프로토콜 구현에서 다룬 TLV(Type-Length-Value) 패턴을 바이너리 직렬화에도 그대로 적용한다. RESP에서는 $5\r\nhello\r\n처럼 텍스트 기반의 길이 접두사를 사용했지만 바이너리 포맷에서는 4바이트 uint32로 길이를 먼저 쓰고 바로 뒤에 문자열 바이트를 붙인다. 구분자(\r\n)가 필요 없으니 공간도 절약되고, 고정 4바이트를 읽으면 뒤에 몇 바이트를 읽어야 하는지 바로 결정된다.
바이너리 포맷 설계
포맷 MINIDB의 전체 파일 구조는 헤더, Entry(반복), EOF 세 부분으로 구성된다.
Header (7바이트)
| 오프셋 | 크기 | 내용 | 값 |
|---|---|---|---|
| 0 | 6바이트 | Magic Bytes | MINIDB |
| 6 | 1바이트 | Version | 0x01 |
버전 필드는 포맷의 하위 호환성을 위한 것이다. 포맷이 변경되면 버전을 올려서 읽는 쪽에서 자신이 이해할 수 있는 버전인지 먼저 확인할 수 있다.
Entry (타입별 가변)
각 엔트리는 Type -> Key -> Value -> TTL 순서로 기록된다.
[1 byte] Type 0x00=String, 0x01=List
[4 bytes] KeyLength uint32, Big Endian
[N bytes] KeyData UTF-8 문자열
--- String인 경우 ---
[4 bytes] ValueLength uint32, Big Endian
[N bytes] ValueData UTF-8 문자열
--- List인 경우 ---
[4 bytes] ElementCount uint32, Big Endian
반복:
[4 bytes] ElemLength uint32, Big Endian
[N bytes] ElemData UTF-8 문자열
--- TTL ---
[1 byte] HasExpiry 0x00=없음, 0x01=있음
(0x01인 경우)
[8 bytes] ExpireAt int64, Big Endian, Unix 밀리초
TTL은 남은 초가 아니라 만료되는 절대 시각을 unix 밀리초로 저장한다. 남은 초를 저장하면 파일을 로드하는 시점에 얼마나 시간이 지났는지를 알 수 없기 때문이다. 절대 시각을 저장하면 로드 시점에 현재 시각과 비교해서 이미 만료되었는지 바로 판단할 수 있다.
그리고 스냅샷 시점에 이미 만료된 데이터를 파일에 쓰는 것은 리소스 낭비이므로 이미 만료된 키는 저장하지 않는다.
EOF 마커 (1바이트)
| 크기 | 값 |
|---|---|
| 1바이트 | 0xFF |
파일의 끝을 명시적으로 표시한다. 파일이 중간에 잘렸다면 EOF 마커가 없을 것이므로 불완전한 파일임을 감지할 수 있다.
전체 코드
format.go
바이너리 포맷의 상수를 정의한다. 코드 전체에서 0x00, 0xFF 같은 매직 넘버 대신 의미 있는 이름으로 참조할 수 있다.
package persistence
var MagicBytes = [6]byte{'M', 'I', 'N', 'I', 'D', 'B'}
const (
Version byte = 0x01
TypeString byte = 0x00
TypeList byte = 0x01
NoExpiry byte = 0x00
HasExpiry byte = 0x01
EOF byte = 0xFF
)
rdb.go - Encoder
io.Writer에 바이너리 데이터를 쓰는 Encoder 전체 코드다.
package persistence
import (
"bufio"
"encoding/binary"
"io"
"time"
)
type Encoder struct {
w *bufio.Writer
}
func NewEncoder(w io.Writer) *Encoder {
return &Encoder{bufio.NewWriter(w)}
}
// 파일 헤더를 쓴다. Magic bytes "MINIDB" + Version 0x01 = 총 7바이트.
func (e *Encoder) WriteHeader() error {
e.w.Write(MagicBytes[:])
_, err := e.w.Write([]byte{Version})
return err
}
// String 타입 엔트리를 쓴다.
// [Type 0x00] [Key] [Value] [TTL]
func (e *Encoder) WriteStringEntry(key, value string, expireAt *time.Time) error {
if err := e.writeBytes([]byte{TypeString}); err != nil {
return err
}
if err := e.writeString(key); err != nil {
return err
}
if err := e.writeString(value); err != nil {
return err
}
return e.writeExpiry(expireAt)
}
// List 타입 엔트리를 쓴다.
// [Type 0x01] [Key] [ElementCount] [Element1] [Element2] ... [TTL]
func (e *Encoder) WriteListEntry(key string, values []string, expireAt *time.Time) error {
if err := e.writeBytes([]byte{TypeList}); err != nil {
return err
}
if err := e.writeString(key); err != nil {
return err
}
if err := e.writeUint32(uint32(len(values))); err != nil {
return err
}
for _, v := range values {
if err := e.writeString(v); err != nil {
return err
}
}
return e.writeExpiry(expireAt)
}
// EOF 마커를 쓴다. 파일의 끝을 명시적으로 표시
func (e *Encoder) WriteEOF() error {
return e.writeBytes([]byte{EOF})
}
// 버퍼에 남아있는 데이터를 실제 Writer에 내보낸다.
// 이걸 호출하지 않으면 마지막 데이터가 파일에 기록되지 않는다
func (e *Encoder) Flush() error {
return e.w.Flush()
}
func (e *Encoder) writeBytes(data []byte) error {
_, err := e.w.Write(data)
return err
}
// uint32 값을 Big Endian 4바이트로 변환해서 쓴다.
func (e *Encoder) writeUint32(n uint32) error {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, n)
return e.writeBytes(buf)
}
// int64 값을 Big Endian 8바이트로 변환해서 쓴다.
func (e *Encoder) writeInt64(n int64) error {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(n))
return e.writeBytes(buf)
}
// Length-Prefixed 문자열을 쓴다.
// [4바이트 길이] + [문자열 바이트] 형태
func (e *Encoder) writeString(s string) error {
if err := e.writeUint32(uint32(len(s))); err != nil {
return err
}
return e.writeBytes([]byte(s))
}
// TTL 정보를 쓴다
// expireAt이 nil이면 0x00 한 바이트만 쓴다 (만료 없음)
// nil이 아니면 0x01 + 8바이트 Unix 밀리초를 쓴다.
func (e *Encoder) writeExpiry(expireAt *time.Time) error {
if expireAt == nil {
return e.writeBytes([]byte{NoExpiry})
}
if err := e.writeBytes([]byte{HasExpiry}); err != nil {
return err
}
return e.writeInt64(expireAt.UnixMilli())
}
store.go - Save
Store에 추가한 Save 메서드다. RLock을 잡고 전체 데이터를 순회하며 Encoder로 기록한다.
func (s *Store) Save(path string) error {
s.mu.RLock()
defer s.mu.RUnlock()
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
encoder := persistence.NewEncoder(file)
encoder.WriteHeader()
for key, entry := range s.data {
// 이미 만료된 키는 저장할 필요 없으니 건너뛴다.
// isExpired()는 삭제(쓰기)를 하기 때문에 RLock 상태에서 호출 불가
// 읽기 전용으로 만료 여부만 확인한다.
if entry.ExpireAt != nil && entry.ExpireAt.Before(time.Now()) {
continue
}
switch entry.Type {
case TypeString:
encoder.WriteStringEntry(key, entry.Str, entry.ExpireAt)
case TypeList:
values := entry.List.Range(0, entry.List.Length-1)
encoder.WriteListEntry(key, values, entry.ExpireAt)
}
}
encoder.WriteEOF()
return encoder.Flush()
}
server.go — SAVE 핸들러
case "SAVE":
err := s.store.Save("dump.rdb")
if err != nil {
writer.WriteError(err.Error())
} else {
writer.WriteSimpleString("OK")
}
코드 분석
Encoder의 레이어 구조
Encoder는 네트워크 프로토콜 스택처럼 레이어 구조로 설계되어 있다. 아래 레이어부터 위로 올라가며 각 레이어가 하위 레이어를 조합하는 방식이다.
가장 아래 writeBytes는 bufio.Writer에 바이트를 쓸 뿐이다. 그 위의 writeUint32와 writeInt64는 정수를 Big Endian 바이트로 변환해서 writeBytes에 넘긴다. writeString은 writeUint32(길이) + writeBytes(데이터) 조합이고, 최상위 공개 메서드들은 이 헬퍼들을 순서대로 호출하는 것뿐이다.
이 구조 덕분에 WriteStringEntry의 구현은 명세서를 그대로 옮긴 것처럼 읽힌다. 타입 쓰고, 키 쓰고, 값 쓰고, 만료 정보를 쓴다. 각 헬퍼가 바이너리 인코딩의 복잡함을 숨기고 있기 때문이다.
Save에서의 읽기 전용 만료 확인
Save에서 만료된 키를 건너뛸 때 기존의 isExpired()를 사용하지 않고, entry.ExpireAt.Before(time.Now())로 직접 확인한다. 이전 챕터에서 다룬 것처럼 isExpired()에는 delete() 쓰기 동작이 포함되어 있어 RLock 상태에서 호출할 수 없다. Save는 읽기 전용이므로 RLock을 사용하고 만료 키는 삭제하지 않고 건너뛰기만 한다. 실제 삭제는 Active Deletion이 백그라운드에서 처리한다.
Hex Dump 분석
실제로 SAVE를 실행한 결과를 xxd로 확인하면 바이너리 포맷이 눈앞에 펼쳐진다. RPUSH로 food 키에 pizza를 넣고 SAVE한 결과다.
00000000: 4d49 4e49 4442 0101 0000 0004 666f 6f64 MINIDB......food
00000010: 0000 0001 0000 0005 7069 7a7a 6100 ff ........pizza..
바이트 하나하나를 내가 설계한 포맷에 대응시키면 아래와 같다.
4d 49 4e 49 44 42 <- "MINIDB" Magic Bytes
01 <- Version 0x01
01 <- TypeList (0x01)
00 00 00 04 <- Key 길이 = 4
66 6f 6f 64 <- "food"
00 00 00 01 <- Element Count = 1
00 00 00 05 <- Element 길이 = 5
70 69 7a 7a 61 <- "pizza"
00 <- NoExpiry (TTL 없음)
ff <- EOF
설계한 포맷이 그대로 저장되어 있는 것을 볼 수 있다. 이 바이트 시퀀스를 역순으로 읽어서 다시 메모리 자료구조로 복원하는 것이 바로 다음에 구현할 Decoder의 역할이다.
테스트
TestWriteStringEntry
func TestWriteStringEntry(t *testing.T) {
// given
var buf bytes.Buffer
enc := NewEncoder(&buf)
// when: key="hi", value="go", TTL 없음
err := enc.WriteStringEntry("hi", "go", nil)
enc.Flush()
// then
if err != nil {
t.Fatalf("에러 발생: %v", err)
}
data := buf.Bytes()
// 총 길이: Type(1) + KeyLen(4) + Key(2) + ValLen(4) + Val(2) + NoExpiry(1) = 14
if len(data) != 14 {
t.Fatalf("총 길이: %d, expected: 14", len(data))
}
// Type: 0x00
if data[0] != TypeString {
t.Fatalf("Type: 0x%02x, expected: 0x00", data[0])
}
// Key length: 2
keyLen := binary.BigEndian.Uint32(data[1:5])
if keyLen != 2 {
t.Fatalf("Key length: %d, expected: 2", keyLen)
}
// Key: "hi"
if string(data[5:7]) != "hi" {
t.Fatalf("Key: %s, expected: hi", string(data[5:7]))
}
// Value length: 2
valLen := binary.BigEndian.Uint32(data[7:11])
if valLen != 2 {
t.Fatalf("Value length: %d, expected: 2", valLen)
}
// Value: "go"
if string(data[11:13]) != "go" {
t.Fatalf("Value: %s, expected: go", string(data[11:13]))
}
// No expiry: 0x00
if data[13] != NoExpiry {
t.Fatalf("Expiry: 0x%02x, expected: 0x00", data[13])
}
}
Encoder를 bytes.Buffer에 연결해서 실제 파일 없이도 바이트를 검증한다. io.Writer 인터페이스를 받도록 설계한 덕분에 가능한 방식이다. 바이트 오프셋을 직접 계산해서 Type, Key Length, Key Data, Value Length, Value Data, Expiry 각 필드가 정확한 위치에 정확한 값으로 기록되었는지 확인한다.
TestSave_SkipExpiredKeys
func TestSave_SkipExpiredKeys(t *testing.T) {
// given: 만료된 키 1개 + 유효한 키 1개
store := New()
store.Set("expired", "old")
store.Set("valid", "new")
store.Expire("expired", 1)
time.Sleep(2 * time.Second)
path := filepath.Join(t.TempDir(), "skip.rdb")
// when
err := store.Save(path)
// then: 만료 키 제외, "valid" 키만 저장
// Header(7) + String("valid"=5,"new"=3: 1+4+5+4+3+1=18) + EOF(1) = 26
if err != nil {
t.Fatalf("에러 발생: %v", err)
}
data, _ := os.ReadFile(path)
if len(data) != 26 {
t.Fatalf("파일 크기: %d, expected: 26 (만료 키 제외)", len(data))
}
}
만료된 expired 키가 파일에 포함되지 않는지 파일 크기로 검증한다. 두 키가 모두 저장되면 26바이트보다 클 것이고, valid 키만 저장되면 정확히 26바이트가 된다. t.TempDir()은 테스트가 끝나면 자동으로 정리되므로 파일 cleanup을 직접 할 필요가 없다.
TestEncodeFullFile
func TestEncodeFullFile(t *testing.T) {
// given
var buf bytes.Buffer
enc := NewEncoder(&buf)
expireAt := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
// when: Header + String 엔트리 + List 엔트리(TTL 포함) + EOF
enc.WriteHeader()
enc.WriteStringEntry("name", "go", nil)
enc.WriteListEntry("list", []string{"a", "b"}, &expireAt)
enc.WriteEOF()
enc.Flush()
// then
data := buf.Bytes()
// "MINIDB"로 시작
if string(data[:6]) != "MINIDB" {
t.Fatalf("Magic bytes: %s", string(data[:6]))
}
// 0xFF로 끝남
if data[len(data)-1] != EOF {
t.Fatalf("마지막 바이트: 0x%02x, expected: 0xFF", data[len(data)-1])
}
// Header(7) + String(16) + List(32) + EOF(1) = 56
if len(data) != 56 {
t.Fatalf("총 길이: %d, expected: 56", len(data))
}
}
Header, String 엔트리, TTL이 설정된 List 엔트리, EOF까지 완전한 파일 구조를 생성하고, Magic Bytes로 시작하는지, EOF로 끝나는지, 총 바이트 수가 정확한지 검증한다. 각 필드의 바이트 수를 직접 계산하면 7 + 16 + 32 + 1 = 56이 나오고, 이 값이 실제 출력과 일치하면 모든 엔트리가 올바른 크기로 인코딩된 것이다.
마치며
이번 포스팅에서는 메모리에만 존재하던 데이터를 파일에 기록하는 첫 번째 단계를 구현했다. 직렬화 포맷을 직접 설계하면서 평소에 당연하게 쓰던 바이너리 파일 안에 얼마나 많은 설계의 흔적(?)이 숨어 있는지 알 수 있었다. Magic Bytes로 파일을 식별하고, Endianness로 바이트 순서를 통일하고, Length-Prefixed 문자열로 가변 길이를 처리하고, 이번엔 다루진 않았지만 이전에 TCP 스트림 파싱을 구현할때 다루었던 Buffered I/O로 시스템 콜을 줄이는 것까지해서 하나의 바이너리 포맷을 완성시켰다.
현재는 쓰기만 가능한 구조로, xxd로 hex dump를 확인하면 설계한 포맷대로 바이트가 기록된 걸 볼 수 있지만 이 바이트를 다시 메모리 자료구조로 복원하는 기능은 아직 없다. 다음 포스팅에서는 Decoder를 구현하여 서버 시작 시 RDB 파일에서 데이터를 복원하는 과정을 다루어보겠다.
참고