2025년 12월 14일
[가상 면접 사례로 배우는 대규모 시스템 설계 기초] 6. 키-값 저장소 설계
- 키-값 저장소는 키-값 데이터베이스라고도 불리는 비 관계형(non-relational) 데이터베이스이다.
- 키-값 쌍에서의 키는 유일해야 한다.
- 6장에서는 키-값 쌍을 저장하고(put), 키에 달려있는 값을 꺼내는(get) 키-값 저장소를 설계해본다.
문제 이해 및 설계 범위 확정
- 완벽한 설계란 존재하지 않는다.
- 데이터의 일관성과 가용성 사이에서 타협적 결정을 내린 설계가 좋다.
- 6장에서는 다음 특성을 갖는 키-값 저장소를 설계할 것이다.
- 키-값 쌍의 크기는 10KB 이하이다.
- 큰 데이터를 저장할 수 있어야 한다.
- 높은 가용성을 제공해야 한다. 시스템에 장애가 생겨도 빨리 응답해야 한다.
- 높은 규모 확장성을 제공해야 한다. 트래픽 양에 따라 서버 증설/삭제 이루어져야 한다.
- 데이터 일관성 수준은 조정이 가능해야 한다.
- 응답 지연시간(latency)이 짧아야 한다.
단일 서버 키-값 저장소
- 한 대 서버만 사용하는 키-값 저장소를 설계하는 것은 쉽다.
- 그냥 키-값 쌍을 모두 메모리에 해시 테이블로 저장하는 것이다.
- 장점: 빠르고 직관적임
- 단점: 모든 데이터를 메모리 안에 두는 것이 불가능할 수도 있다.
- 개선책:
- 데이터를 압축한다.
- 자주 쓰이는 데이터만 메모리에 두고 나머지는 디스크에 저장한다.
분산 키-값 저장소
- 하지만 이렇게 개선한다고 해도, 한 대 서버로 부족한 때가 곧 찾아온다.
- 많은 데이터를 저장하려면 분산 키-값 저장소를 만들어 스케일 아웃을 할 필요가 있다.
- 분산 키-값 저장소는 분산 해시 테이블이라고도 불린다.
- 분산 시스템을 설계할 때는 CAP 정리를 이해하고 있어야 한다.
CAP 정리
- CAP 정리는 다음 세 가지 요구사항을 동시에 만족하는 분산 시스템을 설계하는 것은 불가능 하다는 정리이다.
- 데이터 일관성(consistency) - 클라이언트는 어떤 노드던 같은 데이터를 보게된다.
- 가용성(availability) - 클라이언트는 일부 노드에 장애가 발생하더라도 항상 응답을 받을 수 있다.
- 파티션 감내(partition tolerance) - 두 노드 사이에 네트워크 통신 장애가 일어나도 시스템은 동작한다.
- 키-값 저장소는 앞서 제시한 세 가지 요구사항 가운데 어느 두 가지를 만족하느냐에 따라 다음과 같이 분류할 수 있다.
- CP 시스템 - 가용성을 희생하더라도 일관성과 파티션 감내를 지원하는 저장소
- AP 시스템 - 데이터 일관성을 희생하더라도 가용성과 파티션 감내를 지원하는 저장소
- CA 시스템 - 파티션 감내는 지원하지 않지만 일관성과 가용성을 지원하는 저장소 (현실적으로 네트워크 장애는 피할 수 없기 때문에 실세계에 CA 시스템은 존재하지 않는다.)
예시
- 구체적인 사례로 예시를 들어서 알아보자.
- 이상적 환경이라면 네트워크가 파티션되는 상황은 절대로 일어나지 않을 것이다.
- n1에 기록된 데이터는 자동으로 n2와 n3에 복제된다.
- 데이터 일관성과 가용성도 만족된다.
- 하지만 실세계의 분산 시스템은 파티션 문제를 피할 수 없다.
- 그렇기 때문에 우리는 일관성과 가용성 사이에서 하나를 선택해야 한다.
- 만약 n3에 장애가 발생한다면 n1 또는 n2에 기록한 데이터는 n3에 전달되지 않는다.
- n3에 기록되었으나 아직 n1 및 n2로 전달되지 않은 데이터가 있다면 n1과 n2는 오래된 사본을 갖고 있을 것이다.

데이터 일관성 선택 (CP 시스템)
- 가용성 대신 일관성을 선택한다면 세 서버 사이에 생길 수 있는 데이터 불일치 문제를 피해야 한다.
- 가장 간단한 방법은 n1과 n2에 대해 쓰기 연산을 중단시키는 방법이다. (가용성을 포기)
- 은행권 시스템은 보통 데이터 일관성을 양보하지 않는다.
가용성 선택 (AP 시스템)
- 일관성 대신 가용성을 선택한다면 설사 오래된 데이터를 반환할 위험이 있어도 계속 읽기 연산을 허용한다.
- n1과 n2는 계속 쓰기 연산을 허용하고 파티션 문제가 해결된 후 새로운 데이터를 n3에 전송하게 된다.
분산 키-값 저장소를 만들 때는 요구사항에 맞도록 CAP 정리를 적용해야 한다.
시스템 컴포넌트
- 지금부터는 키-값 저장소 구현에 사용될 핵심 컴포넌트와 기술들을 살펴본다.
- 데이터 파티션
- 데이터 다중화
- 일관성
- 일관성 불일치 해소
- 장애 처리
- 시스템 아키텍처 다이어그램
- 쓰기/읽기 경로
데이터 파티션
- 대규모 어플리케이션은 전체 데이터를 단일 서버에 저장할 수 없고, 데이터를 파티션으로 분할한 다음 여러 대의 서버에 저장해야 한다.
- 데이터를 파티션 단위로 나눌 때는 다음 두 가지 문제를 중요하게 따져봐야 한다.
- 데이터를 여러 서버에 고르게 분산할 수 있는가
- 노드가 추가되거나 삭제될 때 데이터의 이동을 최소화할 수 있는가
- 이는 5장에서 다룬 안정 해시가 문제를 해결하는 데 적합한 기술이다.

- 안정 해시의 동작 과정
- 서버를 해시 링에 배치한다 (s0~s7)
- 키(key0)를 같은 링 위에 배치하고, 그 지점으로부터 링을 시계 방향으로 순회하다 처음 만나는 서버가 해당 키의 값을 저장할 서버가 된다.
- 안정 해시를 사용함으로써 시스템 부하에 따라 서버가 자동으로 추가/삭제 될 수 있고, 각 서버의 용량에 맞는 가상 노드 수를 조정할 수 있다.
데이터 다중화
- 높은 가용성과 안정성을 확보하기 위해서는 데이터를 N개 서버에 비동기적으로 다중화할 필요가 있다.
- 어떤 키를 해시 링 위에 배치한 후, 그 지점으로부터 시계 방향으로 링을 순회하면서 만나는 첫 N개 서버에 데이터 사본을 보관한다.
- 따라서 N = 3으로 설정한 예제에서 key0은 s1, s2, s3에 저장된다.

- 그런데 가상 노드를 사용한다면 위와 같이 선택된 N개의 노드가 대응될 실제 물리 서버의 개수가 N보다 작아질 수 있다.
- 예를 들어 물리 서버 세 대 A, B, C가 있고 가상 노드도 서버 당 세 개씩 A1, A2, A3, ... , C1, C2, C3 가 있다고 하자.
- 요청이 들어와서 데이터를 N = 3개의 서버에 다중화를 시켰는데 그게 A1, A2, A3가 되어 같은 서버에 저장되는 경우도 있다.
- 같은 데이터 센터에 속한 노드는 정전, 네트워크 이슈, 자연재해 등의 문제를 동시에 겪을 가능성이 높기 때문에 노드를 선택할 때 같은 물리 서버를 중복 선택하지 않도록 해야 한다.
데이터 일관성
- 여러 노드에 다중화된 데이터는 적절히 동기화해야 하는데, 정족수 합의(Quorum Consensus) 프로토콜을 사용하면 읽기/쓰기 연산 모두에 일관성을 보장할 수 있다.
- 우선 관계된 정의부터 몇 가지 살펴보자.
- N = 사본 개수.
- W = 쓰기 연산에 대한 정족수. 적어도 W개 서버로부터 쓰기 연산 성공 응답을 받아야 쓰기 연산 성공
- R = 읽기 연산에 대한 정족수. 적어도 R개 서버로부터 쓰기 연산 성공 응답을 받아야 읽기 연산 성공

- 위 그림은 N = 3, W = 1 인 경우에 대한 예제이다.
- W = 1의 의미는 쓰기 연산이 성공했다고 판단하기 위해 중재자(coordinator)는 최소 한 대 서버로부터 쓰기 성공 응답을 받아야 한다는 뜻이다.
- 따라서 s1으로부터 성공 응답을 받았다면 s0, s2로부터의 응답은 기다릴 필요가 없다.
- 중재자는 클라이언트와 노드 사이에서 프락시(proxy) 역할을 한다.
- W, R, N의 값을 정하는 것은 응답 지연과 데이터 일관성 사이의 타협점을 찾는 과정이다.
- W나 R의 값이 1보다 큰 경우에는 데이터의 일관성의 수준은 향상되지만, 응답 속도는 느려질 것이다.
- W + R > N 이라면 일관성을 보증할 최신 데이터를 가진 노드가 최소 하나 겹치기 때문에 강한 일관성이 보장된다.
- 면접 시 상황별로 N, W, R 값을 정하는 대략적인 구성은 다음과 같다.
- R = 1, W = N - 빠른 읽기 연산에 최적화
- W = 1, R = N - 빠른 쓰기 연산에 최적화
- W + R > N - 강한 일관성 보장
- W + R <= N - 강한 일관성 보장 X
일관성 모델
- 키-값 저장소를 설계할 때 일관성 모델은 중요한 요소이다.
- 일관성 모델은 데이터 일관성의 수준을 결정하는데, 종류가 다양하다.
- 강한 일관성 - 모든 읽기 연산은 가장 최근에 갱신된 결과를 반환한다.
- 약한 일관성 - 읽기 연산은 가장 최근에 갱신된 결과를 반환하지 못할 수 있다.
- 결과적 일관성 - 갱신 결과가 결국에는 모든 사본에 반영된다.
- 강한 일관성을 달성하기 위해서는 모든 사본에 현재 쓰기 연산의 결과가 반영될 때까지 해당 데이터에 대한 읽기/쓰기를 금지하는 것이다. 하지만 이 방법은 고가용성 시스템에는 적합하지 않다.
- 결과적 일관성 모델을 따를 경우 쓰기 연산이 병렬적으로 발생하면 시스템에 저장된 값의 일관성이 깨질 수도 있는데, 이 문제는 클라이언트가 해결해야 한다.
- 클라이언트 측에서 데이터의 버전 정보를 활용해 일관성이 깨진 데이터를 읽지 않도록 하는 기법에 대해서는 다음 절에서 살펴본다.
비 일관성 해소 기법 : 데이터 버저닝
- 데이터를 다중화하면 가용성은 높아지지만 사본 간 일관성이 깨질 확률이 높아진다.
- 버저닝(versioning)과 벡터 시계(vector clock)는 이 문제를 해결하기 위해 등장한 기술이다.
- 버저닝은 데이터를 변경할 때마다 해당 데이터의 새로운 버전을 만드는 것이다.
- 버저닝을 설명하기 전에 우선 데이터 일관성이 어떻게 깨지는지 예제를 통해 살펴보자.

- 만약 한 데이터에 대해서 동시에 쓰기 연산이 발생한다면 우리는 충돌하는 두 값을 갖게 될 것이다.
- 이후 name 데이터를 조회할 때 일관성이 깨지는 문제가 발생한다.
- 이 문제를 해결하기 위해서는 충돌을 발견하고 자동으로 해결해 낼 버저닝 시스템이 필요하다.
- 벡터 시계(vector clock)는 이런 문제를 푸는데 보편적으로 사용되는 기술이다.

- 벡터 시계는 [서버, 버전]의 순서쌍을 데이터에 매달아 어떤 버전이 먼저인지 판단하고, 다른 버전과 충돌이 있는지 판단할 때 사용되는 기술이다.
- 벡터 시계는 다음과 같이 표현한다.
D([S1, v1], [S2, v2], ..., [Sn, vn])- D는 데이터, Si는 서버 번호, vi는 버전 카운터
- 만일 데이터 D를 서버 Si에 기록하면, 시스템은 아래 작업 가운데 하나를 수행해야 한다.
- [Si, vi]가 있으면 vi를 증가시킨다.
- 그렇지 않으면 새 항목 [Si, 1]를 만든다.
- 위 그림을 통해 예시를 살펴보자
- 클라이언트가 데이터 D1을 시스템에 기록 -> Sx 서버가 처리 -> D1([Sx, 1])
- 클라이언트가 데이터 D1을 읽고 D2로 업데이트 -> Sx 서버가 처리 -> D2([Sx, 2])
- 클라이언트가 데이터 D2를 읽고 D3로 업데이트 -> Sy 서버가 처리 -> D3([Sx, 2], [Sy, 1])
- 클라이언트가 데이터 D2를 읽고 D4로 업데이트 -> Sz 서버가 처리 -> D4([Sx, 2], [Sz, 1])
- 데이터 D2가 Sy, Sz 모두 다른 값으로 바뀌어 충돌 -> Sx 서버가 충돌 처리 -> D5([Sx, 3], [Sy, 1], [Sz, 1])
- 이렇게 벡터 시계를 사용하면 버전이 어떤 버전의 이전 버전인지 쉽게 파악할 수 있다.
- 버전 사이의 충돌도 쉽게 확인할 수 있다.
- 이전 버전의 모든 구성요소가 현재 버전의 값보다 크면 충돌이 있는 것이다.
- 예를 들어, D([S0, 1], [S1, 2]) 와 D([S0, 2], [S1, 1])은 충돌이 존재하는 경우다.
- 하지만 D([S0, 1], [S1, 1])와 D([S0, 1], [S1, 2])는 충돌이 없다.
- 하지만 벡터 시계를 사용하여 충돌을 감지하고 해결하는 것은 두 가지 단점이 존재한다.
- 충돌 감지 및 해소 로직이 클라이언트에 들어가야 해서, 클라이언트 구현이 복잡해진다.
- [서버, 버전]의 순서쌍 개수가 빨리 늘어나기 때문에, 반드시 임계치를 정해서 오래된 순서쌍을 벡터 시계에서 제거해주어야 한다.
장애 감지 & 해소
- 대구모 시스템에서 장애는 흔하게 벌어지는 사건이다.
- 우선 장애를 감지하는 기법을 먼저 살펴보고, 장애 해소 전략들을 짚어보자.
- 분산 시스템에서는 "서버 A가 죽었습니다"만 가지고 장애처리를 하지 않고, 두 대 이상의 서버에서 서버 A의 장애 보고를 받아야 장애가 발생했다고 간주한다.
- 이를 감지하는 가장 간단한 방법은 멀티캐스팅 채널을 구축하는 방법이다.

- 하지만 이 방법은 서버가 많을 때는 비효율적이다.
- 분산 시스템에서는 가십 프로토콜(gossip protocol)과 같은 분산형 장애 감지 솔루션을 채택하는 것이 효율적이다.

- 가십 프로토콜의 동작 방식은 다음과 같다.
- 각 노드는 멤버 ID와 박동 카운터 쌍으로 이루어진 멤버십 목록을 유지한다.
- 각 노드는 주기적으로 자신의 박동 카운터를 증가시킨다.
- 각 노드는 무작위 노드에게 자신의 박동 카운터 목록을 보내고, 받은 노드는 멤버십 목록을 최신 값으로 갱신한다.
- 어떤 노드의 박동 카운터 값이 일정 시간 갱신되지 않으면 장애(offline) 상태로 간주한다.
일시적 장애 처리
- 가십 프로토콜 장애를 감지한 시스템은 가용성을 보장하기 위해 필요한 조치를 해야 한다.
- 엄격한 정족수 접근법을 쓴다면, 읽기와 쓰기 연산을 금지해야 한다.
- 하지만 가용성을 생각해서 느슨한 정족수 접근법을 사용할 수도 있다.
- 예를 들어, 쓰기 연산을 수행할 W개의 건강한 서버와 읽기 연산을 수행할 R개의 건강한 서버를 해시 링에서 고른다.
- 이때 장애 상태인 서버는 무시한다.
- 장애 상태인 서버로 가는 요청은 다른 서버가 잠시 맡아 처리한다.
- 그동안 발생한 변경사항은 해당 서버가 복구되었을 때 일괄 반영하여 데이터 일관성을 보존한다.
- 일시적으로 처리해주는 쓰기 서버는 장애 서버를 위해 단서를 남겨준다고 해서, 이를 단서 후 임시 위탁(hinted handoff) 기법이라고 부른다.

- 예를 들어 장애 상태인 노드 s2에 대한 연산을 일시적으로 s3가 처리해주고, s2가 복구되면 s3가 갱싱된 데이터를 s2로 인계해준다.
영구 장애 처리
- 단서 후 임시 위탁 기법은 일시적 장애를 처리하기 위한 것이다.
- 영구적인 노드의 장애 상태는 어떻게 처리해야 할까?
- 영구적 장애 상태 처리는 반-엔트로피(anti-entropy) 프로토콜을 구현하여 사본들을 동기화한다.
- 사본 간의 일관성이 망가진 상태를 탐지하고 전송 데이터의 양을 줄이기 위해서는 머클(Merkle) 트리를 사용한다.
- 머클 트리는 각 노드에 자식 노드의 레이블에서 계산된 해시 값을 레이블로 붙여두는 트리이다.
- 다음 그림은 키 공간이 1부터 12까지 있을 때 머클 트리를 만드는 예제이고 일관성이 망가진 데이터는 빨간색으로 표기했다.
1. 키 공간을 버킷으로 나눈다 (예시: 4개)

2. 버킷에 포함된 키에 균등 분포 해시(uniform hash) 함수를 적용하여 해시를 계산한다.

3. 버킷별로 해시값을 계산 후, 해당 해시를 레이블로 갖는 노드를 생성한다.

4. 자식 노드의 레이블에서 새로운 해시 값을 계산하여 이진 트리를 상향식으로 구성해 나간다.

- 두 머클 트리의 비교는 루트 노드의 해시 값을 비교하면서 시작한다.
- 루트 노드의 해시값이 같으면 두 서버의 데이터는 같은 것이다.
- 다르다면 왼쪽 -> 오른쪽 노드 순서로 비교해 나가면서 다른 데이터를 갖는 버킷을 찾을 수 있고, 해당 버킷만 동기화하면 된다.
- 머클 트리 사용 시 버킷 하나의 크기가 꽤 크다. 10억개의 키를 100만개의 버킷으로 관리해야 하는데, 그러면 한 버킷 당 1,000개의 키를 관리하게 된다.
시스템 아키텍처 다이어그램
- 키-값 저장소를 만드는 데 필요한 다양한 기술적 고려사항들을 살펴보았으니, 이제 아키텍처 다이어그램을 그려보자.
- 아키텍처의 주요 기능들은 다음과 같다.
- 클라이언트는 저장소가 제공하는 get, put API와 통신한다.
- 중재자는 클라이언트에게 저장소에 대한 proxy 역할을 하는 노드다.
- 노드는 안정 해시의 해시 링 위에 분포한다.
- 시스템은 완전히 분산되어 노드를 자동으로 추가/삭제할 수 있다.
- 데이터는 여러 노드에 다중화된다.
- 모든 노드가 같은 책임을 지기에 SPOF(Single Point of Failure)는 존재하지 않는다.

- 정리하면 각 노드는 아래의 기능을 지원해야 한다.
- 클라이언트 API
- 장애 감지
- 데이터 충돌 해소
- 장애 복구 메커니즘
- 다중화
- 저장소 엔진
쓰기 경로
- 이제 쓰기 요청이 특정 노드에 전달되면 무슨 일이 벌어지는지 살펴보자.
- 그림 예제는 카산드라(Cassandra)의 사례를 참고한 것이다.

- 쓰기 요청이 커밋 로그(commit log) 파일에 기록된다.
- 데이터가 메모리 캐시에 기록된다.
- 메모리 캐시가 가득차거나, 사전에 정의된 임계치에 도달하면 데이터는 디스크의 SSTable에 저장된다.
- SSTable (Sorted-String Table) - <키, 값> 의 순서쌍을 정렬된 리스트 형태로 관리하는 테이블
읽기 경로
- 읽기 요청을 받은 노드는 데이터가 메모리 캐시에 있는지부터 살펴본다.
- 있는 경우 메모리 캐시에서 반환하고 없는 경우는 디스크에서 가져온다.

- 데이터가 메모리에 있는지 검사한다. 있다면 바로 반환한다.
- 데이터가 없다면 블룸 필터를 검사한다.
- Bloom filter (블룸 필터) - 찾는 키가 어느 SSTable에 존재하는지 확인하는 필터
- 블룸 필터를 통해 키가 어떤 SSTable에 있는지 확인한다.
- SSTable에서 데이터를 추출한다.
- 클라이언트에게 반환한다.
마무리
- 6장 키-값 저장소 설계에서는 많은 개념들이 있었고, 저장소를 설계하기 위한 많은 기술적인 부분도 있었다.
- 정리해보자면 다음과 같다.
목표/문제 | 기술 |
대규모 데이터 저장 | 안정 해시로 부하 분산 |
읽기 연산 가용성 보장 | 여러 데이터 센터에 걸친 데이터 다중화 |
쓰기 연산 가용성 보장 | 버저닝, 벡터 시계로 충돌 해소 |
데이터 파티션 | 안정 해시 |
점진적 규모 확장성 | 안정 해시 |
다양성 | 안정 해시 |
조절 가능한 데이터 일관성 | 정족수 합의 (quorum consensus) |
일시적 장애 처리 | 느슨한 정족수 프로토콜, 단서 후 임시 위탁(hinted handoff) |
영구적 장애 처리 | 머클 트리 |
데이터 센터 장애 대응 | 여러 데이터 센터에 걸친 데이터 다중화 |
느낀점
- 일관성, 가용성, 확장성을 생각하면서 저장소를 설계하는 것은 정말 어려운 일이라는 것을 느꼈다..!
- 네트워크 파티션이 언제든 발생할 수 있다는 전제를 두고 시스템을 설계한다는 점이 인상 깊었다.
- 그동안은 장애를 예외적인 상황으로만 생각했는데, 이번 장을 통해 장애를 전제로 한 설계가 왜 중요한지 조금은 이해할 수 있었다.
- 앞으로 다른 시스템이나 아키텍처를 볼 때도 “왜 이런 선택을 했을까?”를 한 번 더 생각해보는 계기가 될 것 같다.

