포스팅에 사용된 그림은 책에서 제공하는 그림들 입니다.
키-값 저장소는 키-값 데이터베이스라고도 불리는 비 관계형 데이 저장소에 저장되는 값은 고유 식별자를 키로 가져야 한다. 키와 값 사이의 이런 연결 관계를 “키-값”쌍 이라고 자칭한다.
키-값 쌍에서의 키는 유일해야 하며 해당 키에 매달린 값은 키를 통해서만 접근할 수 있다. 키는 일반 텍스트일 수도 있고 해시 값일 수도 있다. 성능상의 이유로, 키는 짧을수록 좋다.
키-값 쌍에서의 값은 문자열일 수도 있고 리스트일 수도 있고 객체일 수도 있다. 키-값 저장소는 보통 값으로 무엇이 오든 상관하지 않는다. 키-값 저장소로 널리 알려진 것은 아마존 다이나모, memcached, 레디스 같은 것들이 있다.
다음은 키 값 저장소에 보관된 데이터의 사례다.
이번 장에서 여러분은 다음 연산을 지원하는 키-값 저장소를 설계해 볼 것이다.
완벽한 설계란 없다. 읽기, 쓰기 그리고 메모리 사용량 사이에 어떤 균형을 찾고, 데이터의 일관성과 가용성 사이에서 타협적 결정을 내린 설계를 만들었다면 쓸만한 답안일 것이다. 이번 장에서는 다음과 같은 특성을 갖는 키-값 저장소를 설계해 볼 것이다.
한 대 서버만 사용하는 키-값 저장소를 설계하는 것은 쉽다. 가장 직관적인 방법은 키-값 쌍 전보를 메모리에 해시 테이블로 저장하는 것이다. 그러나 이 접근법은 빠른 속도를 보장하긴 하지만 모든 데이터를 메모리 안에 두는 것이 불가능할 수도 있다는 약점을 갖고있다. 이 문제를 해결하기 위한 개선책으로는 다음과 같은 방법이 있다.
그러나 이렇게 개선한다고 해도, 한 대 서버로 부족할 때가 곧 찾아온다. 많은 데이터를 저장하려면 분산 키-값 저장소를 만들 필요가 있다.
분산 키-값 저장소는 분산 해시 테이블이라고도 부른다. 키-값 쌍을 여러 서버에 분산시키는 탓이다. 분산 시스템을 설계할 때는 CAP정리를 이해하고 있어야 한다.
CAP 정리는 데이터 일관성, 가용성, 파티션 감내라는 세 가지 요구사항을 동시에 만족하는 분산 시스템을 설계하는 것은 불가능하다는 정리다. 우선 각 요구사항의 의미부터 명확히 정리하고 넘어가자.
키-값 저장소는 앞서 제시한 세 가지 요구사항 가운데 어느 두 가지를 만족하느냐에 따라 다음과 같이 분류할 수 있다.
분산 시스템에서 데이터는 보통 여러 노드에 복제되어 보관된다. 세 대의 복제 노드 n1,n2,n3에 데이터를 복제하여 보관하는 상황을 아래그림과 같이 가정해보자.(p94)
이상적 환경이라면 네트워크가 파티션되는 상황은 절대로 일어나지 않을 것이다.
n1에 기록된 데이터는 자동적으로 n2와 n3에 복제된다. 데이터 일관성과 가용성도 만족된다.
분산 시스템은 파티션 문제를 피할 수 없다. 그리고 파티션 문제가 발생하면 우리는 일관성과 가용성 사이에서 하나를 선택해야 한다. 아래 그림은 n3에 장애가 발생하여 n1 및 n2와 통신할 수 없는 상황의 그림이다. 클라이언트가 n1 또는 n2에 기록한 데이터는 n3에 전달되지 않는다. n3에 기록되었으나 아직 n1 및 n2로 전달되지 않은 데이터가 있다면 n1과 n2는 오래된 사본을 갖고 있을 것이다.
가용성 대신 일관성을 선택한다면(CP 시스템) 세 서버 사이에 생길 수 있는 데이터 불일치 문제를 피하기 위해 n1과 n2에 대해 쓰기 연산을 중단시켜야 하는데, 그렇게 하면 가용성이 깨진다. 은행권 시스템은 보통 데이터 일관성을 양보하지 않는다.
하지만 일관성 대신 가용성을 선택한 시스템(AP 시스템)은 설사 낡은 데이터를 반환할 위험이 있더라도 계속 읽기 연산을 허용해야 한다. 아울러 n1과 n2는 계속 쓰기 연산을 허용할 것이고, 파티션 문제가 해결된 뒤에 새 데이터를 n3에 전송할 것이다.
키-값 저장소 구현에 사용될 핵심 컴포넌트들 및 기술들만 살펴보자.
이번 절에 다루는 내용은 널리 사용되고 있는 세 가지 키-값 저장소, 즉 다이나모, 카산드라, 빅테이블의 사례를 참고한 것이다.
대규모 애플리케이션의 경우 전체 데이터를 한 대 서버에 욱여넣는 것은 불가능하다. 가장 단순한 해결책은 데이터를 작은 파티션들로 분할한 다음 여러 대 서버에 저장하는 것이다. 데이터를 파티션 단위로 나눌 때는 다음 두 가지 문제를 중요하게 따져봐야 한다.
5장에서 다룬 안정 해시는 이런 문제를 푸는 데 적합한 기술이다. 안정 해시의 동작 원리를 간략히 다시보자.
안정 해시를 사용하여 데이터를 파티션하면 좋은 점은 다음과 같다.
높은 가용성과 안정성을 확보하기 위해서는 데이터를 N개 서버에 비동기적으로 다중화할 필요가 있다. 여기서 N은 튜닝 가능한 값이다. N개 서버를 선정하는 방법은 이러하다.
어떤 키를 해시 링 위에 배치한 후, 그 지점으로부터 시계 방향으로 순회하면서 만나는 첫 N개 서버에 데이터 사본을 보관하는 것이다. 따라서 N=3으로 설정한 아래 그림에선 key0은 s1~3에 저장된다.
그런데 가상 노드를 사용한다면 위와 같이 선택한 N개의 노드가 대응될 실제 물리 서버의 개수가 N보다 작아질 수 있다. 이 문제를 피하려면 선택할 때 같은 물리 서버를 중복 선택되지 않도록 해야 한다.
같은 데이터 센터에 속한 노드는 정전, 네트워크 이슈, 자연재해등의 문제를 동시에 겪을 가능성이 있다. 따라서 안정성을 담보하기 위해 데이터의 사본은 다른 센터의 서버에 보관하고, 센터들은 고속 네트워크로 연결한다.
여러 노드에 다중화된 데이터는 적절히 동기화 되어야 한다. 정족수 합의 프로토콜을 사용하면 읽기/쓰기 연산 모두에 일관성을 보장할 수 있다.
N=3일경우
W=1은 데이터가 한 대 서버에만 기록된다는 뜻이 아니다. 위 그림 같이 데이터가 s0~s2에 다중화되는 상황을 예로 살펴보자. W=1의 의미는, 쓰기 연산이 성공했다고 판단하기 위해 중재자는 최소 한 대 서버로부터 쓰기 성공 응답을 받아야 한다는 뜻이다. 따라서 s1으로부터 성공 응답을 받았다면 s0,s2로부터의 응답은 기다릴 필요가 없다. 중재자는 클라이언트와 노드 사이에서 프락시(proxy)역할을 한다.
W,R,N의 값을 정하는 것은 응답 지연과 데이터 일관성 사이의 타협점을 찾는 전형적인 과정이다. W=1 또는 R=1인 구성의 경우 중재자는 한 대 서버로부터의 응답만 받으면 되니 응답속도는 빠를 것이다. W나 R의 값이 1보다 큰 경우에는 시스템이 보여주는 데이터 일관성의 수준은 향상될 테지만 중재자의 응답 속도는 가장 느린 서버로부터의 응답을 기다려야 하므로 느려질 것이다.
W+R > N인 경우에는 강한 일관성이 보장된다.
일관성을 보증할 최신 데이터를 가진 노드가 최소 하나는 겹칠 것이기 때문이다.
그렇다면 면접 시에는 N, W, R 값을 어떻게 정해야 할까? 다음에 가능한 몇 가지 구성을 제시하였다.
요구되는 일관성 수준에 따라 W, R, N의 값을 조정하면 된다.
일관성 모델의 종류
강한 일관성을 달성하는 일반적인 방법은, 모든 쓰기 연산의 결과가 반영될 때 까지 해당 데이터에 대한 읽기/쓰기를 금지하는 것이다. 이 방법은 고가용성 시스템에 부적합하다. 새로운 요청의 처리가 중단되기 때문이다.
다이나모 또는 카산드라 같은 저장소는 최종 일관성 모델을 택하고 있는데, 이번 장에서도 그 모델에 맞게 키-값 저장소를 설계해볼 것이다.
데이터를 다중화하면 가용성은 높아지지만 사본 간 일관성은 깨질 가능성은 높아진다. 버저닝과 벡터 시계는 그 문제를 해소하기 위해 등장한 기술이다. 버저닝은 데이터를 변경할 때마다 해당 데이터의 새로운 버전을 만드는 것을 의미한다. 따라서 각 버전의 데이터는 변경 불가능하다.
버저닝에 대해 알아보기 전에 우선 데이터 일관성이 어떻게 깨지는지 알아보자.
아래 그림처럼 데이터의 사본이 노드 n1과 n2에 보관되어 있다고 가정하자. 이 데이터를 가져오려는 서버 1, 서버 2는 get(”name”)연산으로 값을 가져온다.
위 그림과 같이 서버 1은 “name”에 매달린 값을 “johnSanFrancisoco”로 바꾸고, 서버 2는 “johnNewYork”으로 바꾼다고 하자. 그리고 이 두 연산은 동시에 이뤄진다고 하자. 이제 우리는 충돌(conflict)하는 두 값을 가지게 되었다. 각각의 버전을 v1,v2라고 하자.
이 변경이 이루어진 이후에, 원래 값은 무시할 수 있다. 변경이 끝난 옛날 값이어서다. 하지만 마지막 두 버전 v1과 v2 사이의 충돌은 해소하기 어려워 보인다.
이 문제를 해결하려면 충돌을 발견하고 자동으로 해결해 낼 버저닝 시스템이 필요하다. 백터 시계는 이런 문제를 푸는데 보편적으로 사용되는 기술이다. 지금부터 그 동작 원리를 살펴보자.
백터 시계는 [서버, 버전]의 순서쌍을 데이터에 매단 것이다. 어떤 버전이 선행 버전인지, 후행 버전인지, 아니면 다른 버전과 충돌이 있는지 판벼하는데 쓰인다.
백터 시계는 D([S1, v1], [S2, v2], … [Sn, vn])와 같이 표현하다고 가정하자. 여기서 D는 데이터이고 vi는 버전 카운터, Si는 서버 번호이다. 만일 데이터D를 서버 Si에 기록하면 시스템은 아래 작업 가운데 하나를 수행해야 한다.
위의 설명은 추상적이다. 실제 로직이 어떻게 수행되는지 사례를 통해 알아보자.
벡터 시계를 사용하면 어떤 버전 X가 버전 Y의 이전 버전인지 쉽게 판단할 수 있다. 버전 Y에 포함된 모든 구성요소의 값이 X에 포함된 모든 구성요소 값보다 같거나 큰지만 보면 된다. 예를 들어 벡터 시계 D([s0, 1], [s1, 1])은 D([s0, 1], [s1, 2])의 이전 버전이다. 따라서 두 데이터 사이에 충돌은 없다.
어떤 버전 X와 Y 사이에 충돌이 있는지 보려면 Y의 벡터 시계 구성요소 가운데 X의 벡터 시계 동일 서버 구성요소보다 작은 값을 것이 있는지 보면 된다. 예를 들어, D([s0, 1], [s1, 2])와 D([s0, 2], [s1, 1])는 서로 충돌한다.
그러나 벡터 시계를 사용해 충돌을 감지하고 해소하는 방법에는 두 가지 분명한 단점이 있다. 첫 번째는 충돌 감지 및 해소 로직이 클라이언트에 들어가야 하므로, 클라이언트 구현이 복잡해진다는 것이다.
두 번째는 [서버: 버전]의 순서쌍 개수가 굉장히 빨리 늘어난다는 것이다. 이 문제를 해결하려면 그 길이에 어떤 임계치를 설정하고, 임계치 이상으로 길이가 길어지면 오래된 순서쌍을 벡터 시계에서 제거하도록 해야 한다. 그러나 이렇게 하면 버전 간 선후 관계가 정확하게 결정될 수 없기 때문에 충돌 해소 과정의 효율성이 낮아지게 된다. 하지만 다이나모 데이터베이스에 관계된 문헌에 따르면 아마존은 실제 서비스에서 그런 문제가 벌어지는 것을 발견한 적이 없다고 한다. 그러니 대부분의 기업에서 벡터 시계는 적용해도 괜찮은 솔루션일 것이다.
분산 시스템에서는 그저 한 대 서버가 “지금 서버 A가 죽었습니다”라고 한다해서 바로 서버 A를 장애처리 하지는 않는다. 보통 두 대 이상의 서버가 똑같이 서버 A의 장애를 보고해야 해당 서버에 실제로 장애가 발생했다고 간주하게 된다.
아래 그림과 같이 모든 노드 사이에 멀티캐스팅 채널을 구축하는 것이 서버 장애를 감지하는 가장 손쉬운 방법이다. 하지만 이 방법은 서버가 많을 때는 분명 비효율적이다.
가십 프로토콜 같은 분산형 장애 감지 솔루션을 채택하는 편이 보다 효율적이다. 가십 프로토콜의 동작 원리는 다음과 같다.
위 그림의 예제를 보자.
가십 프로토콜로 장애를 감지한 시스템은 가용성을 보장하기 위해 필요한 조치를 해야 한다. 엄격한 정족수 접근법을 쓴다면, 앞서 “데이터 일관성” 절에서 설명한 대로, 읽기와 쓰기 연산을 금지해야 할 것이다.
느슨한 정족수 접근법은 이 조건을 완화하여 가용성을 높인다. 정족수 요구사항을 강제하는 대신, 쓰기 연산을 수행할 W개의 건강한 서버와 읽기 연산을 수행할 R개의 건강한 서버를 해시 링에서 고른다. 이때 장애 상태인 서버는 무시한다.
장애 상태인 서버로 가는 요청은 다른 서버가 잠시 맡아 처리한다. 그동안 발생한 변경사항은 해당 서버가 복구 되었을 때 일관 반영하여 데이터 일관성을 보장한다. 이를 위해 임시로 쓰기 연산을 처리한 서버에는 그에 관한 단서를 남겨둔다. 이런 장애 처리 기법을 임시 위탁 기법이라고 부른다.
아래 그림을 보면 장애 상태인 노드 s2에 대한 읽기 및 쓰기 연산은 일시적으로 노드 s3가 처리한다. s2가 복구되면, s3는 갱신된 데이터를 s2로 인계 할 것이다.
임시 위탁 기법은 일시적으로 장애를 처리하는 기법이다. 영구적인 노드의 장애 상태는 어떻게 처리해야 할까? 그런 상황을 처리하기엔 반-엔트로피 프로토콜을 구현하여 사본들을 동기화 한다.
💡 반-엔트로피 프로토콜이란? 사본들을 비교하여 최신 버전으로 갱신하는 과정을 포함한다. 사본 간의 일관성이 망가진 상태를 탐지하고 전송 데이터의 양을 줄이기 위해서는 **머클**이라는 트리를 사용해야 한다. 💡 머클 트리란? 해시 트리라고도 부르는 머클 트리는 각 노드에 그 자식 노드들에 보관된 값의 해시, 또는 자식 노드들에 레이블로부터 계산된 해시 값을 레이블로 붙여두는 트리다. 해시 트리를 사용하면 대규모 자료 구조의 내용을 효과적이면서도 보안상 안전한 방법으로 **검증**할 수 있다.키 공간이 1부터 12까지일 때 머클 트리를 만드는 예제를 한번 살펴보자. 일관성이 망가진 데이터가 위치한 상자는 다른 색으로 표시해두었다.
이 두 머클 트리의 비교는 루트(root) 노드의 해시값을 비교하는 것으로 시작한다. 루트 노드의 해시 값이 일치한다면 두 서버는 같은 데이터를 갖는 것이다. 그 값이 다른 경우에는 왼쪽 노드 → 오른쪽 노드 순으로 해시 값을 비교한다. 이런 방식으로 탐색하여 데이터를 갖는 버킷을 찾고 그 버킷들만 동기화 한다
머클 트리를 사용하면 동기화해야 하는 데이터의 양은 실제로 존재하는 차이의 크기에 비례할 뿐, 두 서버에 보관된 데이터의 총량과는 무관해진다. 하지만 실제로 쓰이는 시스템의 경우 버킷 하나의 크기가 꽤 크다는 것은 알아두어야 한다. 가능한 구성 가운데 하나를 예로 들면 10억 개의 키를 백만개의 버킷으로 관리하는 것인데, 그 경우 하나의 버킷은 1,000개 키를 관리하게 된다.
데이터 센터를 다중화 하는 것이 중요하다.
키-값 저장소를 만드는 데 필요한 다양한 기술적 고려사항들을 살펴보았으나,
이제 아키텍처 다이어그램을 봐보자.
완전히 분산된 설계를 채택하였으므로, 모든 노드는 아래그림에 제시된 기능 전부를 지원해야 한다.
아래그림은 쓰기 요청이 특정 노드에 전달되면 무슨 일이 벌어지는지를 보여준다. 아래 그림에서 보인 구조는 기본적으로 카산드라의 사례를 참고한 것임에 유의하기 바란다.
읽기 요청을 받은 노드는 데이터가 메모리 캐시에 있는지 부터 살핀다. 있는 경우에는 아래 그림과 같이 해당 데이터를 클라이언트에게 반환한다.
데이터가 메모리에 없는 경우에는 디스크에서 가져와야 한다. 아까 말한 SSTable에서 찾아야 하는데 어떤 테이블에 원하는 Key가 있는지 알아낼 효율적인 방법이 필요할 것이다. 이런 문제를 푸는데는 블룸 필터가 흔히 사용된다.
데이터가 메모리에 없을 떄 읽기 연산이 처리되는 경로를 보면 아래 그림과 같다.
이번 장의 소개된 많은 개녁뫄 기술을 요약해 보자. 아래 표를 보고 분산 키-값 저장소가 가져야 하는 기능과 그 기능 구현에 이용되는 기술을 정리를 봐보자.
목표/문제 | 기술 |
---|---|
대규모 데이터 저장 | 안정 해시를 사용해 서버들에 부하 분산 |
읽기 연산에 대한 높은 가용성 보장 | 데이터를 여러 데이터센터에 다중화 |
쓰기 연산에 대한 높은 가용성 보장 | 버저닝 및 벡터 시계를 사용한 충돌 해소 |
데이터 파티션 | 안정 해시 |
점진적 규모 확장성 | 안정 해시 |
다양성 | 안정 해시 |
조절 가능한 데이터 일관성 | 정족수 합의 |
일시적 장애 처리 | 느슨한 정족수 프로토콜과 단서 후 임시 위탁 |
영구적 장애처리 | 머클 트리 |
데이터 센터 장애 대응 | 여러 데이터 센터에 걸친 데이터 다중화 |