키-값 저장소(key-value store)는 키-값 데이터베이스라고도 불리는 비 관계형(non-relational) 데이터베이스이다.
- 이 저장소에 저장되는 값은 고유 식별자를 키로 가져야한다.
- 키와 값 사이의 이런 연결 관계를 키-값 쌍(key-value pair)이라고 지칭한다.
키
키-값 쌍에서의 키는 유일해야 하며 해당 키에 매달린 값은 키를 통해서만 접근할 수 있다.
키는 일반 텍스트일 수도 있고 해시 값일 수도 있지만, 성능상의 이유로 짧을수록 좋다.
- 일반 텍스트 키: “last_logged_in_at”
- 해시 키: 253DDEC4
값
키-값 저장소는 보통 값으로 무엇이 오든 상관하지 않는다.
- 문자열, 리스트, 객체 등
키-값 저장소로 널리 알려진 것은 아마존 다이나모, memcached, 레디스 같은 것들이 있다.
또한 기본적으로 아래와 같은 연산을 지원해야한다.
put(key, value)
: 키-값 쌍을 저장소에 저장한다.get(key)
: 인자로 주어진 키에 매달린 값을 꺼낸다.
문제 이해 및 설계 범위 확정
완벽한 설계란 없다.
읽기, 쓰기 그리고 메모리 사용량 사이에 어떤 균형을 찾고, 데이터의 일관성과 가용성 사이에서 타협적 결정을 내린 설계를 만들었다면 충분히 쓸만한 답이다.
이번 장에서는 다음 특성을 갖는 키-값 저장소를 설계해본다.
- 키-값 쌍의 크기는 10KB 이하이다.
- 큰 데이터를 저장할 수 있어야 한다.
- 높은 가용성을 제공해야한다.
- 시스템은 장애가 있더라도 빨리 응답해야한다.
- 높은 규모 확장성을 제공해야 한다.
- 트래픽 양에 따라 자동적으로 서버 증설/삭제가 이루어져야 한다.
- 데이터 일관성 수준은 조정이 가능해야 한다.
- 응답 지연시간(latency)이 짧아야 한다.
단일 서버 키-값 저장소
한 대 서버만 사용하는 키-값 저장소는 설계가 쉽다.
가장 직관적인 방법은 키-값 쌍 전부를 메모리에 해시 테이블로 저장하는 것이다.
이 방법은 빠른 속도를 보장하지만 모든 데이터를 메모리안에 두는 것이 불가능할 수도 있다.
이 문제의 개선책은 다음과 같은 것들이 있다.
- 데이터 압축
- 자주 쓰이는 데이터만 메모리에 두고 나머지는 디스크에 저장
이렇게 개선한다고 해도, 한 대 서버로 부족한 때가 찾아오며, 많은 데이터를 저장하기 위해서 분산 키-값 저장소를 만들어야한다.
분산 키 값 저장소
분산 키 값 저장소는 키-값 쌍을 여러 서버에 분산시키므로 분산 해시 테이블이라고도 불린다.
분산 시스템을 설계할 때는 CAP 정리(Consistency, Availability, Partition Tolerance theorem)를 이해하고 있어야 한다.
CAP 정리
CAP 정리는 데이터 일관성(consistency), 가용성(availability), 파티션 감내(partition tolerance)라는 세가지 요구사항을 동시에 만족하는 분산 시스템을 설계하는 것은 불가능하다는 정리이다.
- 데이터 일관성
- 분산 시스템에 접속하는 모든 클라이언트는 어떤 노드에 접속했느냐에 관계없이 언제나 같은 데이터를 보게 되어야 한다.
- 가용성
- 분산 시스템에 접속하는 클라이언트는 일부 노드에 장애가 발생하더라도 항상 응답을 받을 수 있어야 한다.
- 파티션 감내
- 파티션은 두 노드 사이에 통신 장애가 발생하였음을 의미한다.
- 네트워크에 파티션(분할)이 생기더라도 시스템은 계속 동작하여야 한다는 것을 뜻한다.
네트워크 파티션?
테이터베이스 시스템의 일부 노드들이 서로 통신할 수 없는 상황으로 인해 각 노드들(파티션)이 독립적으로 동작하게 되어 각 파티션이 서로 다른 상태나 데이터를 가지게 되는 상황
따라서 이들 가운데 어떤 두 가지를 충족하려면 나머지 하나는 반드시 희생되어야 한다는 것을 의미한다.
키 값 저장소는 세 가지 요구사항 가운데 어느 두 가지를 만족하느냐에 따라 다음과 같이 분류할 수 있다.
- CP: 일관성과 파티션 감내를 지원(가용성 희생)
- AP: 가용성과 파티션 감내를 지원(데이터 일관성 희생)
- CA: 일관성과 가용성을 지원(파티션 감내 지원 안함)
- 통상 네트워크 장애는 피할 수 없는 일로 여겨지므로, 분산 시스템은 반드시 파티션 문제를 감내할 수 있도록 설계되어야 한다.
- 실세계에서 CA 시스템은 존재하지 않는다.
구체적 사례
분산 시스템에서 데이터는 보통 여러 노드에 복제되어 보관된다.
이상적 상태
이상적인 환경이라면 네트워크가 파티션되는 상황은 절대로 일어나지 않을 것이다.
- n1에 기록된 데이터는 자동적으로 n2, n3에 복제되며 데이터 일관성과 가용성도 만족한다.
실세계의 분산 시스템
분산 시스템은 파티션 문제를 피할 수 없다.
파티션 문제가 발생하면 일관성과 가용성 사이에서 하나를 선택해야 한다.
n3에 장애가 발생하여 n1, n2와 통신 할 수 없는 상황에서
- 클라이언트가 n1, n2에 기록한 데이터는 n3에 전달되지 않는다.
- n3에 기록되었으나 n1, n2로 전달되지 않은 데이터가 있다면 오래된 사본을 갖고 있을 것이다.
CP 시스템
가용성 대신 일관성을 선택한다면 세 서버 사이에 생길 수 있는 데이터 불일치 문제를 피하기 위해 n1, n2에 대해 쓰기 연산을 중단시켜야한다.
이러한 경우 일부 노드가 장애가 발생하여도 동작해야 한다는 가용성이 깨진다.
데이터 일관성을 양보할 수 없는 시스템은 이렇게 처리되어 상황이 해결될 때 까지 오류를 반환해야 한다.
- 온라인 뱅킹 등
AP 시스템
일관성 대신 가용성을 선택한 시스템은 낡은 데이터를 반환할 위험이 있더라도 계속 읽기 연산을 허용해야한다.
n1, n2는 계속 쓰기 연산을 허용하고, 파티션 문제가 해결된 뒤 새 데이터를 n3에 전송해야한다.
분산 키-값 저장소를 만들 때는 그 요구사항에 맞도록 CAP 정리를 적용해야 한다.
면접 상황에서는 이 문제에 대해 면접관과 상의하고, 그 결론에 따라 시스템을 설계하도록 하자.
시스템 컴포넌트
키-값 저장소 구형에 사용되는 핵심 컴포넌트들 및 기술을 살펴본다.
- 데이터 파티션
- 데이터 다중화
- 일관성
- 일관성 불일치 해소
- 장애 처리
- 시스템 아키텍처 다이어그램
- 쓰기 경로
- 읽기 경로
데이터 파티션
대규모 애플리케이션의 경우 전체 데이터를 한 대 서버에 욱여넣는 것은 불가능하다.
가장 단순한 해결책은 데이터를 작은 파티션들로 분할한 다음 여러 대 서버에 저장하는 것이다.
- 데이터를 여러 서버에 고르게 분산할 수 있는가
- 노드가 추가되거나 삭제될 때 데이터의 이동을 최소화할 수 있는가
5장에서 다룬 안정 해시는 이런 문제를 푸는 데 적합한 기술로 활용될 수 있다.
안정 해시를 사용하여 데이터를 파티션하면 몇가지 장점이 있다.
- 규모 확장 자동화(automatic scaling)
- 시스템 부하에 따라 서버가 자동으로 추가되거나 삭제되도록 만들 수 있다.
- 다양성(heterogeneity)
- 각 서버의 용량에 맞게 가상 노드의 수를 조정할 수 있다.
- 고성능 서버는 더 많은 가상노드를 갖도록…
데이터 다중화
높은 가용성과 안정성을 확보하기 위해서는 데이터를 N개 서버에 비동기적으로 다중화할 필요가 있다.
어떤 키를 해시 링 위에 배치한 수, 그 지점으로부터 시계 방향으로 링을 순회하면서 만나는 첫 N개 서버에 데이터 사본을 보관한다.
하지만 가상 노드를 사용한다면 선택한 N개의 노드가 대응될 실제 물리 서버의 개수가 N보다 작아질 수 있다.
이 문제를 피하려면 노드를 선택할 때 같은 물리 서버를 중복으로 선택하지 않도록 해야한다.
같은 데이터 센터에 속한 노드는 정전, 네트워크 이슈, 자연 재해 등의 문제를 동시에 같이 겪을 가능성이 있으므로, 안정성을 담보하기 위해 데이터의 사본은 다른 센터의 서버에 보관하고, 센터들은 고속 네트워크로 연결한다.
데이터 일관성
여러 노드에 다중화된 데이터는 적절히 동기화가 되어야 한다.
정족수 합의(Quorum Consensus) 프로토콜을 사용하면 읽기/쓰기 연산 모두에 일관성을 보장할 수 있다.
N
: 사본의 개수W
: 쓰기 연산에 대한 정족수- 쓰기 연산이 성공한 것으로 간주되려면 적어도
W
개의 서버로부터 쓰기 연산이 성공했다는 응답을 받아야함
- 쓰기 연산이 성공한 것으로 간주되려면 적어도
R
: 읽기 연산에 대한 정족수- 읽기 연산이 성공한 것으로 간주되려면 적어도
R
개의 서버로부터 응답을 받아야함
- 읽기 연산이 성공한 것으로 간주되려면 적어도
일반적으로 N = R + W > N
조건을 만족하도록 설정하며, 읽기와 쓰기 요청이 적어도 하나의 공통 노드를 통해 일관성을 유지하도록 보장한다.
W = 1
는 쓰기 연산이 성공했다고 판단하기 위해 중재자(coordinator)는 최소 한 대 서버로부터 쓰기 성공 응답을 받아야한다는 뜻이다.
- s1으로 부터 성공 응답을 받았다면, 나머지 응답은 기다릴 필요가 없다.
중재자는 클라이언트와 노드 사이에서 프락시(proxy)역할을 한다.
W
, R
, N
의 값을 정하는 것은 응답 지연과 데이터 일관성 사이의 타협점을 찾는 전형정인 과정이다.
W = 1
orR = 1
- 중재자는 한 대 서버로부터의 응답만 받으면 되므로 응답속도는 빠르다.
W > 1
orR > 1
- 데이터 일관성의 수준은 향상되지만 중재자의 응답 속도는 가장 느린 서버로부터의 응답을 기다려야 하므로 느려진다.
따라서 W + R > N
인 경우에는 일관성을 보증할 최신 데이터를 가진 노드가 최소 하나는 겹치므로 강한 일관성이 보장된다.
R = 1
,W = N
- 빠른 읽기 연산에 최적화된 시스템
R = N
,W = 1
- 빠른 쓰기 연산에 최적화된 시스템
R + W > N
- 강한 일관성이 보장됨
- 보통
N = 3
,W = R = 2
- 보통
- 강한 일관성이 보장됨
R + W <= N
- 강한 일관성이 보장되지 않음
요구되는 일관성 수준에 따라 W
, R
, N
값을 조정한다.
일관성 모델
일관성 모델(consistency model)은 키-값 저장소를 설계할 때 고려해야 할 요소로 데이터 일관성 수준을 결정한다.
- 강한 일관성
- 모든 읽기 연산은 최신 결과를 반환한다.
- 클라이언트는 절대로 낡은 데이터를 볼 수 없다.
- 약한 일관성
- 읽기 연산은 최신 결과를 반환하지 못할 수 있다.
- 결과적 일관성
- 약한 일관성의 한 형태로, 갱신 결과가 결국에는 모든 사본에 반영(동기화)된다.
강한 일관성을 달성하는 일반적인 방법은, 모든 사본에 현재 쓰기 연산의 결과가 반영될 때까지 해당 데이터에 대한 읽기/쓰기를 금지하는 것이다.
- 새로운 요청의 처리가 중단되므로 고 가용성 시스템에는 적합하지 않다.
다이나모 또는 카산드라 같은 저장소는 결과적 일관성 모델을 택하고 있다.
- 결과적 일관성 모델을 따를 경우 쓰기 연산이 병렬적으로 발생하면 시스템에 저장된 값의 일관성이 깨어질 수 있는데, 이 문제는 클라이언트가 해결해야한다.
- 클라이언트 측에서 데이터의 버전 정보를 활용해 일관성이 깨진 데이터를 읽지 않도록 해야한다.
데이터를 다중화하면 가용성은 높아지지만 사본 간 일관성이 깨질 가능성은 높아진다.
비 일관성 해소 기법: 데이터 버저닝
버저닝은 데이터를 변경할 때마다 해당 데이터의 새로운 버전을 만든다.
- 각 버전은 변경 불가능하다.
서버 1, 2가 다른 노드의 같은name
의 값을 동시에 변경하여 충돌이 발생했다고 가정했을 때 각각을 버전 v1, v2로 볼 수 있다.
이러한 충돌 문제를 해결하려면, 충돌을 발견하고 자동으로 해결해 낼 버저닝 시스템이 필요하다.
백터 시계(vector clock)는 [서버, 버전]
의 순서 쌍을 데이터에 매단 것으로 충돌 문제를 푸는데 보편적으로 사용된다.
- 어떤 버전이 선행 버전인지, 후행 버전인지, 충돌이 있는지 판별하는 데 쓰인다.
데이터 D를 서버 Si에 기록하려면 아래 작업 가운데 하나를 수행해야한다.
[Si, Vi]
가 있으면Vi
를 증가시킨다.- 그렇지 않다면 새 항목
[Si, 1]
을 만든다.
- 클라이언트가
D1
을 시스템에 기록한다.- 처리한 서버는
Sx
이므로 백터 시계는D1[Sx, 1]
으로 변한다.
- 처리한 서버는
- 다른 클라이언트가
D1
을 읽고D2
로 업데이트한 다음 기록한다.D2
는D1
의 변경이므로 덮어쓴다.Sx
가 처리했으므로 벡터 시계를D2[Sx, 2]
로 변경한다.
- 다른 클라이언트가
D2
를 읽어D3
로 갱신한 다음 기록한다.Sy
가 처리했으므로 백터 시계 상태는D3([Sx, 2], [Sy, 1])
- 또 다른 클라이언트가
D2
를 읽고D4
로 갱신한 후 기록한다.Sz
가 처리했으므로 백터 시계 상태는D4([Sx, 2], [Sz, 1])
- 어떤 클라이언트가 D3과 D4를 읽으면 데이터 간 충돌이 있다는 것을 알게 되므로, 클라이언트가 해소한 후 서버에 기록한다.
Sx
가 처리했으므로 백터 시계는D5([Sx, 3], [Sy, 1], [Sz, 1])
로 바뀐다.
벡터 시계를 이용하면 버전 Y에 포함된 모든 구성 요소의 값이 X에 포함된 모든 구성요소 값보다 같거나 큰지만 확인하면 어떤 버전 X가 버전 Y의 이전 버전인지 쉽게 판단할 수 있다.
D([s0, 1], [s1, 1])
은D([s0, 1], [s1, 2])
보다 이전 버전이다.(충돌 X)
어떤 버전 X와 Y 사이에 충돌이 있는지 보려면 Y의 벡터 시계 구성 요소 가운데 X의 벡터 시계 동일 서버 구성요소보다 작은 값을 갖는 것이 있는지 확인한다.
D([s0, 1], [s1, 2])
,D([s0, 2], [s1, 1])
는 서로 충돌한다.
벡터 시계를 통해 충돌 감지하고 해소하는 방법에는 두 가지 단점이 있다.
- 충돌 감지 및 해소 로직이 클라이언트에 들어가야 하므로, 클라이언트 구현이 복잡해진다.
[서버: 버전]
의 순서쌍 개수가 굉장히 빨리 늘어난다.- 순서쌍 개수에 임계치를 설정하고, 임계치 이상으로 길이가 길어지면 오래된 순서쌍을 백터 시계에서 제거한다.
- 버전 간 선후 관계가 정확하게 결정될 수 없으므로 충돌 해소 과정의 효율성이 낮아질 수 있다.
- 실제 서비스에서 그런 문제는 거의 발생하지 않으므로, 대부분 기업에서 괜찮다.
장애 처리
대규모 시스템에서 장애는 아주 흔하게 벌어지므로 장애를 어떻게 처리할 것이냐 하는 것은 굉장히 중요한 문제이다.
장애 감지
분산 시스템에서는 서버 A에 문제가 생겼을 때 바로 장애를 처리하지 않고, 보통 두 대 이상의 서버가 똑같이 서버 A에 대해 장애를 보고해야 해당 서버에 실제로 장애가 발생했다고 간주한다.
모든 노드 사이에 멀티캐스팅 채널을 구축하는 것이 서버 장애를 감지하는 손쉬운 방법이나, 이 방법은 서버가 많을 때 비효율적이다.
따라서 가십 프로토콜(gossip protocol) 같은 분산형 장애 감지 솔루션을 채택하는 편이 보다 효율적이다.
- 각 노드는 맴버십 목록을 유지한다.
- 맴버십 목록: 각 맴버 ID와 그 박동 카운터(heartbeat counter) 쌍의 목록
- 각 노드는 주기적으로 자신의 박동 카운터를 증가시킨다.
- 각 노드는 무작위로 선정된 노드들에게 주기적으로 자기 박동 카운터 목록을 보낸다.
- 박동 카운터 목록을 받은 노드는 맴버십 목록을 최신 값으로 갱신한다.
- 어떤 맴버의 박동 카운터 값이 지정된 시간 동안 갱신되지 않으면 해당 멤버는 장애 상태인 것으로 간주한다.
일시적 장애 처리
장애를 감지한 시스템은 가용성을 보장하기 위해 필요한 조치를 해야한다.
- 엄격한 정족수 접근법은 읽기와 쓰기 연산을 금지한다.
- 느슨한 정족수 접근법은 쓰기 연산을 수행할 W개의 건강한 서버와 읽기 연산을 수행할 R개의 건강한 서버를 해시 링에서 고른다.(장애 서버는 무시)
- 장애 상태인 서버로 가는 요청은 다른 서버가 잠시 맡아 처리한다.
- 그동안 발생한 변경 사항은 장애 서버가 복구 되었을 때 일괄 반영하여 데이터 일관성을 보존한다.
- 임시로 쓰기 연산을 처리한 서버에는 그에 관한 단서를 남겨둔다.
- 단서 후 임시 위탁(hinted handoff)
장애 상태인 s2에 대한 읽기 및 쓰기 연산은 일시적으로 s3가 처리하며, s2가 복구되면 s3는 갱신된 데이터를 s2로 인계한다.
영구 장애 처리
영구적인 노드의 장애 상태는 반-엔트로피(anti-entropy) 프로토콜을 구현하여 사본들을 동기화한다.
반-엔트로피 프로토콜은 사본들을 비교하여 최신 버전으로 갱신하는 과정을 포함한다.
사본 간의 일관성이 망가진 상태를 탐지하고 전송 데이터의 양을 줄이기 위해서 머클(Merkle) 트리를 사용한다.
머클 트리?
해시 트리라고 불리는 머클 트리는 각 노드에 그 자식 노드들의 보관된 값의 해시(자식 노드가 leaf인 경우) 또는 자식 노드들의 레이블로부터 계산된 해시 값을 레이블로 붙여두는 트리
해시 트리를 사용하면 대규모 자료 구조의 내용을 효과적이면서도 보안상 안전한 방법으로 검증할 수 있다.
1 단계
키 공간을 버킷으로 나눈다.
2 단계
버킷에 포함된 각각의 키에 균등 분포 해시 함수를 적용하여 해시 값을 계산한다.
3 단계
버킷 별로 해시값을 계산한 후, 해당 해시 값을 레이블로 갖는 노드를 만든다.
4 단계
자식 노드의 레이블로부터 새로운 해시값을 계산하여, 이진 트리를 상향식으로 구성해 나간다.
두 머클 트리의 비교는 루트 노드의 해시값을 비교하는 것으로 시작하며, 다른 데이터를 갖는 버킷을 찾을 경우 그 버킷들만 동기화한다.
머클 트리를 사용하면 동기화해야 하는 데이터 양은 실제로 존재하는 차이의 크기에 비례할 뿐, 두 서버에 보관된 데이터의 총량과는 무관해진다.
- 실제로 쓰이는 시스템의 경우 버킷 하나의 크기가 꽤 크다.
- 10억(1B) 개의 키를 백만(1M) 개의 버킷으로 관리하면, 하나의 버킷은 1,000개 키를 관리한다.
데이터 센터 장애 처리
데이터 센터 장애는 정전, 네트워크 장애, 자연재해 등 다양한 이유로 발생할 수 있다.
데이터 센터의 장애에 대응할 수 있는 시스템을 만드려면 데이터를 여러 데이터 센터에 다중화하는 것이 중요하다.
시스템 아키텍처 다이어그램
- 클라이언트는 키-값 저장소가 제공하는 두 가지 단순한 API,
get(key)
,put(key, value)
와 통신한다. - 중재자는 클라이언트에게 키-값 저장소에 대한 프락시 역할을 하는 노드다.
- 노드는 안정 해시의 해시 링 위에 분포한다.
- 노드를 자동으로 추가 또는 삭제할 수 있도록, 시스템은 완전히 분산된다.
- 데이터는 여러 노드에 다중화된다.
- 모든 노드가 같은 책임을 지므로, SPOF(Single Point of Failure)는 존재하지 않는다.
완전히 분산된 설계를 채택하였으므로 모든 노드는 제시된 기능을 전부 지원해야한다.
쓰기 경로
- 쓰기 요청이 커밋 로그 파일에 기록된다.
- 데이터가 메모리 캐시에 기록된다.
- 메모리 캐시가 가득 차거나 사전에 정의된 임계치에 도달하면 데이터는 디스크에 있는 SSTable에 기록된다.
- SSTable: Sorted-String Table의 약어로 <키, 값> 의 순서쌍을 정렬된 리스트로 관리하는 테이블이다.
읽기 경로
읽기 요청을 받은 노드는 데이터가 메모리 캐시에 있는지부터 살핀 후 데이터를 클라이언트에게 반환한다.
데이터가 메모리에 없는 경우 디스크에서 가져온다.
어느 SSTable에 찾는 키가 있는지 효율적으로 찾기 위해 블룸 필터(Bloom filter)가 흔히 사용된다.
- 데이터가 메모리에 있는지 검사하고 있다면 반환한다.
- 데이터가 메모리에 없으므로 블룸 필터를 검사한다.
- 블룸 필터를 통해 어떤 SSTable에 키가 보관되어 있는지 알아낸다.
- SSTable에서 데이터를 가져온다.
- 해당 데이터를 클라이언트에게 반환한다.
요약
목표/문제 | 기술 |
---|---|
대규모 데이터 저장 | 안정 해시를 사용해 서버들에 부하 분산 |
읽기 연산에 대한 높은 가용성 보장 | 데이터를 여러 데이터센터에 다중화 |
쓰기 연산에 대한 높은 가용성 보장 | 버저닝 및 백터 시계를 사용한 충돌 해소 |
데이터 파티션 | 안정 해시 |
점진적 규모 확장성 | 안정 해시 |
다양성(heterogeneity) | 안정 해시 |
조절 가능한 데이터 일관성 | 정족수 합의(quorum consensus) |
일시적 장애 처리 | 느슨한 정족수 프로토콜(sloppy quorum)과 단서 후 임시 위탁(hinted handoff) |
영구적 장애 처리 | 머클 트리(Merkle tree) |
데이터 센터 장애 대응 | 여러 데이터 센터에 걸친 데이터 다중화 |