본문 바로가기

nosql

cassandra 2편 -퍼옴

1. 들어가기에 앞서.

안녕하세요. NHN엔터테인먼트 엄세진입니다.

지난번 Cassandra 톺아보기 1편을 올린 뒤로 생각보다 많은 분들의 좋은 반응에 힘을 내어 2편을 작성하게 되었습니다. 사실 1편에서는 처음 Cassandra를 접하면서 저를 혼란스럽게 했던 정보들을 중심적으로 정리한 글에 가까워서, 인터넷을 통하여 검색하면 쉽게 찾아볼 수 있는 내용이라거나 이해를 돕기 위한 자세한 예제들은 분량상 생략한 부분이 있었습니다. 방대한 Cassandra의 내용을 모두 담을 수는 없기 때문에 이번 문서에서도 최대한 중요하거나 혹은 찾기 힘든 내용들로 간추렸다는 점을 유념해주시고, Cassandra를 자세히 알고 싶으신 분들은 Datastax의 공식 문서나 외국 도서를 읽으시는 편을 거듭 추천해 드립니다.

2. Cassandra의 데이터 분산

지난 1편을 통하여 우리는 Cassandra가 여러 개의 노드로 구성된 Ring 형태를 띄고 있다는 것과 각 노드는 각각의 hash token 범위를 담당하고 있으며, partition key로 지정한 column의 value들로 Row key를 결정하고, 이를 hashing한 token을 기준으로 데이터를 노드별로 분산하여 저장한다는 사실을 정리한 바 있습니다. 게다가 Virtual Node가 무슨 용어인지도 대충 알았으니, 이제는 얼추 어떠한 원리로 데이터를 분산하는지 대략은 알 것 같다는 생각이 듭니다. 그렇다면 이제 자연스럽게 Read/Write에 따른 Cassandra의 동작 과정을 확인해야겠지만, 그 전에 Cassandra의 데이터 분산 방식에 대해 아주 살짝쿵 좀 더 자세히 알아보겠습니다.

왜냐구요? 그냥 넘어가기엔 생각보다 중요하기 때문입니다.

사실 지금까지 Cassandra에 대해 정리한 내용에는 나름 중요한 몇 가지 사실들이 빠져있습니다. 그중에서도 가장 중요한 것은 바로 Row key를 token으로 어떻게 변환하느냐는 점입니다. Cassandra에서는 이렇듯 Row key를 token으로 변환해주는 모듈을 Partitioner라고 부릅니다. conf/cassandra.yaml의 partitioner 항목을 보면 해당 Cassandra가 어떤 Partitioner를 사용하는지 확인 할 수 있으며, Cassandra는 기본적으로 RandomPartitioner, Murmur3Partitioner, ByteOrderedPartitioner라는 이름의 세 가지 Partitioner를 제공합니다. (물론 사용자가 org.apache.cassandra.dht.IPartitioner 를 상속하여 원하는 Partitioner를 구현 할 수도 있지만 이를 추천하지는 않습니다. 어렵기도 하고 성능을 보장하기도 힘들기 때문입니다. 물론, 운영상의 문제도 같이 발생 할 수 있겠죠.)

RandomPartitioner는 Row key를 MD5로 hashing하여 token을 생성합니다. Murmur3Partitioner는 Murmur5로 Hashing하여 token을 생성하죠. 하지만 ByteOrderedPartitioner(이하 BOP)는 조금 다릅니다. BOP는 Row key를 16진수 형태로 변환하여 이 값을 token으로 사용합니다. 즉, BOP 변환된 token은 문자 순서로 정렬되어 각 노드에 분산된다는 이야기가 됩니다. 만약 BOP를 쓰게 된다면, Row key들이 항상 문자 순서대로 정렬되어있으니 대용량 데이터를 특별한 가공 없이 그대로 range query를 할 수 있는 등, 여러 가지로 편리하게 사용 할 수 있겠죠. 하지만 BOP는 대표적인 Cassandra의 Anti-Pattern 중에 하나입니다. 왜일까요? 이유는 BOP를 사용할 경우 Hotspot이 발생할 확률이 매우 높기 때문입니다.



(그림 1 : ByteOrderedPartitioner를 사용 시 Hotspot 발생의 예. A~G로 시작하는 Row key가 과도하게 많을 경우 노드 하나에 비대한 데이터가 쌓이게 된다.)

BOP를 사용하여 Row key 문자 순서대로 각 노드별로 데이터를 분산하게 된다면, 모든 노드에 데이터를 균일하게 분산하기 위해서는 데이터의 분산 기준을 담당하는 Row key 자체가 모든 문자열에 대해서 균일하게 분포해야 합니다. 하지만 현실은 그렇지 않죠. BOP를 사용 할 경우, 특정 문자로 밀집되어있는 Row key들을 저장하는 노드가 자연스럽게 Hotspot이 되어버립니다. 더구나 이는 단순히 노드를 늘리거나 줄인다고 해서 해결되는 일이 아닙니다. 노드를 늘렸더니 오히려 전혀 사용하지 않는 노드가 다수 생길 수도 있고, 노드를 줄였더니 더 심각한 Hotspot이 생겨버릴 수도 있기 때문이죠. 이는 BOP의 경우 분산의 정도가 전적으로 Row key의 분포에 달려있기 때문입니다.
이러한 이유로 Cassandra는 Murmur3Partitioner를 default Partitioner로 사용하고 있습니다. MurMur3 hash function을 이용함으로 인해서 모든 데이터를 비교적 균일하게 모든 노드에 분산 할 수 있는 것이죠. 물론 이 역시 단점은 존재합니다. 문자열이 아닌 Hash 값을 기준으로 정렬하여 각 노드에 저장되므로, Row key의 문자열로 정렬된 데이터가 필요 할 경우엔 사용자는 모든 데이터를 가져온 다음 Application Layer에서 직접 데이터를 가공하여 정렬하여야 합니다. 가령 엄청난 양의 데이터가 분산되어 저장되어있는데, Row key를 기준으로 데이터를 paging하는 등의 작업은 불가능한 일인 셈이죠. 그 많은 데이터를 모두 가져와서 직접 정렬해야 최종적으로 사용자가 원하는 위치의 데이터 집합을 뽑아낼 수 있을 테니까요. 따라서 사용자는 자신의 서비스가 사용 할 데이터의 스키마를 작성 할 때, 처음부터 이를 고려하여 신중히 결정하여야 합니다.

지금까지 Partitioner에 대해 대략적으로 짚어 보았으니 이번엔 Cassandra의 Data Consistency와 Replication에 대해 간단히 알아볼 차례입니다.
Cassandra는 기본적으로 CQL을 통해 쿼리 시점에 Read와 Wirte에 따른 다양한 Consistency Level을 통해서 몇 개의 Replication을 통해 어느 정도 수준의 데이터 일관성을 확보 할 것인지 선택 할 수 있습니다. 또한, 처음 Keyspace 생성 할 때 Replication의 배치 전략과 그 전략에 맞는 Replication 복제 개수, 위치 위치를 결정 할 수 있죠. 그리고 이러한 기능을 지원하기 위해서 conf/cassandra.yaml의 endpoint_snitch 항목에 snitch의 종류를 세팅하게 됩니다. 그러면 snitch란 뭘까요? 쉽게 말하자면, 데이터센터가 어떻게 구성되어있는지, 장비가 설치된 렉이 어떻게 나뉘어져 있는지에 대한 topology를 Cassandra에게 알려주기 위한 옵션입니다.
Cassandra에서 제공하는 snitch의 종류는 매우 다양합니다. snitch는 단순히 1개의 Data Center를 가정한 것도 있고, 다수의 Data Center에 다양한 Rack 배치까지 고려한 것도 있으며, 심지어 Cloud Stack이나 Google Cloud와 같은 Cloud 서비스에 특화된 snitch도 존재합니다. 이러한 snitch를 바탕으로 Cassandra는 사용자가 정의한 스키마에 따라서 어느 Data Center의 어느 Rack에다가 각각 몇 개의 Replication Data를 나누어 저장 할 것인지 등을 결정하는 것이죠. 그리고 이렇게 구성된 Cassandra에 사용자가 데이터를 CRUD하고자 한다면, 사용자가 해당 쿼리와 함께 지정한 Consistency Level을 통하여 데이터를 처리하게 됩니다.
Read/Write에 따른 Consistency Level의 종류와 특징, 다양한 snitch들 각각의 자세한 설명들은 분량상 이 글에서 모두 다루기 힘들기 때문에 Datastax의 공식문서를 참조하시길 부탁드립니다. ( 참조 링크 : SnitchData Consistency )

3. Cassandra와 Read/Write.

이제는 데이터의 분산이라는 관점에서 Cassandra를 어느 정도 살펴보았으니 이제 실제로 Cassandra가 데이터를 읽고 쓸 때 일어나는 동작에 대해서 살펴볼 차례입니다. 먼저 Cassandra에 데이터를 Write하는 상황을 가정해봅시다.



(그림 2 : Cassandra Data Write)

사용자는 Cassandra의 어느 노드들 중 하나에 Write 요청을 합니다. Cassandra에서는 요청을 받게 되는 최초의 노드를 Coordinator 노드라고 부릅니다. 그러면, 이 Coordinator는 해당 데이터의 Row key를 hashing하여 어느 노드들에 데이터를 Write해야 하는지 확인합니다. 그런 뒤에 해당 쿼리에 지정된 Consistency Level에 따라 몇 개의 노드에 Write해야하는지 참고하여 현재의 데이터를 Write해야 할 노드들의 status가 정상인지를 확인합니다. 이때, 특정 노드의 status가 정상이 아니라면 Consistency Level에 따라 "hint hand off"라는 로컬 임시 저장공간에 Write 할 데이터를 저장합니다. 만약 나중에 비정상 상태의 노드가 정상으로 돌아오면 Coordinator 노드가 data를 Write해주기 위해서이죠. 이때 주의 할 점은 hint hand off가 항상 모든 데이터를 Consistency를 보장해주는 것은 아니라는 점입니다. 비록 데이터의 복원에 큰 도움이 되는 기능일지라도 기본적으로 hint hand off는 데이터를 임시로 저장하는 공간이기 입니다.(만약 hint hand off에 데이터가 저장한 뒤 해당 Coordinator 노드가 죽어버리는 경우를 상상해보시길 바랍니다.)
어쨌거나 hint hand off에 데이터를 백업했다면, Coordinator 노드는 Cassandra의 topology를 확인하여 어느 데이터 센터의 어느 렉에 있는 노드에 먼저 접근 할 것인지 결정하여 데이터와 함께 Write를 요청합니다.



(그림 3 : Write Data Storage Layer. 이미지 출처 : Datastax)

실제로 데이터 저장하게 될 노드는 Write 요청이 오면 혹시 모를 장애에 대비하여, "CommitLog"라고 불리는 로컬 디스크의 파일에 기록을 남깁니다. 그런 뒤에 "MemTable"이라는 이름의 메모리 저장공간에 데이터를 Write한 뒤, 성공 메시지를 돌려줌으로써 Write 요청에 대한 동작은 마무리됩니다. 그리고 해당 노드는 MemTable에 데이터가 충분히 쌓이면 디스크 버전의 MemTable인 "SSTable"에 데이터를 Flush합니다. 이때, SSTable은 immutable하며, sequential하다는 특징을 가지고 있으며 Cassandra는 이러한 다수의 SSTable을 Compaction하여 데이터를 관리합니다.
그러면 Read의 처리과정은 어떨까요? 알아봅시다.



(그림 4 : Cassandra Data Read)

사용자는 Cassandra의 어느 노드들 중 하나에 Read 요청을 합니다. Coordinator 노드는 해당 요청의 Row key를 hashing하여 접근해야 할 노드의 위치를 파악한 뒤, Consistency Level을 체크하여 몇 개의 Replication을 확인해야 할지 결정합니다. 그런 뒤에 Coordinator 노드는 데이터가 있는 가장 가까운 노드에는 Data Request를 요청하고, 그다음 가까운 노드들에는 Data Digest Request를 요청합니다. Coordinator 노드는 이렇게 가져온 Data와 Data Digest를 확인하여 데이터 정보가 일치하지 않으면 일치하지 않는 데이터들의 노드들로부터 Full Data를 가져와서 그중 가장 최신 데이터를 사용자에게 돌려줍니다. 그리고 뒤엔 돌려준 최신 데이터를 기준으로 나머지 노드들의 데이터들을 수리합니다.
그렇다면 실제 데이터가 저장된 노드 안에서는 어떤 과정으로 동작할까요?



(그림 5 : Read Data Storage Layer. 그림의 Partition summary란 Cassandra 구동 시 메모리에 올려놓은 index의 요약본을 의미한다. 이미지 출처 :Datastax)

실제 데이터가 저장되어있는 노드에 데이터 요청이 오게 되면, 먼저 MemTable에 저장된 데이터들을 확인합니다. 이때, 데이터가 있으면 좋지만 없다면 이미 Flushing된 데이터들이 저장되어있는 SSTable들을 확인해야겠죠. 그렇다고 당장 SSTable에 접근하는 것은 아닙니다. 각각의 SSTable을 확인 할 때, 성능 향상을 위해서 해당 SSTable과 짝지어서 구성되어 있는 Bloom Fileter와 Index라는 것을 먼저 확인하는 것이죠. (여기서 Index란 추후 소개 할 Secondery Index와는 다른 용어입니다.)
Bloom Filter란 긍정오류는 발생 할 수 있지만, 부정오류는 발생하지 않는 확률적인 자료구조입니다. 쉽게 말해서, 없는 걸 있다고 거짓말 할 수는 있지만, 있는 걸 없다고 거짓말 하지는 않는다는 것이죠. I/O가 일어나기 전에 일차적으로 메모리에 저장되어있는 Bloom Filter를 통하여 짝꿍 SSTable에 데이터가 존재하는지를 확인했다면, 그 다음엔 역시 메모리에 저장되어있는 Summery Index를 통해 디스크에 저장되어있는 원본 Index를 확인하여 SSTable 내 Data 위치에 대한 offset을 알게됩니다. 이 과정을 모두 거친 뒤에야 비로소 해당 SSTable에서 원하는 데이터를 가져와 돌려줄 수 있는 것이죠. 이러한 데이터의 검색 과정은 가장 최근에 생성된 SSTable부터 차례대로 이루어지게 됩니다.

글이 길어졌지만 아직 짚고 넘어가야 할 점이 남아 있습니다. Delete에 대한 이야기입니다.
Cassandra는 Delete를 바로 수행하지 않습니다. 모든 데이터에는 Tombstone이라는 marker가 존재하며, 특정 데이터의 Delete 요청이 일어날 경우 이 Tombstone에 마킹을 한 뒤에 주기적인 Garbage Collection이나 SSTable의 Compaction 등의 이벤트가 발생 할 때 비로소 데이터를 정말로 삭제하는 것이죠. 이러한 Tombstone은 꽤나 중요한 개념이기 때문에 어떠한 문제를 일으킬 수 있는지에 대해서 Cassandra의 안티패턴과 관련하여 나중에 한 번 더 다루겠습니다.

이렇게 Cassandra의 데이터의 처리 과정을 정리하고 보니 한가지 빠진 부분이 있는 것 같습니다. 바로 Update입니다. 하지만 앞서 많은 내용들을 이미 설명하였기 때문에 Update는 몇 가지 중요한 부분만 살짝 짚고 넘어가면 될 것 같습니다. Cassandra의 Update는 내부적으로 Delete/Write로 구현되어있기 때문입니다. 앞서 말했듯이 데이터가 저장되어 있는 SSTable은 immutable하므로 Delete를 통해 Tombstone에 마킹을 하게 되고, Update 해야 할 새로운 데이터는 다른 곳에 쓰여지게 됩니다.

4. 2편을 마치며.

어쩌다 보니 2편도 마찬가지로 내용이 많이 길어지게 되어 죄송한 마음입니다. 최대한 간추린다고 간추렸는데 쉽지가 않았습니다. 이번 편에서 미처 다루지 못한 Cassandra가 제공하는 기능들, 자주 쓰이거나 써서는 안 되는 패턴 등의 내용은 마지막 3편에서 마무리하겠습니다.

감사합니다.

'nosql' 카테고리의 다른 글

apache Hbase  (0) 2016.04.07
nosql 범주  (0) 2016.04.07
nosql 등장배경  (0) 2016.04.07
cassandra 3편 -퍼옴  (1) 2016.04.06
cassandra 1편 -퍼옴  (0) 2016.04.05