Key-Value: 키로 값을 직접 찾는 가장 단순한 데이터 모델
키-값(Key-Value) 저장소는 고유한 키 하나로 값 하나를 저장하고 조회하는 데이터 모델이다. 해시 테이블(Hash Table)을 기반으로 평균 O(1) 접근을 제공하며, 캐시·세션·분산 설정 저장소에 널리 쓰인다. 단순한 대신 범위 조회나 조인 같은 복잡한 질의는 제한적이므로 접근 패턴에 맞춰 키를 설계해야 한다.
By 송민성
1. 개념
키-값(Key-Value) 모델은 고유한 키(key) 와 그에 대응하는 값(value) 의 쌍으로 데이터를 저장하는 방식이다. 연산은 기본적으로 세 가지다.
put(key, value): 저장 또는 덮어쓰기get(key): 키로 값 조회delete(key): 키 삭제
값은 데이터베이스가 내부 구조를 해석하지 않는 불투명한(opaque) 바이트 덩어리로 취급하는 경우가 많다. 문자열, JSON, 바이너리 모두 값이 될 수 있다. 일부 저장소(Redis 등)는 값으로 리스트, 해시, 집합 같은 자료구조를 제공하기도 한다.
프로그래밍 언어의 딕셔너리(dict), 맵(Map), 객체와 개념적으로 같고, 이를 프로세스 밖으로 확장해 네트워크 너머의 저장소로 만든 것이 키-값 저장소(Key-Value Store)다.
2. 왜 사용하는가
- 단순한 접근 모델: 질의 언어 파싱, 조인, 쿼리 플래너 같은 비용이 없어 지연 시간이 짧다.
- 수평 확장(Horizontal Scaling): 데이터가 키 단위로 독립적이라 키를 기준으로 샤딩(Sharding)하기 쉽다.
- 유연한 스키마: 값의 구조를 저장소가 강제하지 않는다.
- 캐시 용도: 비싼 계산이나 DB 조회 결과를 키로 저장해 반복 비용을 줄인다.
관계형 데이터베이스가 "데이터 간 관계와 임의 질의"에 강하다면, 키-값 저장소는 "키를 알고 있을 때 빠르게 읽고 쓰기"에 특화되어 있다.
3. 동작 원리
해시 기반 인덱싱
가장 흔한 구현은 해시 테이블(Hash Table)이다. 키에 해시 함수(hash function)를 적용해 버킷(bucket) 위치를 계산하고, 그 위치에 값을 저장한다.
index = hash(key) mod 버킷 수- 해당 버킷에서 키를 비교해 값을 찾는다.
- 서로 다른 키가 같은 버킷에 들어가면 충돌(collision)이 발생하며, 체이닝(chaining)이나 개방 주소법(open addressing)으로 해결한다.
- 적재율(load factor)이 높아지면 버킷 수를 늘리고 재해시(rehash)한다.
영속성(Persistence)
메모리 기반 저장소는 디스크 영속성을 위해 보통 다음 방식을 쓴다.
- 로그 기반 저장: 쓰기를 로그 파일 끝에 순차 추가(append-only)하고, 메모리의 해시 인덱스가 각 키의 최신 로그 위치를 가리킨다. Bitcask가 대표적이다.
- LSM 트리(Log-Structured Merge-tree): 메모리의 정렬된 구조(memtable)에 쓰고, 이를 정렬된 파일(SSTable)로 플러시한 뒤 백그라운드에서 병합(compaction)한다. LevelDB, RocksDB가 이 계열이다.
- 스냅샷/AOF: Redis는 특정 시점의 스냅샷(RDB)과 쓰기 명령을 기록하는 AOF(Append Only File)를 제공한다.
분산 환경
키를 여러 노드에 나눌 때는 hash(key) mod N 방식이 단순하지만, 노드 수가 바뀌면 대부분의 키가 재배치된다. 이를 줄이기 위해 일관된 해싱(Consistent Hashing) 을 사용한다. 해시 공간을 원형으로 보고 노드와 키를 같은 공간에 배치해, 노드가 추가·제거될 때 인접 구간의 키만 이동하게 한다.
4. 코드 예제
4-1. 체이닝 방식 해시 테이블 직접 구현 (Python)
class KVStore:
def __init__(self, capacity: int = 8):
self._buckets = [[] for _ in range(capacity)]
self._size = 0
def _index(self, key: str) -> int:
return hash(key) % len(self._buckets)
def put(self, key: str, value) -> None:
bucket = self._buckets[self._index(key)]
for i, (k, _) in enumerate(bucket):
if k == key:
bucket[i] = (key, value) # 덮어쓰기
return
bucket.append((key, value))
self._size += 1
if self._size / len(self._buckets) > 0.75:
self._resize()
def get(self, key: str, default=None):
for k, v in self._buckets[self._index(key)]:
if k == key:
return v
return default
def delete(self, key: str) -> bool:
bucket = self._buckets[self._index(key)]
for i, (k, _) in enumerate(bucket):
if k == key:
del bucket[i]
self._size -= 1
return True
return False
def _resize(self) -> None:
old = self._buckets
self._buckets = [[] for _ in range(len(old) * 2)]
self._size = 0
for bucket in old:
for k, v in bucket:
self.put(k, v)
if __name__ == "__main__":
store = KVStore()
for i in range(20):
store.put(f"user:{i}", {"id": i})
print(store.get("user:7")) # {'id': 7}
store.delete("user:7")
print(store.get("user:7")) # None4-2. Redis로 캐시와 TTL 사용하기 (Shell)
redis-cli SET user:42:profile '{"name":"kim"}' EX 60
redis-cli GET user:42:profile
redis-cli TTL user:42:profile
redis-cli DEL user:42:profileEX 60은 60초 뒤 키가 만료되도록 설정한다.
4-3. 캐시 어사이드(Cache-Aside) 패턴 (TypeScript)
type Entry<V> = { value: V; expiresAt: number };
class TTLCache<V> {
private store = new Map<string, Entry<V>>();
constructor(private ttlMs: number) {}
get(key: string): V | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
set(key: string, value: V): void {
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
}
async function fetchUserFromDb(id: number): Promise<{ id: number; name: string }> {
await new Promise((r) => setTimeout(r, 100)); // DB 지연 흉내
return { id, name: `user-${id}` };
}
const cache = new TTLCache<{ id: number; name: string }>(5_000);
async function getUser(id: number) {
const key = `user:${id}`;
const cached = cache.get(key);
if (cached) return cached; // 캐시 히트
const user = await fetchUserFromDb(id); // 캐시 미스
cache.set(key, user);
return user;
}
(async () => {
console.time("first");
await getUser(1);
console.timeEnd("first");
console.time("second");
await getUser(1);
console.timeEnd("second");
})();4-4. 관계형 DB에서 키-값 형태 쓰기 (SQL, PostgreSQL)
CREATE TABLE kv (
key text PRIMARY KEY,
value jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO kv (key, value)
VALUES ('config:theme', '{"mode":"dark"}')
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now();
SELECT value FROM kv WHERE key = 'config:theme';5. 시간 복잡도 또는 성능 특성
해시 테이블 기반 구현의 get, put, delete는 평균 O(1), 모든 키가 한 버킷에 몰리는 최악의 경우 체이닝 기준 O(n) 이다. 재해시(resize)가 일어나는 put은 해당 시점에 O(n) 비용이 들지만, 확장 비율을 일정하게 유지하면 분할 상환(amortized) 기준 O(1)이다.
| 연산 | 해시 기반 (평균) | 정렬 구조(LSM/B-트리) |
|---|---|---|
| 단일 키 조회 | O(1) | O(log n) |
| 단일 키 쓰기 | O(1) | LSM은 쓰기 경로가 순차 기록 중심, B-트리는 O(log n) |
| 범위 조회 | 지원 안 함(전체 순회 O(n)) | 효율적 지원 |
해시 기반은 키 순서를 보존하지 않아 범위 조회에 약하다. 범위 조회가 필요하면 정렬된 구조를 쓰는 저장소를 고려해야 한다.
6. 실무 사용 사례
- 캐시: DB 조회 결과, 렌더링된 페이지 조각 저장 (Redis, Memcached)
- 세션 저장소: 세션 ID를 키, 사용자 상태를 값으로 저장
- 분산 설정/조정: etcd 같은 저장소가 설정과 리더 선출 정보를 키-값으로 관리
- 레이트 리미팅(Rate Limiting):
ratelimit:{userId}키에 카운터와 TTL 사용 - 대규모 서비스 저장소: DynamoDB 같은 관리형 서비스가 파티션 키 기반으로 확장
키 네이밍은 도메인:식별자:속성(예: user:42:profile)처럼 일관된 규칙을 두면 관리와 삭제가 쉬워진다.
7. 주의할 점
- 범위·조건 조회 한계: "나이가 30 이상인 사용자" 같은 질의는 별도 인덱스를 직접 설계해야 한다.
- 핫 키(Hot Key): 특정 키에 요청이 몰리면 해당 노드가 병목이 된다.
- 캐시 무효화(Invalidation): 원본 데이터가 바뀌어도 캐시가 오래된 값을 반환할 수 있다. TTL과 명시적 삭제를 함께 설계한다.
- 캐시 스탬피드(Cache Stampede): 인기 키가 만료되는 순간 다수 요청이 동시에 DB로 몰릴 수 있다. 락, 만료 시간 분산(jitter) 등으로 완화한다.
- 큰 값 문제: 값이 매우 크면 네트워크와 메모리 부담이 커진다. 크기를 제한하는 것이 안전하다.
- 영속성 보장 수준: 메모리 기반 저장소는 설정에 따라 장애 시 최근 쓰기가 유실될 수 있다. 용도(캐시 vs 원본 저장소)에 맞게 설정한다.
- 키 순회 명령: Redis의
KEYS *는 전체 키를 훑으므로 운영 환경에서 피하고SCAN을 사용한다.
8. 핵심 정리
- 키-값 모델은
put/get/delete로 이루어진 가장 단순한 데이터 모델이다. - 해시 테이블 기반이면 단일 키 연산이 평균 O(1)이며, 범위 조회는 약하다.
- 영속성은 append-only 로그, LSM 트리, 스냅샷 등으로 확보한다.
- 분산 환경에서는 일관된 해싱으로 노드 변경 시 재배치를 최소화한다.
- 캐시, 세션, 설정 저장 등 "키를 알고 조회하는" 패턴에 적합하며, 접근 패턴에 맞춘 키 설계가 성능과 운영성을 좌우한다.