9장: 최종 프로젝트 — 키-값 스토어

지금까지의 모든 장은 한 번에 하나의 아이디어를 독립적으로 소개했습니다. 이 마지막 장은 그 반대입니다: 이 책이 다룬 거의 모든 것 — 구조체와 메서드, ?T와 match, defer, 그리고 표준 라이브러리의 HashMap — 에 기대어, 작지만 정말로 쓸모 있는 것 하나를 만듭니다: 인메모리 키-값 스토어입니다.

타입 설계하기

키-값 스토어에는 항목을 보관할 곳과 그에 대한 몇 가지 연산이 필요합니다. 문자열 키 해시 맵인 std::StrMap<V>가 무거운 작업을 대신 해 주고, Store는 우리가 실제로 노출하고 싶은 연산들로 그것을 감쌉니다.

c
#include <std/collections/map.h>
#include <std/collections/string.h>

struct Store {
    struct StrMap<struct String> entries;

    void init();
    void set(const &char key, const &char value);
    ?struct String get(const &char key);
    int remove(const &char key);
    void drop();
};

get은 ?struct String을 반환합니다 — 키가 존재하면 저장된 값의 복사본을, 존재하지 않으면 null을 반환합니다. 이것이 바로 8장의 패턴이 정확히 제 역할을 하는 모습입니다: "아무것도 못 찾을 수 있다"는 사실이 반환 타입에 바로 드러나 있고, 모든 호출자는 값을 얻기 전에 두 경우 모두를 처리해야 합니다.

구현하기

c
void Store::init() {
    self.entries = std::StrMap::new();
}

void Store::set(const &char key, const &char value) {
    self.entries.insert(key, std::String::from(value));   // replacing drops the old value
}

맵은 제네릭이므로 자신이 struct String을 저장한다는 것을 압니다 — 어디에도 캐스트가 없습니다. 맵은 키의 복사본과 String 값을 모두 소유합니다: 이미 있는 키로 삽입하면 맵이 교체되는 값을 drop하므로, set은 그냥 삽입입니다.

c
?struct String Store::get(const &char key) {
    match (self.entries.get(key)) {
        case some(v): return v.clone();
        case null:    return null;
    }
    return null;
}

맵의 get은 ?&String을 반환합니다: 맵 자체의 저장 공간 안을 가리키는 참조이거나 null입니다. Store::get은 그 참조 대신 복제본을 반환하는데, 그 참조는 맵이 다음에 리사이즈되거나 해당 항목이 덮어써지기 전까지만 유효하기 때문입니다 — 정확히 5장이 다루는 종류의 암묵적 라이프타임 의존성입니다. 복제되어 독립적으로 소유된 String은 이 질문 자체를 완전히 비켜갑니다.

c
int Store::remove(const &char key) {
    match (self.entries.remove(key)) {
        case some(old): { old.drop(); return 1; }
        case none:      return 0;
    }
    return 0;
}

void Store::drop() {
    self.entries.drop();   // drops every key and value
}

맵의 remove는 값을 우리에게 넘겨주므로(move) Store::remove가 그 값을 drop합니다. 맵에 남아 있는 것은 모두 맵의 것입니다: 맵을 drop하면 모든 키와 값이 함께 drop되므로 Store::drop은 호출 하나로 끝납니다 — 값을 먼저 직접 해제하면(값들에 대한 foreach) 두 번 해제하게 됩니다.

조립하기

c
extern int printf(const char* fmt, ...);

int main() {
    struct Store store;
    store.init();
    defer store.drop();

    store.set("name", "SafeC");
    store.set("kind", "language");
    store.set("name", "SafeC!");      // overwrites: the old value is dropped

    ?struct String name = store.get("name");
    match (name) {
        case none:    unsafe { printf("name: (not found)\n"); }
        case some(v): { unsafe { printf("name: %s\n", v.as_ptr()); } v.free(); }
    }

    int removed = store.remove("kind");
    unsafe { printf("removed kind: %d\n", removed); }

    ?struct String kind = store.get("kind");
    match (kind) {
        case none:    unsafe { printf("kind: (not found, as expected)\n"); }
        case some(v): { unsafe { printf("kind: %s\n", v.as_ptr()); } v.free(); }
    }

    return 0;
}
text
name: SafeC!
removed kind: 1
kind: (not found, as expected)

store.init(); 바로 뒤의 defer store.drop();는 8장의 "획득 바로 다음에 해제" 관용구를 한 번 더 보여줍니다 — 여러분이 직접 SafeC 프로그램을 작성할 때쯤에는 이 짝짓기가 챙겨야 할 무언가가 아니라 자동으로 몸에 밴 것처럼 느껴져야 합니다.

여기서 어디로 갈까

이 Store는 의도적으로 작게 만들어졌습니다 — 인메모리 전용이고, 영속성도 없고, 동시 접근도 없습니다. 이것을 확장하는 것은 이 책이 깊이 다룰 여유가 없었던 나머지 것들을 연습하는 합리적인 방법입니다.

  • 영속성: drop() 시점에 std::csv나 std::json으로 entries를 디스크에 직렬화하고, init() 시점에 다시 불러옵니다.
  • 동시성: std::sync의 뮤텍스로 entries에 대한 접근을 감싸고, std::spawn으로 생성한 스레드들 사이에서 하나의 Store를 공유합니다(동시성 참고).
  • 진짜 아레나: 항목들이 짧게 살고 일괄적으로 함께 지워진다면 (예: 요청 단위 캐시), 힙 기반 String 값을 &arena<R>로 할당된 것으로 바꾸고, 항목을 하나씩 해제하는 대신 배치 사이마다 아레나를 리셋합니다 — 5장이 정확히 이 트레이드오프를 다뤘습니다.

여기서부터는, 이 책이 소개한 어느 한 기능에 대한 완전한 세부 사항이 필요하다면 레퍼런스 섹션으로, 이 장이 기댔던 HashMap/String 조합을 넘어 std::가 제공하는 모든 것 — 컬렉션, 네트워킹, 암호화, 직렬화 등등 — 을 확인하려면 표준 라이브러리 섹션으로 갑니다. 모두 지금 여러분이 속속들이 알게 된 것과 정확히 같은 리전 및 안전성 규칙 위에 만들어져 있습니다.

읽어 주셔서 감사합니다 — 즐겁게 개발하시기 바랍니다.