들어가며
이전 포스팅에서 구현 했던 Encoder와 SAVE 명령으로 데이터가 바이너리 파일로 저장되어 생성되긴 하지만 아직 복원 기능이 없어서 서버를 재시작하면 저장된 바이너리 파일이 있어도 해당 바이너리 파일을 역직렬화하고 로드하는 기능은 구현하지 않았기때문에 데이터는 전부 사라진다. 이번 포스팅에서는 바이너리 파일을 읽어 메모리 자료구조로 복원하는 Decoder를 구현하고 CRC32 Checksum으로 파일이 손상되지 않았는지 검증하는 메커니즘을 추가해보자. 서버 시작 시 자동으로 RDB 파일을 로드하여 영속성 사이클을 완성하는 것이 최종 목표다.
데이터 무결성
데이터 무결성은 데이터가 저장, 전송, 처리되는 전 과정에서 의도하지 않은 변경이 발생하지 않았음을 보장하는 성질이다. 데이터베이스 이론에서는 ACID의 Consistency와 연결되지만 여기서 다루는 것은 더 낮은 레벨의 문제인 파일 자체가 물리적으로 온전한가에 대한 것이다.
바이너리 파일이 손상되는 경로는 다양하다.
디스크 오류 - 하드디스크의 섹터가 물리적으로 손상되어 저장된 비트가 뒤집히는 경우이다.
불완전한 쓰기 - SAVE 도중 프로세스가 강제 종료되면 파일의 앞부분만 기록되고 나머지는 이전 데이터나 쓰레기 값이 남는다
전원 차단 - 운영체제의 쓰기 버퍼가 디스크에 플러시되기 전에 전원이 끊기면 파일 시스템 수준에서 데이터가 유실된다
이런 상황에서 손상된 파일을 그대로 로드하면 엉뚱한 데이터가 복원되거나, 바이너리 파서가 잘못된 오프셋을 읽어 예측할 수 없는 동작을 한다. 손상을 감지하는 것이 첫 번째 방어선이며, 그 핵심 도구가 체크섬이다.
Checksum
checksum은 데이터 블록에 대해 고정 크기의 값을 계산하여 데이터와 함께 저장하는 무결성 검증 기법이다. 나중에 데이터를 읽을 때 같은 알고리즘으로 다시 계산하고 저장된 값과 비교한다.
단순 합산(additive checksum)부터 해시 기반 체크섬까지 다양한 알고리즘이 있다. 단순 합산은 각 바이트를 더한 값을 체크섬으로 사용하는데, 바이트 순서가 바뀌어도 합이 같으면 감지하지 못하는 한계가 있다. 파일 저장에서는 이보다 강력한 CRC를 사용한다.
CRC
CRC(Cyclic Redundancy Check, 순환 중복 검사)는 데이터 전송과 저장에서 오류를 감지하기 위해 설계된 해시 함수다. 디지털 통신에서 가장 널리 쓰이는 오류 감지 코드 중 하나다.
CRC의 핵심 원리는 다항식 나눗셈(Polynomial Division) 이다. 데이터를 하나의 큰 이진수로 취급하고, 미리 정해진 생성 다항식(Generator Polynomial)으로 모듈러-2 나눗셈을 수행한 나머지가 CRC 값이 된다. 모듈러-2 연산에서는 뺄셈이 XOR과 동일하기 때문에 하드웨어로도 매우 빠르게 구현할 수 있다.
CRC-32는 32비트(4바이트) 해시 값을 생성하며 IEEE 802.3 표준 다항식을 사용한다. 실제로 다양한 곳에서 CRC-32를 볼 수 있다.
| 프로토콜/포맷 | CRC 사용처 |
|---|---|
| Ethernet | 모든 프레임의 FCS(Frame Check Sequence) |
| ZIP / GZIP | 압축 파일 내부의 무결성 검증 |
| PNG | 각 청크의 무결성 검증 |
| ext4 | 파일 시스템 메타데이터 무결성 |
CRC-32의 오류 감지 능력은 아래와 같다.
- 모든 단일 비트 오류를 감지한다
- 모든 2비트 오류를 감지한다
- 32비트 이하의 연속 오류(Burst Error)를 100% 감지한다
- 32비트를 초과하는 Burst Error도 1 - 2^(-32)의 확률(99.99999998%)로 감지한다
한 가지 중요한 구분이 있다. CRC-32는 암호학적 해시가 아니다. SHA-256 같은 암호학적 해시는 의도적인 변조를 감지하도록 설계되지만 CRC는 우발적인 손상을 감지하는 데 최적화되어 있다. 동일한 CRC 값을 가진 다른 데이터를 만들어내는 것(충돌)이 의도적으로 가능하기 때문에 보안 용도로는 적합하지 않다. 하지만 디스크 오류나 불완전한 쓰기 같은 우발적 손상을 감지하는 데는 빠르고 효과적이다.
go의 표준 라이브러리에서는 hash/crc32 패키지로 사용할 수 있다.
import "hash/crc32"
// 방법 1: 한 번에 계산
checksum := crc32.ChecksumIEEE(data) // []byte → uint32
// 방법 2: 스트리밍 방식 (데이터를 나눠서 넣을 수 있음)
h := crc32.NewIEEE()
h.Write(part1)
h.Write(part2)
checksum := h.Sum32()
방법 2의 스트리밍 방식은 hash.Hash32 인터페이스를 구현한다. 데이터를 조각조각 write하면서 해시를 누적하고 마지막에 Sum32()로 최종 값을 얻는다. encoder처럼 데이터를 순차적으로 쓰면서 동시에 해시를 계산하는 용도에 적합하다.
역직렬화와 방어적 파싱
역직렬화
바로 직전의 포스팅에서는 메모리의 자료구조를 바이트 시퀀스로 변환하는 직렬화 과정을 다루었지만, 이번에는 역방향으로 바이트 시퀀스를 읽어서 원래의 자료구조를 메모리에 복원하는 역직렬화 과정이 필요하다.
encoder와 decoder는 대칭 관계다. encoder가 writeUint32 -> writeString -> writeExpiry 순서로 썼다면, decoder는 readUint32 -> readString -> readExpiry 순서로 읽는다. 쓰는 순서와 읽는 순서가 정확히 일치해야 바이트 경계가 맞는다. 한 바이트라도 어긋나면 이후의 모든 파싱이 틀어진다.
방어적 파싱
encoder는 자기가 쓰는 데이터를 신뢰할 수 있지만, decoder는 외부에서 온 데이터를 읽는다. 파일이 손상되었거나 다른 프로그램이 만든 파일이거나, 버전이 다른 포맷일 수 있다. 방어적 파싱은 이런 상황에서 파서가 안전하게 실패하도록 설계하는 원칙이다.
구체적으로 decoder에서 방어적 파싱이 적용되는 지점은 아래와 같다.
- Magic Bytes 검증 - 파일의 첫 6바이트가
MINIDB(서버에서 미리 상수로 선언한 매직바이트)인지 확인한다. 아니면 즉시 에러를 반환하여 엉뚱한 파일을 파싱하는 것을 방지한다 - 버전 검증 - Version 바이트가 지원하는 버전인지 확인한다. 미래 버전의 포맷을 현재 decoder가 파싱하면 예측할 수 없는 동작이 발생할 수 있다
- 알 수 없는 타입 처리 - Entry의 Type 바이트가
0x00(String)도0x01(List)도 아니면 에러를 반환한다. 파일이 손상되어 Type 바이트가 변조된 경우를 감지한다 - EOF 확인 -
0xFF(EOF)를 만나면 정상적으로 파싱을 종료한다. 파일이 중간에 잘렸다면 EOF 없이io.ErrUnexpectedEOF가 발생한다
파일 포맷 변경
이전 포스팅에서 설계한 포맷에 CRC32 체크섬 4바이트를 추가한다. EOF 마커 뒤에 기록한다.
[Header] MINIDB + Version 7 bytes
[Entry] 타입별 엔트리 반복 가변
[EOF] 0xFF 1 byte
[CRC32] 체크섬 (Big Endian) 4 bytes <- 추가
체크섬의 계산 범위는 헤더부터 EOF까지의 모든 바이트다. CRC32 값 자체는 계산에 포함하지 않는다. 만약 체크섬을 계산에 포함하면, 체크섬이 바뀔 때마다 해시가 바뀌고 바뀐 해시로 다시 체크섬이 바뀌는 순환 문제가 발생한다.
전체 코드
format.go
ChecksumSize 상수를 추가한다. CRC32는 4바이트다.
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
ChecksumSize = 4 // 추가
)
encoder.go
Encoder 구조체에 hash.Hash32 필드를 추가하고 데이터를 쓸 때마다 동시에 해시를 누적한다. WriteChecksum 메서드가 추가되었다.
package persistence
import (
"bufio"
"encoding/binary"
"hash"
"hash/crc32"
"io"
"time"
)
type Encoder struct {
w *bufio.Writer
hash hash.Hash32
}
func NewEncoder(w io.Writer) *Encoder {
return &Encoder{
w: bufio.NewWriter(w),
hash: crc32.NewIEEE(),
}
}
func (e *Encoder) WriteHeader() error {
if err := e.writeBytes(MagicBytes[:]); err != nil {
return err
}
return e.writeBytes([]byte{Version})
}
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)
}
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)
}
func (e *Encoder) WriteEOF() error {
return e.writeBytes([]byte{EOF})
}
func (e *Encoder) Flush() error {
return e.w.Flush()
}
func (e *Encoder) WriteChecksum() error {
checksum := e.hash.Sum32()
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, checksum)
_, err := e.w.Write(buf)
return err
}
// ========== 헬퍼 메서드 ==========
func (e *Encoder) writeBytes(data []byte) error {
_, err := e.w.Write(data)
if err == nil {
e.hash.Write(data)
}
return err
}
func (e *Encoder) writeUint32(n uint32) error {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, n)
return e.writeBytes(buf)
}
func (e *Encoder) writeInt64(n int64) error {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(n))
return e.writeBytes(buf)
}
func (e *Encoder) writeString(s string) error {
if err := e.writeUint32(uint32(len(s))); err != nil {
return err
}
return e.writeBytes([]byte(s))
}
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())
}
decoder.go
Encoder의 역방향으로 바이너리 데이터를 읽어 메모리 자료구조로 복원하는 Decoder다.
package persistence
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"hash/crc32"
"io"
"os"
"time"
)
type Decoder struct {
r *bufio.Reader
}
type DecodedEntry struct {
Type byte
Key string
Value string
Values []string
ExpireAt *time.Time
}
func NewDecoder(r io.Reader) *Decoder {
return &Decoder{bufio.NewReader(r)}
}
func (d *Decoder) ReadHeader() error {
magicByte, err := d.readBytes(6)
if err != nil {
return err
}
if !bytes.Equal(magicByte, MagicBytes[:]) {
return fmt.Errorf("invalid magic bytes")
}
version, err := d.readBytes(1)
if err != nil {
return err
}
if version[0] != Version {
return fmt.Errorf("버전이 불일치합니다.")
}
return nil
}
func (d *Decoder) ReadEntry() (*DecodedEntry, error) {
typeBuf, err := d.readBytes(1)
if err != nil {
return nil, err
}
if typeBuf[0] == EOF {
return nil, nil
}
key, err := d.readString()
if err != nil {
return nil, err
}
entry := &DecodedEntry{Type: typeBuf[0], Key: key}
switch typeBuf[0] {
case TypeString:
value, err := d.readString()
if err != nil {
return nil, err
}
entry.Value = value
case TypeList:
count, err := d.readUint32()
if err != nil {
return nil, err
}
values := make([]string, 0, count)
for i := 0; i < int(count); i++ {
v, err := d.readString()
if err != nil {
return nil, err
}
values = append(values, v)
}
entry.Values = values
default:
return nil, fmt.Errorf("unknown entry type: 0x%02x", typeBuf[0])
}
expireAt, err := d.readExpiry()
if err != nil {
return nil, err
}
entry.ExpireAt = expireAt
return entry, nil
}
func VerifyChecksum(path string) error {
file, err := os.ReadFile(path)
if err != nil {
return err
}
if len(file) < ChecksumSize {
return fmt.Errorf("파일의 크기가 Checksum 사이즈보다 작습니다.")
}
content := file[:len(file)-ChecksumSize]
stored := binary.BigEndian.Uint32(file[len(file)-ChecksumSize:])
computed := crc32.ChecksumIEEE(content)
if stored != computed {
return fmt.Errorf(
"Checksum이 일치하지 않습니다: stored=0x%08x, computed=0x%08x",
stored,
computed,
)
}
return nil
}
// ========== 헬퍼 메서드 ==========
func (d *Decoder) readBytes(n int) ([]byte, error) {
buf := make([]byte, n)
_, err := io.ReadFull(d.r, buf)
return buf, err
}
func (d *Decoder) readUint32() (uint32, error) {
buf, err := d.readBytes(4)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint32(buf), nil
}
func (d *Decoder) readInt64() (int64, error) {
buf, err := d.readBytes(8)
if err != nil {
return 0, err
}
return int64(binary.BigEndian.Uint64(buf)), nil
}
func (d *Decoder) readString() (string, error) {
length, err := d.readUint32()
if err != nil {
return "", err
}
buf, err := d.readBytes(int(length))
if err != nil {
return "", err
}
return string(buf), nil
}
func (d *Decoder) readExpiry() (*time.Time, error) {
buf, err := d.readBytes(1)
if err != nil {
return nil, err
}
if buf[0] == NoExpiry {
return nil, nil
}
ms, err := d.readInt64()
if err != nil {
return nil, err
}
t := time.UnixMilli(ms)
return &t, nil
}
store.go - Save + Load
Save에 WriteChecksum 호출이 추가되었고, Load 메서드가 새로 구현되었다.
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 {
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()
encoder.WriteChecksum()
return encoder.Flush()
}
func (s *Store) Load(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if err := persistence.VerifyChecksum(path); err != nil {
return err
}
content := data[:len(data)-persistence.ChecksumSize]
decoder := persistence.NewDecoder(bytes.NewReader(content))
if err := decoder.ReadHeader(); err != nil {
return err
}
for {
entry, err := decoder.ReadEntry()
if err != nil {
return err
}
if entry == nil {
break
}
if entry.ExpireAt != nil && entry.ExpireAt.Before(time.Now()) {
continue
}
switch entry.Type {
case persistence.TypeString:
s.data[entry.Key] = &Entry{
Type: TypeString,
Str: entry.Value,
ExpireAt: entry.ExpireAt,
}
case persistence.TypeList:
list := NewList()
for _, v := range entry.Values {
list.RPush(v)
}
s.data[entry.Key] = &Entry{
Type: TypeList,
List: list,
ExpireAt: entry.ExpireAt,
}
}
if entry.ExpireAt != nil {
s.heap.Push(&HeapItem{
Key: entry.Key,
ExpireAt: *entry.ExpireAt,
})
}
}
return nil
}
server.go
Start()에서 accept 루프 진입 전에 RDB 파일을 로드한다.
func (s *Server) Start() error {
listener, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
s.listener = listener
defer s.listener.Close()
log.Printf("현재 서버가 [%s] 에서 리스닝중입니다.", s.addr)
if err := s.store.Load("dump.rdb"); err != nil {
log.Printf("RDB 로딩 실패: %v", err)
} else {
log.Println("RDB 파일 로딩 완료")
}
s.store.StartExpiry()
defer s.store.StopExpiry()
for {
conn, err := s.listener.Accept()
if err != nil {
log.Print("연결 중 오류: ", err)
continue
} else {
go s.handleConnection(conn)
}
}
}
코드 분석
Encoder의 이중 쓰기 - hash 필드
Encoder에는 bufio.Writer 하나만 있었다. 체크섬 지원을 위해 hash.Hash32 필드가 추가되었고, writeBytes가 두 곳에 동시에 쓰는 구조로 바뀌었다.
func (e *Encoder) writeBytes(data []byte) error {
_, err := e.w.Write(data)
if err == nil {
e.hash.Write(data)
}
return err
}
e.w.Write로 버퍼에 데이터를 쓰고, 성공하면 e.hash.Write로 같은 데이터를 해시에도 넣는다. 모든 상위 메서드(writeString, WriteStringEntry 등)가 writeBytes를 통해 데이터를 쓰기 때문에, 헤더부터 EOF까지 기록되는 모든 바이트가 자동으로 해시에 누적된다.
WriteChecksum - writeBytes를 쓰지 않는 이유
func (e *Encoder) WriteChecksum() error {
checksum := e.hash.Sum32()
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, checksum)
_, err := e.w.Write(buf) // writeBytes가 아닌 e.w.Write를 직접 사용
return err
}
WriteChecksum은 의도적으로 writeBytes를 거치지 않고 e.w.Write를 직접 호출한다. writeBytes를 사용하면 위에서 설명했듯 체크섬 바이트가 e.hash에 포함되어 자기 자신이 해시에 들어가는 순환 문제가 발생한다. 체크섬은 헤더부터 EOF까지의 데이터에 대해서만 계산되어야 하므로 체크섬 자체는 해시 계산에서 제외해야 한다.
Decoder의 레이어 구조
Encoder의 레이어 구조와 대칭적으로, Decoder도 동일한 레이어 계층을 가진다.
가장 아래 readBytes는 io.ReadFull로 n바이트만큼 읽는다. readUint32는 4바이트를 읽어 binary.BigEndian.Uint32로 변환하고, readString은 readUint32(길이) + readBytes(데이터)를 조합한다. encoder의 writeString이 길이를 쓰고 -> 데이터를 쓰는 순서였으니, decoder의 readString은 길이를 읽고 -> 그만큼 데이터를 읽는 순서로 대응된다.
ReadEntry - EOF와 알 수 없는 타입
func (d *Decoder) ReadEntry() (*DecodedEntry, error) {
typeBuf, err := d.readBytes(1)
if err != nil {
return nil, err
}
if typeBuf[0] == EOF {
return nil, nil // 정상 종료
}
// ...
default:
return nil, fmt.Errorf("unknown entry type: 0x%02x", typeBuf[0])
}
ReadEntry의 반환 타입은 (*DecodedEntry, error)이다. 세 가지 상태를 표현한다.
(entry, nil)- 정상적으로 엔트리를 읽은 경우(nil, nil)— EOF를 만나서 더 이상 읽을 엔트리가 없는 경우(nil, error)- 파싱 에러가 발생한 경우
(nil, nil) 반환은 go에서 스트림의 끝을 표현하는 일반적인 패턴이다. 호출하는 쪽에서는 entry == nil이면 루프를 종료하고, err != nil이면 에러를 전파한다. switch문의 default 케이스에서는 앞 서 말한 방어적 파싱이 적용되어 알 수 없는 타입 바이트를 만나면 16진수 값을 포함한 에러 메시지를 반환한다.
VerifyChecksum - 체크섬 분리와 비교
func VerifyChecksum(path string) error {
file, err := os.ReadFile(path)
// ...
content := file[:len(file)-ChecksumSize]
stored := binary.BigEndian.Uint32(file[len(file)-ChecksumSize:])
computed := crc32.ChecksumIEEE(content)
if stored != computed {
return fmt.Errorf(
"Checksum이 일치하지 않습니다: stored=0x%08x, computed=0x%08x",
stored, computed,
)
}
return nil
}
VerifyChecksum은 decoder의 메서드가 아니라 패키지 수준의 함수다. 파일 경로를 받아 os.ReadFile로 전체를 읽고 마지막 4바이트를 체크섬으로 분리한다. 나머지 바이트(헤더부터 EOF까지)에 대해 crc32.ChecksumIEEE로 CRC32를 계산하고 저장된 값과 비교한다.
체크섬이 불일치하면 파일이 손상된 것이므로 파싱을 시도할 필요가 없기때문에 decoder보다 먼저 호출된다.
Store.Load - 체크섬 분리 후 디코딩
content := data[:len(data)-persistence.ChecksumSize]
decoder := persistence.NewDecoder(bytes.NewReader(content))
Load에서 bytes.NewReader를 사용하는 이유는, os.ReadFile은 []byte를 반환하지만 NewDecoder는 io.Reader를 받는다. bytes.NewReader가 []byte를 io.Reader로 변환하는 어댑터 역할을 한다. 여기서 중요한 것은 체크섬 4바이트를 잘라낸 뒤 decoder에 넘긴다는 점이다. 체크섬 바이트를 포함하면 decoder가 EOF 이후에 추가 데이터를 만나 파싱 에러가 발생한다.
파일이 존재하지 않을 때의 처리도 눈여겨볼 부분이다.
if os.IsNotExist(err) {
return nil
}
os.IsNotExist로 파일이 없는 것과 파일을 읽다가 실패한 것을 구분한다. 서버를 처음 실행하면 dump.rdb가 없는 것이 정상이므로 에러 없이 빈 상태로 시작한다. 디스크 권한 문제 등 다른 에러는 그대로 전파한다.
만료된 키 처리는 Save와 동일한 패턴이다. entry.ExpireAt.Before(time.Now())로 이미 만료된 키를 건너뛴다. TTL이 있는 키는 Store의 최소 힙에도 등록하여 Active Deletion이 정상 동작하도록 한다.
테스트
TestReadStringEntry
func TestReadStringEntry(t *testing.T) {
// given: Header + String("hello", "world") + EOF
data := encodeToBytes(t, func(enc *Encoder) {
enc.WriteHeader()
enc.WriteStringEntry("hello", "world", nil)
enc.WriteEOF()
})
decoder := NewDecoder(bytes.NewReader(data))
decoder.ReadHeader()
// when
entry, err := decoder.ReadEntry()
// then
if err != nil {
t.Fatalf("에러 발생: %v", err)
}
if entry.Type != TypeString {
t.Fatalf("Type: 0x%02x, expected: 0x%02x", entry.Type, TypeString)
}
if entry.Key != "hello" {
t.Fatalf("Key: %s, expected: hello", entry.Key)
}
if entry.Value != "world" {
t.Fatalf("Value: %s, expected: world", entry.Value)
}
if entry.ExpireAt != nil {
t.Fatalf("ExpireAt이 nil이어야 합니다")
}
}
encoder로 인코딩한 데이터를 decoder로 다시 읽어 원본과 일치하는지 확인하는 테스트이다. encodeToBytes 헬퍼가 bytes.Buffer에 인코딩하고 바이트 슬라이스를 반환하면 bytes.NewReader로 감싸서 decoder에 넘긴다. 실제 파일 없이 메모리에서 인코딩-디코딩 사이클 전체를 검증할 수 있다.
TestVerifyChecksum_Corrupted
func TestVerifyChecksum_Corrupted(t *testing.T) {
// given: 정상 파일 작성 후 중간 바이트 변조
path := filepath.Join(t.TempDir(), "corrupted.rdb")
file, _ := os.Create(path)
enc := NewEncoder(file)
enc.WriteHeader()
enc.WriteStringEntry("key", "value", nil)
enc.WriteEOF()
enc.WriteChecksum()
enc.Flush()
file.Close()
// 파일 변조: 10번째 바이트를 뒤집는다
data, _ := os.ReadFile(path)
data[10] ^= 0xFF
os.WriteFile(path, data, 0644)
// when
err := VerifyChecksum(path)
// then
if err == nil {
t.Fatal("변조된 파일에 대해 에러가 발생해야 합니다")
}
}
정상적으로 작성된 파일의 바이트 하나를 XOR로 뒤집은 뒤 체크섬 검증을 시도한다. data[10] ^= 0xFF는 10번째 바이트의 모든 비트를 반전시킨다. CRC32가 단일 비트 오류도 감지하므로 전체 바이트가 변경되면 당연히 불일치가 발생한다.
TestLoad_StringEntries
func TestLoad_StringEntries(t *testing.T) {
// given: Save로 String 엔트리 저장
store := New()
store.Set("name", "gopher")
store.Set("lang", "go")
path := filepath.Join(t.TempDir(), "string.rdb")
store.Save(path)
// when: 새 Store에 Load
loaded := New()
err := loaded.Load(path)
// then
if err != nil {
t.Fatalf("에러 발생: %v", err)
}
v1, ok1 := loaded.Get("name")
if !ok1 || v1 != "gopher" {
t.Fatalf("name: %s, exist: %v", v1, ok1)
}
v2, ok2 := loaded.Get("lang")
if !ok2 || v2 != "go" {
t.Fatalf("lang: %s, exist: %v", v2, ok2)
}
}
Save -> Load -> Get으로 이어지는 통합 테스트다. 원본 store에 데이터를 넣고 save한 뒤, 완전히 새로운 store에 load하여 데이터가 복원되는지 확인한다. 두 개의 독립적인 store 인스턴스를 사용하여 메모리 공유가 아닌 파일을 통한 복원임을 보장한다.
TestSaveAndLoad - 서버 통합 테스트
func TestSaveAndLoad(t *testing.T) {
// given: 서버1에서 SET + SAVE
server1 := New(":16379")
go server1.Start()
time.Sleep(time.Second)
conn1, _ := net.Dial("tcp", "localhost:16379")
reader1 := bufio.NewReader(conn1)
conn1.Write([]byte("*3\r\n$3\r\nSET\r\n$8\r\nload-key\r\n$5\r\nhello\r\n"))
reader1.ReadString('\n')
conn1.Write([]byte("*1\r\n$4\r\nSAVE\r\n"))
saveResp, _ := reader1.ReadString('\n')
if saveResp != "+OK\r\n" {
t.Fatalf("SAVE 응답: %s", saveResp)
}
conn1.Close()
// when: 새 서버2를 같은 dump.rdb로 시작
server2 := New(":16380")
go server2.Start()
time.Sleep(time.Second)
conn2, _ := net.Dial("tcp", "localhost:16380")
defer conn2.Close()
reader2 := bufio.NewReader(conn2)
conn2.Write([]byte("*2\r\n$3\r\nGET\r\n$8\r\nload-key\r\n"))
// then: 이전 서버에서 저장한 값이 복원됨
lenLine, _ := reader2.ReadString('\n')
if lenLine != "$5\r\n" {
t.Fatalf("GET 길이: %s", lenLine)
}
valLine, _ := reader2.ReadString('\n')
if valLine != "hello\r\n" {
t.Fatalf("GET 값: %s", valLine)
}
os.Remove("dump.rdb")
}
실제 사용 시나리오를 검증하는 테스트이다. 서버1에서 SET -> SAVE를 실행하고, 서버1과는 다른 포트의 서버2를 시작한다. 서버2는 Start() 내부에서 Load("dump.rdb")를 자동으로 호출하므로, GET으로 서버1에서 저장한 데이터가 복원되었는지 확인할 수 있다.
마치며
Encoder만 있을 때는 바이트를 파일에 쓰는 것에 집중했다면, Decoder를 구현하면서 잘못된 입력이 들어오면 어떻게 해야 하는가를 고민하게 됐다. Magic Bytes로 파일 형식을 확인하고 버전 번호로 호환성을 체크하고, 알 수 없는 타입에 에러를 반환하는 것 하나하나는 단순하지만 이런 방어 코드들이 모여 있어야 시스템이 어디서 어떻게 오류가 나고 실패하는지 알 수 있다.
그리고 CRC32 checksum은 4바이트의 작은 값이지만 파일 전체의 무결성을 보장한다. encoder에서 writeBytes로 해시를 누적하고 WriteChecksum에서만 해시를 거치지 않는 비대칭 설계, 검증 시 체크섬 바이트를 잘라내고 나머지 헤더부터 EOF까지만 decoder에 넘기는 흐름 등 구현 과정에서 왜 이 바이트는 해시에 포함하고 저 바이트는 제외해야 하는가를 명확히 이해해야 했다. 어떻게 보면 단순하고 당연한 얘기지만 필자가 처음에 직접 구현할때 writeBytes 를 호출하였었다. 이론적으로는 이해를 했지만 내가 이해한 이론과는 상충되는 코드로 구현을 했었기에 이런데서 더 공부가 되는 것 같다. (코드 한 줄 한 줄의 의도를 더 정확히 파악하도록 노력해보자.)
이제 Save와 Load가 모두 동작하면서 서버를 재시작해도 데이터가 유지하게 되었다. SAVE 명령을 실행하면 메모리 상태가 dump.rdb에 기록되고 서버가 다시 켜지면 서버 라이프사이클에 Load 함수로 인해 자동으로 복원된다. 메모리 전용이었던 데이터베이스에 영속성이라는 새로운 성질이 추가된 셈이다.
참고