본문 바로가기

nosql

cassandra 3편 -퍼옴

1. 들어가기에 앞서

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

드디어 마지막 3편으로 다시 찾아뵙게 되었습니다. 지난 1, 2편을 통하여 Cassandra가 어떻게 분산하고 데이터를 어떻게 입출력하는지를 중심으로 알아보았으니 그렇다면 이제는 확인한 내용을 바탕으로 Cassandra를 어떻게 사용할지에 대한 고민을 할 차례가 되었습니다. 따라서 Cassandra가 지원하는 기능과 Cassandra를 사용할 때 유용하게 쓰이는 Pattern 그리고 되도록이면 피해야할 Anti-Pattern, 그리고 중요한 내용들을 설명하느라 미처 다루지 못하고 넘어갔었던 내용들에 대해 몇가지 정리해드리고자 합니다. 역시 이번 편도 마찬가지로, 내용을 설명하다보면 끝없이 길어질 수 있기 때문에, 대략적인 내용과 원리를 중심으로 최대한 다양하게 다룰 수 있도록 글을 구성하였으니 유념하여주시기 바랍니다. 덧붙여 이번 편을 이해하기 위해서는 Cassandra 전반적인 구조를 알고 있는 편이 좋으므로 이 글을 처음 보는 분이시라면 되도록 1, 2편을 먼저 읽어보시기를 권합니다.

2. Cassandra가 지원하는 기능들

Cassandra는 Gossip 프로토콜을 통하여 모든 노드가 동등한 Ring 구조를 이루고 있고, 이로 인하여 효과적인 데이터 분산 및 높은 Scalability 그리고 High Availability를 실현할 수 있었지만 아시다시피 Cassandra가 모든 점에서 완벽한 솔루션은 아닙니다. 많은 NoSQL 제품들이 그렇듯이 Cassandra 역시 Join이나 Transaction을 지원하지 않고, 높은 수준의 Index 역시 제공하지 않습니다. Cassandra가 지원하는 Index는 앞서 말했던 Row Key를 검색하기 위한 기본적인 Index 와 0.7버전부터 추가된 Secondary Index라는 조촐한 기능을 가지고 있을 뿐이죠.(Secondary Index는 조금 후에 설명하겠습니다.) 그렇다면 Cassandra의 특징적인 기능들을 몇가지 알아보고, 이러한 기능들이 어떤 특징을 가지고 있는지 짚어보겠습니다.

(1) Light-Weight Transaction.

이름에서도 짐작할 수 있듯이, Light-Weight Transaction은 아주 간단하게나마 가볍게 지원하는 작은 범주의 Transaction 기능입니다. 서비스를 개발하다 보면 중복되면 안 되거나 일관성을 유지해야하는 데이터를 다뤄야할 때가 있습니다. Light-Weight Transaction은 이럴 때 유용하게 사용할 수 있는 기능으로, 특정 조건에 맞추어 데이터를 조작할 수 있도록 해줍니다. 즉, Compare and Set 에 한정된 Transaction인 것이죠. 사용 방법은 비교적 간단한데, Insert와 Update 구문에서 IF 구문을 이용하는 것으로 쓸 수 있습니다. 간단한 예를 들어보겠습니다. 먼저 아래와 같은 Table이 있다고 가정하고 하나의 데이터를 Insert 해봅시다.

 CREATE TABLE test_keyspace.test_table_ex_1 ( id text PRIMARY KEY, name text, descript text );
 INSERT INTO test_keyspace.test_table_ex_1 (id, name , descript ) VALUES ( 'id_1', 'name_1', 'test_data');
 SELECT * FROM test_keyspace.test_table_ex_1;





(그림 1 : 1개의 데이터가 들어가 있다.)

이전 글에서 말씀드렸듯, 기본적으로 Cassandra는 Insert와 Update가 내부적으로는 사실상 동일하게 동작하기 때문에, 이때 아래와 같은 쿼리를 실행할 경우, RDBMS와는 달리 문제 없이 데이터가 수정이 가능합니다.

 INSERT INTO test_keyspace.test_table_ex_1 (id, name , descript ) VALUES ( 'id_1', 'name_1', 'test_data_2');
 SELECT * FROM test_keyspace.test_table_ex_1;





(그림 2 : Insert를 실행했음에도 쿼리문이 수행되어 description을 수정해버렸다.)

이런 Cassandra의 특징은 여러가지 문제를 발생시킬 수 있습니다. 섣불리 Insert했다가 기존 데이터가 손실되어버리니까요. 특히 중복되어 존재해서는 안되는 종류의 데이터라면 더욱 문제겠죠. 그렇다면 이제 똑같은 쿼리문에 IF NOT EXISTS를 붙여 실행해봅시다.

 INSERT INTO test_keyspace.test_table_ex_1 (id, name , descript ) VALUES ( 'id_1', 'name_1', 'test_data_3') IF NOT EXISTS;




(그림 3 : applied라는 Column은 해당 데이터 쿼리의 성공 여부를 보여준다.)

지금의 경우, 이미 존재하는 값이 있기 때문에 데이터를 수정하지 않고 쿼리 동작이 실패했음 알 수 있습니다. 그렇다면 사실상 Insert와 동일한 Update도 Light-Weight Transaction을 제공하지 않을까요? 맞습니다. 다만 Update는 Insert와는 조금 다른 형태로, 아래와 같은 구문을 이용하여 기능을 제공합니다.

UPDATE test_keyspace.test_table_ex_1 SET descript='test_data_3' WHERE id = 'id_1' IF name = 'name_1';




(그림 4 : name column이 "name_1"일 때만 쿼리가 정상적으로 수행된다.)

이렇듯 Insert와 Update에 한하여 제공되는 Light-Weight Transaction은 Transaction을 제공하지 못하는 Cassandra에서 때때로 유용하게 쓸 수 있는 기능입니다. 하지만 아무래도 문법적으로 제공하는 Query의 자유도도 여전히 낮은 수준이며, Light-Weight Transaction 자체가 사용을 남용할 경우 필연적으로 성능의 저하를 불러올 수 있기 때문에 꼭 필요한 곳에 적당히 사용하는 것이 중요합니다.

(2) Secondary Index

Secondary Index는 Cassnadra 7.0 이후에 검색을 위하여 추가된 간단한 기능입니다. 기본적으로 Cassandra는 Partition Key(Row Key)로 데이터를 분산하고 Cluster Key(혹은 Column Name)로 Row 내 데이터 정렬을 기본적으로 하고 있기 때문에 이 두 가지 Key로 지정된 CQL Column에 대한 Data는 CQL의 Where 구문을 이용한 검색이 가능하지만, 그렇지 않은 CQL Column에 대해서는 검색 방법이 없었습니다. 때문에 많은 Cassandra 사용자들이 데이터의 비정규화를 통하여 문제를 해결해 왔었죠.


(그림 5 : 검색 결과가 나오지 않는다.)

하지만 Secondary Index를 생성한 후 같은 Query를 실행한다면 이러한 문제는 간단히 해결됩니다.


(그림 6 : Where문에 Partition key의 정보가 없이도 간단하게 검색할 수 있다.)

만약 Secondary Index가 없다면, 원하는 데이터를 얻기 위해서는 반드시 Partition Key와 Cluster Key를 알고서 검색해야 하지만, 특정 CQL Column에 Secondary Index를 생성하여 사용한다면 해당 CQL Column에 대하여 원하는 데이터를 얼마든지 검색이 가능하게 된 것이죠.


(그림 7 : '>'와 같은 Range Query는 동작하지 않는다.)

물론 많은 분들이 예상하시다시피 이 역시 만능 열쇠는 아닙니다. 구조상 구현하기 힘든 기능은 반드시 Trade Off를 동반하는 법이니까요. Secondary Index는 Range Query를 지원하지 않기 때문에 '='와 Contains와 같은 문법을 이용한 한정적인 검색만 가능한 데다가 남용하지 말아야할 성능상의 이유가 분명히 존재합니다. 이렇듯 왜 Secondary Index를 남용해서는 안되는지, 여전히 비정규화 기법이 유용하게 사용되고 있는지에 대해서는 조금 후에 이야기하겠습니다.

(3) Batch

Cassandra를 사용하다 보면, 때떄로 한 번에 두개 이상의 동작을 한 번에 수행해야할 필요성을 느끼게 됩니다. 물론 이 동작들에 대한 Transaction이 보장된다면 더할나위 없이 좋겠지만, 아시다시피 Cassandra는 Transaction을 지원하지 않죠. 대신 Transaction의 네가지 요소(ACID) 중에서 Atomic에 대한 동작만을 보장해주는 기능이 있는데, 그것이 바로 Batch라는 기능입니다.
Batch는 여러개의 Query문을 한 번에 묶어서 한번에 수행할 수 있으며 해당 동작에 대해 반드시 All or Noting의 결과를 보장합니다. 다만, Batch의 성능은 다소 느린 편이며 Transaction을 보장하는 기능이 아니라는 점들을 명확히 알고 사용하지 않으면 낭패를 보기 쉽다는 점에 주의해야합니다.

(4) Collection

초창기 Cassandra에는 Super Column과 Super Column Family라는 개념이 있었습니다. Cassandra의 Column Value에 또다시 Column 데이터가 들어갈 수 있었고, 이를 Super Column이라고 불렀으며, 이러한 Supser Column 타입의 데이터를 가지고 있는 Column Family를 Super Column Family라고 불렀던 것이죠. 이는 스키마를 구성하는 데에 있어서 상당히 편리한 면이 있었지만 다소 사용하기에는 복잡한 개념이었는데, 다행스럽게도 이 두 개념은 Cassandra 1.2 부터는 폐기되어 사라졌고 그 자리는 Collection이라는 것으로 대체되었습니다. (물론 기존 버전과 호환성을 위하여 Super Column 및 Super Column Family을 사용할 경우엔, 내부적으로 Collection으로 저장되는 형식으로 변경되었습니다.)
Collection은 이름에서부터 알 수 있듯이 Set, List Map 세가지 타입으로 나뉩니다. 그리고 실제 데이터가 입력될 Value 값들의 사이즈는 64KB로 크기가 제한되어있죠. 하지만 이보다 더욱 주목해야할 부분은, 이런 세 가지 타입의 데이터가 실제 Cassandra Data Layer에서는 각각 어떤 방식으로 저장되느냐는 점입니다. 차례대로 살펴보겠습니다.

 Create TABLE  test_keyspace.test_table_set (name text PRIMARY KEY , data set); 
 INSERT INTO test_keyspace.test_table_set (name, data ) VALUES ( 'eom', {'1', '1', '1'} );
 SELECT * FROM test_keyspace.test_table_set;



(그림 8 : Set의 특성상 한 개의 1 값만 저장되었음을 알 수 있다.)

먼저 Set을 Column으로 가지는 Table을 만들고 데이터를 Insert한 뒤에 Select구문으로 살펴보면 위와 같은 결과를 얻을 수 있습니다. CQL 상에서 Set 타입의 자료는 지극히 상식적으로 저장되었음을 알 수 있죠. 그렇다면 실제 데이터의 형태로 보여주는 cli 유틸리티에서 이 데이터를 가져오면 어떻게 표현될까요?

 use test_keyspace;
 list test_table_set;



(그림 9 : data:와 붙어있는 숫자는 set에 속한 String의 16진수 byte 값이다.)

Set 타입으로 지정된 CQL Column의 이름에 ":"문자를 붙인 뒤 실제 데이터의 16진수로 변환한 값을 붙여 Column Name으로 사용하고 있습니다. 그리고 해당하는 Column의 Value는 비어있다는 걸 알 수 있습니다. 그렇다면 이번엔 List는 어떨까요?

 Create TABLE  test_keyspace.test_table_list (name text PRIMARY KEY , data list);
 INSERT INTO test_keyspace.test_table_list (name, data ) VALUES ( 'eom', ['1', '1', '1']); 
 SELECT * FROM test_keyspace.test_table_list;



(그림 10 : List의 특성상 모든 1의 값이 저장되었다.)

 use test_keyspace;
 list test_table_list;



(그림 11 : "data:"에 붙어있는 문자열은 Cassandra가 발급한 UUID이다.)

중복 데이터를 포함할 수 있는 List의 특성상, Set과는 달리 Column Name에는 Cassandra가 발급한 UUID가 대신 자리를 차지하게 됩니다. 그리고 실제 List에 삽입했던 데이터들은 Column의 Value에 들어가 있는 걸 확인할 수 있죠. 이렇게 Set과 List를 살펴 보고 나니 Map은 어떻게 저장될 것인지 대충 알 것 같습니다. 하지만 정확한 확인을 위하여 Map 역시 한 번 테스트해봅시다.

 Create TABLE  test_keyspace.test_table_map (name text PRIMARY KEY , data map);
 INSERT INTO test_keyspace.test_table_map (name, data ) VALUES ( 'eom', {'1':'sejin', '1':'sejin', '2':'duron'}); 
 SELECT * FROM test_keyspace.test_table_map;



(그림 12 : key가 겹치는 1에 대한 값은 하나만 저장되었음을 확인할 수 있다.)

 use test_keyspace;
 list test_table_map;



(그림 13 : map의 key는 Column Name에, value는 Column Value에 저장되었다.)

Set과 같은 특성을 가진 Map의 Key는 Column Name에 ":"문자와 함께 저장되었고, List와 같은 특성을 가진 Map의 Value는 Column Value에 저장되었음을 확인할 수 있습니다.
이렇듯 Cassandra 내부에서 Collection이 실제로 어떻게 저장되는가를 간단히 살펴보았습니다. 이러한 내용들을 반드시 살펴야하는 이유는 실제 데이터 저장 방식을 고려하지 않은 채 Cassandra Table의 스키마를 작성할 경우, 때때로 성능상의 문제를 일킬 수 있기 때문입니다. 예를 들어, 앞선 내용에서 추측할 수 있듯, Collection에 데이터를 끊임없이 삽입하게 된다면 Cassandra에 저장되는 Row의 길이만 계속 늘어나게 될 뿐, Cassandra 최대의 장점인 데이터 분산은 이루어지지 않아 Hotspot이 발생할 위험 높아지기 등, 여러가지 리스크가 생길 수 있겠죠. 따라서 실제로 데이터가 어떻게 저장되는가에 대한 부분을 아는 것은 생각보다 중요한 일입니다.

3. Cassandra Pattern

지난 글들을 차분히 돌이켜 생각해보면 Cassandra의 큰 특징은 모든 노드가 동등한 Ring 형태의 분산시스템이며, 데이터를 쓰고 읽는데 탁월한 장점을 가지고 있는 시스템이라는 사실로 요약해볼 수 있습니다. 그렇다면 Cassandra를 올바르게, 혹은 올바르지 않게 사용한다는 것은 아무래도 이러한 Cassandra의 구조에 맞도록 스키마를 설계하고 사용하는가와 밀접한 연관이 있겠죠. 익히 널리 알려진 내용들을 중심으로 Cassandra를 어떻게 사용해야하는지, 어떻게 사용하면 안되는지 하나씩 알아보도록 하겠습니다.

(1) Time Sequencial Data

우리는 Cassandra는 Partition Key (Row Key) 단위로 데이터를 분산하고, 하나의 Partition Key 안에서 데이터는 SSTable이라는 저장소에 이미 정렬된 상태로 기록된다는 사실을 알고 있습니다. 또한 Cassandra는 훌륭한 분산형 구조를 통해 많은 데이터를 쓰고, 읽는데 적합하지만, Tombstone이라는 방식으로 인하여 대량의 Data에 대한 Delete에는 다소 부적합는 사실 역시 이미 어느정도 살펴본 바 있죠. 그러면 Cassandra는 어디에 사용하면 좋을까요? 아무래도 SNS라던가 Log Data등 저장하는 데에 좋지 않을까요? 그리고 이러한 데이터들은 한가지 특징을 가지고 있습니다. 시간 순서대로 쌓인다는 점이죠.
한가지 서비스를 가정해봅시다. 5분 간격으로 각 서버들의 실제 사용자 숫자와 서버의 상태를 기록해야하는 서비스가 있다고 말이죠. 그렇다면 이러한 기록성 데이터는 어떤 형식으로 저장하면 좋을까요? 분산시킬 데이터는 서버단위로 나누면 좋을 것 같으니 Partition key는 아마도 Server의 ID가 적합할 것이고, 각 데이터는 5분 간격으로 일정하게 저장되니 Timestamp가 Cluster Key로 적합하지 않을까요? 간단히 Table을 생성한 뒤에 5분 간격으로 4번의 데이터가 수집되었다고 가정하고 데이터를 넣어봅시다.

 CREATE TABLE test_keyspace.test_ts ( serverId TEXT, timestamp INT, data TEXT, PRIMARY KEY ( serverId, timestamp ) );
 INSERT INTO test_keyspace.test_ts ( serverId, timestamp, data ) VALUES ( 'server_1', 1455194927, '{ "userNumber" : 100, "serverStatus" : "STABLE" }' );
 INSERT INTO test_keyspace.test_ts ( serverId, timestamp, data ) VALUES ( 'server_1', 1455195227, '{ "userNumber" : 105, "serverStatus" : "STABLE" }' );
 INSERT INTO test_keyspace.test_ts ( serverId, timestamp, data ) VALUES ( 'server_1', 1455195527, '{ "userNumber" : 100, "serverStatus" : "STABLE" }' );
 INSERT INTO test_keyspace.test_ts ( serverId, timestamp, data ) VALUES ( 'server_1', 1455195827, '{ "userNumber" : 95, "serverStatus" : "STABLE" }' );
 SELECT * FROM test_keyspace.test_ts;



(그림 14 : 데이터를 넣은 직후의 결과. 총 4개의 데이터가 저장되었다.)

자 이렇게 들어간 데이터는 CQL로만 보더라도 사용하기 매우 편할 것 같습니다. 서버의 ID를 기준으로 Cassandra 노드별로 데이터가 분산될 것이고, 분산된 데이터 안에서는 Cluster Key 기준으로 데이터가 애초에 정렬되어 저장될 테니 WHERE문과 timestamp를 이용해 Range query도 사용이 가능하니까요. 정확한 확인을 위해서 cli 유틸리티를 통해 데이터가 실제로는 어떻게 저장되어있는지 봅시다.

 use test_keyspace;
 list test_ts;



(그림 15 : 이때 각 Column에 들어있는 timestamp는 우리가 CQL Column으로 지정한 CQL Column timetamp가 아니라 cassandra가 내부적으로 사용하기 위해 모든 Column에 기본적으로 붙이는 timestamp이므로 혼동하지 말아야 한다.)

역시 기대대로 Server의 ID는 Row Key가 되었고 Cluseter Key로 지정했던 timestamp의 value값들에 남은 CQL Column 이름이 ":"문자와 붙어 Cassandra Data Layer의 Column Name이 된 것은 알 수 있습니다. 그리고 data CQL Column에 저장했던 데이터들은 16진수 byte array로 변환되어 저장된 것을 확인할 수 있군요. 우리가 원하는 조건을 대부분 만족한 것 같습니다만... 과연 그럴까요?
이런 방식의 스키마는 한 가지 문제를 가지고 있습니다. 이대로라면 시간이 지날 수록 서비스가 저장하는 데이터가 계속 늘어나지 않을까요? 데이터를 분산하는 기준은 Partition Key(Row Key)이므로 하나의 Server Id에 대응되는 모든 시간에 대한 데이터들은 단 하나의 Partition Key를 기준으로 저장될 겁니다. 이는 결국 해당 Partition Key를 담당하고 있는 노드가 Hotspot이 될 가능성도 높아질 것이고, Row의 길이가 길어지면 길어지는 만큼 검색, 삭제에 대한 성능 저하로 이어질 게 분명합니다. 이는 Cassandra의 대표적인 Anti-Pattern중 하나로, Row가 무한정 길어지는 이런 방식의 스키마 구성은 피해야합니다.
그렇다면 어떤 방식으로 스키마를 설계하면 될까요? 물론 100% 올바른 정답이라는 것은 없겠지만, 일단 아래와 같은 테이블을 생성하고 같은 형식의 데이터를 넣어봅시다. 단, 이때 timeboundery에는 매 시간별 정각에 대한 timestamp 값을 넣도록 합니다. (덧 : 1455192000 = 2016. 2. 11. 오후 9:00:00, 1455195600 = 2016. 2. 11. 오후 10:00:00)

 CREATE TABLE test_keyspace.test_ts_2 ( serverId TEXT, timeboundery INT, timestamp INT, data TEXT, PRIMARY KEY( (serverId, timeboundery), timestamp ) );
 INSERT INTO test_keyspace.test_ts_2 ( serverId, timeboundery, timestamp, data ) VALUES ( 'server_1', 1455192000, 1455194927, '{ "userNumber" : 100, "serverStatus" : "STABLE" }' );
 INSERT INTO test_keyspace.test_ts_2 ( serverId, timeboundery, timestamp, data ) VALUES ( 'server_1', 1455192000, 1455195227, '{ "userNumber" : 105, "serverStatus" : "STABLE" }' );
 INSERT INTO test_keyspace.test_ts_2 ( serverId, timeboundery, timestamp, data ) VALUES ( 'server_1', 1455192000, 1455195527, '{ "userNumber" : 100, "serverStatus" : "STABLE" }' );
 INSERT INTO test_keyspace.test_ts_2 ( serverId, timeboundery, timestamp, data ) VALUES ( 'server_1', 1455195600, 1455195827, '{ "userNumber" : 95, "serverStatus" : "STABLE" }' );
 SELECT * FROM test_keyspace.test_ts_2;



(그림 16 : CQL로 표현된 데이터들.)

 use test_keyspace;
 list test_ts_2;



(그림 17 : "'serverId':'timeboundery'"의 형태로 Row Key가 지정되었음을 알 수 있다.)

이제는 1시간 간격으로 timeboundery를 지정하여 Partition Key로 사용하도록 하였으므로 데이터는 ServerId와 timeboundery의 조합으로 분산된다는 것을 위의 결과로 알 수 있습니다. 그렇다면 이러한 구조에서는 다음과 같은 방식으로 데이터를 가져올 수 있을 겁니다. server_1 서버의 2016년 2월 11일 오후 9시부터 오후 10시 이전까지의 기록을 검색해봅시다.

 SELECT * FROM test_keyspace.test_ts_2 WHERE serverId='server_1' AND timeboundery = 1455192000;



(그림 18 : 9시 00분 00초부터 9시 59분 59초 사이의 기록)

좀 더 자세하게 server_1 서버의 2016년 2월 11일 오후 9시부터 오후 9시 50분 이전까지의 기록을 검색하면 아래와 같습니다.

 SELECT * FROM test_keyspace.test_ts_2 WHERE serverId='server_1' AND timeboundery = 1455192000 AND timestamp < 1455195000;



(그림 19 : 2016. 2. 11. 오후 9:48:47에 대한 데이터 하나만 검색되었다.)

이제야 비로소 Time Sequencial한 데이터를 일정 길이로 잘라 모든 노드에 고루 분산할 수 있고, 또 원하는 데이터를 큰 제약 없이 검색해 가져올 수 있음을 확인할 수 있습니다. 이렇듯 데이터의 길이가 무한정 길어지는 것을 적당히 나눌 수 있도록 Partition key를 지정하는 것은 스키마를 설계할 때 있어서 고려해야하는 매우 중요한 요소 중 하나입니다.

(2) Denormalize

Cassandra는 Join을 지원하지 않고, 고수준의 Index도 지원하지 않습니다. 때문에 데이터를 원하는 방식으로 가져오는 데 많은 제약이 있을 수 밖에 없었죠. 따라서 어느정도 사용성을 만족하기 위해서 많은 사람들은 데이터를 비정규화하여 관리해왔습니다. 물론 0.7 버전 이후로 Secondary Index를 통하여 어느정도 검색에 대한 자유도를 높일 수 있었지만, 근본적인 해결책이 되지는 못했습니다. 따라서 Cassandra에서의 비정규화는 여전히 많은 사람들이 즐겨 쓰는 기법으로 남았습니다. 아주아주 간단한 예를 들어보겠습니다.

CREATE TABLE test_keyspace.test_worker ( 
name TEXT PRIMARY KEY , 
job TEXT
);

Secondary Index는 일단 논외로 두었을 때, 위와 같은 테이블에서는 name을 기준으로 job 데이터를 가져올 수는 있지만 job을 이용하여 name들을 검색하는 것을 불가능합니다. 이럴 때 Cassandra는 다음과 같이 테이블을 생성하여 데이터를 관리한다면 금방 해결할 수 있을 겁니다.

 CREATE TABLE test_keyspace.test_job ( 
job TEXT PRIMARY KEY , 
name TEXT 
);

물론 결과적으로 동일한 데이터가 중복으로 저장되어 관리되는 것이지만, 이는 Join이나 Index를 제대로 지원하지 않는 Cassandra에서는 매우 일상적인 일입니다. Cassandra에게 있어서 scale out은 다른 일에 비하면 대단히 쉬운 일이니까요. 더구나 Disk에 들어가는 비용은 Memory나 CPU같은 다른 자원에 비하면 가장 저렴하다는 것을 생각해 보았을 떄, 이는 어쩌면 당연한 결과라고도 할 수 있겠습니다. 따라서 Cassandra를 사용할 때에는 RDBMS와는 다르게 비정규화 역시 충분히 고려하여 데이터를 어떻게 사용할 것인지 용도에 맞는 스키마를 결정하여 사용해야할 것입니다.

(3) Paging

사실 Cassandra에게 페이징은 참 곤란한 문제 중 하나입니다. 비록 Limit라는 CQL 구문이 있기는 하지만 말 그대로 가져올 데이터의 개수를 제한해주는 역할일 뿐, MySQL의 Limit 처럼 Offset까지 처리해주지 않는데다 그렇다고 ROW NUMBER와 같은 결과값에 넘버링을 해주는 기능이 있는 것도 아닙니다. 사정이 이렇다 보니, 아무래도 Cassandra에서 Paging을 구현하는 것은 생각보다 쉽지 않은 일이죠. 그나마 하나의 Partition Key(Row Key) 에 속한 Column들에 대한 데이터는 이미 정렬되어있다는 특성을 이용하여 Paging이 가능하겠지만, Partition Key(Row Key) 단위의 페이징을 구현하는 것은 사실상 불가능합니다. 이유인 즉, Cassandra의 Partition Key들은 모두 Hashing된 결과값을 기준으로 데이터를 분산하고 있기 때문입니다.
과거 ByteOrderedPartitioner를 사용하던 시절에는 그래도 Partition Key에 대한 Paging이 가능했었습니다. 모든 노드에 대하여 Partition Key의 16진수 byte 값 순서대로 데이터가 분산되었으니까요. 하지만 아시다시피 BOP는 Hotspot이 생길 수 있는 가장 큰 위험요소였고 대표적인 Anti-Pattern으로 알려져 현재는 Murmur3Partitioner가 기본 Partitioner로 사용됨에 따라 모든 Partition Key들은 Hashing 된 결과값을 기준으로 정렬되어 저장되고 있습니다. 따라서 Cassandra에서 제공하는 Token()라는 특별한 함수를 이용하여, Hashing된 값을 순서대로 데이터를 가져오는 것은 가능합니다만, 계속 데이터가 추가, 수정, 삭제될 때마다 데이터의 정렬 상태가 바뀌게 될 테니 사용성은 그다지 높지 않다는 문제가 나타나게 된 것이죠.
어쨌거나, Keyspace - Table - Row - Column 으로 계층화 되어있는 Cassandra 고려했을 때 Paging을 구현한다면 다음과 같은 두 가지로 요약할 수 있을 겁니다.
첫째, 하나의 Partition Key에 속하는 Column들에 대한 페이징.
둘째, 여러 개의 Partition Key(Row Key)에 대한 데이터 페이징.
하나씩 알아보겠습니다.

  • 하나의 Partition Key에 속하는 Column들에 대한 페이징.
    사실 이 경우는 어렵지 않게 구현이 가능합니다. 우선, 아래와 같은 Table을 생성하고 하나의 'junior'라는 Partition Key에 a부터 z까지로 시작하는 사람의 이름이 하나씩 총 26개의 데이터가 저장되어있다고 가정해봅시다.
 CREATE TABLE test_keyspace.test_paging_1 ( class TEXT , name TEXT, description TEXT, PRIMARY KEY (class,  name) );
 INSERT INTO test_keyspace.test_paging_1 ( class, name, description ) VALUES ( 'junior', 'aron', 'developer' );
 INSERT INTO test_keyspace.test_paging_1 ( class, name, description ) VALUES ( 'junior', 'baker', 'developer' );
 ...
 INSERT INTO test_keyspace.test_paging_1 ( class, name, description ) VALUES ( 'junior', 'zena', 'developer' );
 SELECT * FROM test_keyspace.test_paging_1;



(그림 20 : 총 26개의 데이터가 입력되었다.)

하나의 Partition Key(Row Key) 기준으로 내부의 데이터들은 이미 정렬된 상태로 존재하기 때문에 우리는 Limit 구문을 이용하여 다음과 같이 데이터를 가져올 수 있습니다.

 SELECT * FROM test_keyspace.test_paging_1 WHERE class = 'junior' LIMIT 5;



(그림 21 : 처음부터 5개 만큼의 데이터를 가져왔다.)

그렇다면 그 다음이 문제입니다. 가장 처음 5개의 데이터를 가져왔으니 그 다음 데이터 5개는 어떻게 가져와야할까요? 안타깝게도, Cassnadra의 Limit은 정직하게 말 그대로 가져올 데이터의 개수만을 제한할 뿐 다른 기능이 없습니다. 따라서 이미 우리가 확인한 앞 5개의 데이터 중 가장 마지막 데이터를 기준으로 Query를 작성해야 합니다.

SELECT * FROM test_keyspace.test_paging_1 WHERE class = 'junior' AND name > 'elen' LIMIT 5;



(그림 22 : elen 다음에 시작하는 frank부터 5개의 데이터를 가져왔다.)

이러한 방식의 Paging 방식은 몇몇 단점이 있습니다. 만약 RDBMS를 사용한다면 내부적으로 데이터를 정렬하고 결과의 id나 offset을 계산하여 원하는 위치의 데이터만 잘라 가져올 수 있습니다. 하지만 Cassandra의 경우 이러한 기능이 없기 때문에 원하는 위치에 도달할 때까지 Application이 직접 Cassandra에게 계속 질의를 해야하는 것이죠. 예를 들어 당장 첫번째 페이지에서 한 번에 10페이지 뒤의 정보를 알고 싶다면, 사용하지도 않을 9페이지 분량을 차례로 읽어온 뒤에서야 마지막 10번째 페이지에 대한 데이터를 가져올 수 있습니다. 더군다나 이러한 방식은 Paging 대상이 되는 데이터 집합의 변화에 유연하게 대처하기 힘들기 때문에, 정확한 Paging을 구현하고자 한다면 매번 데이터를 처음부터 새로 읽어오는 것을 반복해야할 것입니다.


(그림 23 : Paging 구현 시, 실시간으로 데이터의 변경을 반영하기 위해선 매번 데이터를 처음부터 읽어와야한다.)

  • 여러개의 Partition Key에 대한 데이터에 대한 페이징.
    하지만 지금 살펴볼 내용에 비하면 그나마 하나의 Partition Key에 대한 데이터들의 Paging은 그나마 나았다는 생각이 들지도 모릅니다. 앞서 말씀드렸듯이 하나의 Partition Key에 속한 데이터는 이미 정렬되어 저장되어있기라도 하지만, Cassandra에 속한 수많은 노드들에 흩뿌려진 Partition Key(Row Key)들은 Hashing을 통하여 랜덤한 Token들로 변환이 된 채 분산되었기 때문에, 엄밀히 말하자면 Token을 기준으로 정렬된 것이나 다름없기 때문입니다. 만약 정말정말 필요하여 정렬된 데이터를 얻고 싶다고 한다면, Cassandra에 저장된 모든 Partition Key들을 가져와서 Application이 직접 정렬을 하고 그 안에서 원하는 범위의 데이터를 추출해야할 텐데, 이는 상상만해도 끔찍한 일이 아닐 수 없겠죠.
    이렇듯 Partitioner에 의해 Partition Key는 Hashing되어 저장되므로 우리가 원하는 데이터는 얻을 방법이 없지만, Cassandra는 Hashing된 Token에 대한 Range Query만큼은 가능할 수 있게 해주는 기능을 제공하고 있습니다. Token() 함수가 그것입니다. 일단 예제 구현을 위하여 아래와 같이 Table을 생성하고 데이터를 저장해보도록 합시다. 이번엔 class와 name을 바꾸도록 하겠습니다.
 CREATE TABLE test_keyspace.test_paging_2 ( name TEXT, class TEXT ,description TEXT, PRIMARY KEY (name, class) );
 INSERT INTO test_keyspace.test_paging_2 ( name, class, description ) VALUES ( 'aron', 'junior', 'developer' );
 INSERT INTO test_keyspace.test_paging_2 ( name, class, description ) VALUES ( 'baker', 'junior', 'developer' );
 ...
 INSERT INTO test_keyspace.test_paging_2 ( name, class, description ) VALUES ( 'zena', 'junior', 'developer' );



(그림 24 : Partition Key의 Hash 값을 기준으로 각 노드에 데이터가 분산되어 저장었으므로 순서가 뒤죽박죽이다.)

그럼 우선 시험삼아 아래와 같은 Query를 수행해봅시다.

 SELECT * FROM test_keyspace.test_paging_2 WHERE name > 'elen' LIMIT 5;


(그림 25 : 수행되지 않는다.)

당연한 이야기지만 Query는 실패합니다. 그렇다면 아래와 같이 Token() 함수를 사용하면 어떨까요?

 SELECT * FROM test_keyspace.test_paging_2 WHERE token(name) > token('elen') LIMIT 5;



(그림 26 : 정렬된 결과는 아니더라도 Query는 수행된다.)

비록 정렬된 데이터는 아니지만, 어쨌건 원하는 5개의 데이터를 가져옮을 확인할 수 있습니다.

이렇듯 굉장히 제한적이고 빈약한 기능을 제공하는 Cassandra이지만, 많은 사람들은 이 상황에서도 어떻게든 효율적으로 사용하 위한 나름의 방법을 짜내고는 합니다. 여전히 제한적일지라도 위의 두 방식섞어 조금 더 나은 Paging을 구현하는 방법이 있는데, 바로 Prefix를 사용하는 방법입니다. 일종의 편법인 셈이죠. 우선 아래의 간단한 Table을 보겠습니다.

 CREATE TABLE test_keyspace.test_paging_3 ( prefix text, remain text, name text, description text PRIMARY KEY ( prefix, body,) );
 INSERT INTO test_keyspace.test_paging_3 ( prefix , remain , name, class, description ) VALUES ( 'a', 'ron', 'aron', 'junior', 'developer' );
 INSERT INTO test_keyspace.test_paging_3 ( prefix , remain , name, class, description ) VALUES ( 'b', 'aker', 'baker', 'junior', 'developer' );
 ...
 INSERT INTO test_keyspace.test_paging_3 ( prefix , remain , name, class, description ) VALUES ( 'z', 'ena', 'zena', 'junior', 'developer' );
 SELECT * FROM test_keyspace.test_paging_3 ;



(그림 27 : Prefix를 나누어 데이터를 저장한 경우.)

이 Table을 기준으로 생각해보면, Prefix를 기준으로 데이터가 분산되고 해당 Prefix 안에서는 remain 값으로 데이터가 정렬되어 저장되는 것이죠. 경우에 따라 유용하게 쓸 수 있을 법도 합니다. 하지만 Sharding을 고려하지 않으면서 데이터를 고르게 분산하기 위해 Partitioner를 사용하는 것임에도 불구하고 Prefix라는 편법을 사용함으로써 Hotspot이 생길 수 있는 위험을 초래할 수 있다는 점을 충분히 고려해야할 것입니다.

4. Cassandra Anti-Pattern

지금까지 Cassandra에서 일반적으로 쓰이거나 유용하게 쓸 수 있는 작은 팁들을 알아보았습니다. 그렇다면 이제 마지막으로 알아볼 것은 되도록이면 피해야할 Cassandra의 사용 방식을 짚어볼 차례가 되었습니다. 몇몇 부분들은 이미 앞에서도 이야기한 바가 있으므로, 다소 겹치는 내용들은 간략히 설명하도록 하겠습니다.

(1) Unbounded Data

앞 절에서 Time Sequencial Data의 Cassandra 사용법에 대해 알아보면서 간단히 짚고 넘어간 내용입니다. 결론부터 말하자면 하나의 Partition Key(Row Key)이 무한히 데이터를 저장하지 말라는 것이죠. 이러한 방식은 특정 노드에 Hotsopt을 발생시킬 위험을 만들고 또 하나의 Partition Key에 속한 데이터가 과다하게 길어지면 검색 성능이나 적절한 Query 구현에도 불이익이 생기는 것은 당연한 이야기일 것입니다.

(2) Secondary Index

드디어 Secondary Index에 대한 이야기를 할 차례가 된 것 같습니다. Secondary Index는 Cassandra에게 부족한 부분을 채워주는 상당히 편리한 기능들 중 하나입니다. 하지만 이렇게 유용하게 쓸 수 있을 것 같은 이 기능은 내부적으로 어떻게 동작하고 있는지 알아볼 필요가 있습니다. Secondary Index의 남용은 흔한 Anti-Pattern으로 널리 알려져있기 때문이죠. 이유는 그리 복잡하지 않습니다.
Cassandra는 Secondery Index를 위하여 내부적인 Table(ColumnFamily) 정보를 따로 관리하고 있습니다. 그리고 특정 Table의 특정 Column에 대해 Secondary Index를 지정하게 된다면, 해당 Column의 Value들을 Partition Key(Row Key)로 만들어 데이터를 저장하는 구조입니다. 간단히 예를 들어봅시다. 아래와 같은 Table과 Index를 생성한 다음 3개의 데이터를 입력해보겠습니다.

 CREATE TABLE test_keyspace.test_user (
    id TEXT PRIMARY KEY,
    name TEXT,
    location TEXT
 );
 CREATE INDEX test_user_idx ON test_keyspace.test_user (location);

 INSERT INTO test_keyspace.test_user (id, name, location) VALUES ( 'test_id_1', 'duron', 'seoul');
 INSERT INTO test_keyspace.test_user (id, name, location) VALUES ( 'test_id_2', 'frank', 'busan');
 INSERT INTO test_keyspace.test_user (id, name, location) VALUES ( 'test_id_3', 'jack', 'seoul'); 
 SELECT * FROM test_keyspace.test_user;



(그림 28 : 저장된 데이터.)

Partition Key로 id가 지정되었으므로 위의 세 데이터는 세 개의 Row로 나뉘어 데이터가 저장되었을 겁니다. 그리고 이 때 Secondary Index는 Cassandra 내부적으로 대략 아래의 데이터 구조와 같은 형태로 저장됩니다.

 RowKey: busan
 => (name=test_id_2:, value=)
 -------------------
 RowKey: seoul
 => (name=test_id_1:, value=)
 => (name=test_id_3:, value=)

즉, Index로 지정한 데이터를 Cassandra Data Layer의 Row Key로 가지고 해당 데이터의 실제 Row Key(위 예제에서는 id)들을 Column으로 목록처럼 들고있는 것이죠. 이런 방식을 이용해 Cassandra는 Secondary Index로 지정된 Column의 값을 가져와 해당 값에 속한 실제 Row Key들을 알고 각 노드에 접근하여 데이터를 가져오게 됩니다.
이런 구조로 때문에, Secondary Index로 지정된 Column이라 하더라도 Range Query는 사용할 수 없고, '='을 이용한 단순 검색이나 CONTAINS와 같은 문법을 통해 Multi-get 형식으로 한번에 여러 Column Value에 대한 결과를 가져오는 정도의 한정적인 기능만 사용할 수 있는 것이죠.
그러나 정작 가장 큰 문제는 따로 있습니다. 만약 Secondary Index로 지정된 Column에 중복되는 값이 엄청나게 길다고 가정해봅시다. 방금 전 예제를 기준으로 한다면, 'seoul'에 사는 사용자의 수가 엄청나게 많은 경우일 겁니다. Secondary Index의 'seoul'이라는 Row Key 아래에 수많은 사용자들의 id들이 주렁주렁 매달려있다는 이야기겠죠. 그렇다면 이 때 'seoul'에 대한 Query를 하게 된다면? 네. 'seoul'에 속한 데이터를 검색하기 위해 그 많은 id들이 저장된 Cassandra의 수많은 노드들을 전부 접근하게 됩니다. 만약 'seoul'에 속한 id가 정말 많다고 한다면, 최악의 경우엔 Cassandra에 속한 노드 전부가 굉장히 바빠지게 되겠죠. 더군다나 Consistancy Level에 맞추어 각 데이터 별 Replication들도 확인해야한다는 점을 감안한다면... 이는 절대 즐거운 상황은 아닐 겁니다.
따라서 Secondary Index의 사용은 신중히 결정되어야하고, 비정규화는 여전히 많은 사람들에게 사랑받는 Cassnadra Data Modeling 기법 중 하나로 남았습니다. 어차피 Cassandra에 노드를 추가하는 것 정도야 큰 문제도 아니니까요.

(3) Delete Data

Tombstone은 Cassandra가 가지고 있는 꽤나 특징적인 부분들 중 하나입니다. 이전 글에서 설명한 적이 있듯이, Cassandra는 기본적으로 Data의 Delete를 바로 수행하지 않고서 Tombstone이라 불리는 marker에 표시를 해둔 뒤, 시간이 지나 SSTable들의 compaction이 진행될 때 비로소 데이터가 정말로 삭제되는 구조를 가지고 있습니다. 일단 이것만 보기엔 나름 일리가 있어 보이는 효율적이고 합리적인 구조라고 할 수 있겠지만, 실시간으로 데이터를 삭제하지 않는다는 이 원칙으로 인하여 몇가지 예상하지 못한 특이한 부작용이 발생하게 되었죠.
첫번째는 삭제된 데이터가 부활하는 경우입니다. 정말 무시무시한 말이 아닐 수 없습니다. 삭제했던 데이터가 살아난다니요. 하지만 시도 때도 없이 데이터가 부활한다는 그런 말은 아닙니다. 그럼 어떨 때 삭제된 데이터가 좀비마냥 부활하는 걸까요?
이 문제는 장애가 발생한 노드가 정상화되어 Ring에 복귀할 때 발생할 수 있습니다.
Cassandra의 노드 중 하나가 장애가 발생했을 경우를 가정해봅시다. 장애노드는 다운되어 사용할 수 없지만 다른 노드들은 정상적으로 동작하겠죠. 그 와중에 많은 데이터가 변경이 일어날 텐데, 그중에 삭제해야할 데이터가 있다면 다른 정상 상태의 노드들에서는 이미 정상적으로 삭제 된 후일 겁니다. 깨끗하겠죠. 아무런 흔적이 남지 않았을 겁니다. 이때 장애노드가 장애에서 벗어나 Ring에 복귀하면 어떻게 될까요? 장애노드는 그동안 있었던 데이터의 변경점을 반영하기 위하여 다른 노드와 Gossip Protocol을 통해 데이터를 복구하기 시작할 겁니다. 변경된 데이터가 있다면 최신으로 갱신해주고, 새로운 데이터가 있다면 복사해서 가져옵니다. 문제는 이 과정에서 자기가 들고 있던, 다른 노드들에선 이미 삭제된 데이터를 도로 복구시켜버리는 상황이 발생하는 것이죠. 이미 다른 노드들에는 존재하지 않는 데이터인데 논리적으로 봤을 땐 나만 가지고 있더라도 이는 정상적인 데이터므로 Replication은 만족시키기 위해서 다른 노드들에 다시 데이터를 Write 해버리는 것입니다. 그렇게 죽은 데이터가 다시 살아나게 됩니다.
Cassandra의 conf/cassandra.yaml을 확인하시면 gc_grace_seconds라는 옵션을 확인하실 수 있는데, 이름에서 알 수 있듯이 이는 여러 개의 노드로 구성된 Ring 구조에서 서로 간에 데이터의 삭제 여부에 대한 상태를 충분히 안전하게 전파하기 위한 tomstone의 garbage collecting 주기를 설정하는 옵션입니다. 죽은 데이터가 다시 살아나는 이같은 문제가 발생하지 않도록 하기 위해서는 gc_grace_seconds의 옵션을 신중하게 결정하고, 장애 노드의 복구 시점을 gc_grace_seconds를 넘기지 않도록 노력해야합니다. 만약 gc_grace_seconds 주기가 지난 시점에서 장애 노드가 복구되었다면, 다른 정상 노드들은 높은 확률로 compaction을 통하여 데이터를 모두 삭제해버렸을 테니까요.

두번째로는 Read 성능이슈입니다.
Cassandra는 Tombstone을 이용하여 데이터의 삭제를 관리한다는 특징 외에, 모든 데이터를 Sequencial하게 저장한다는 특징 역시 가지고 있습니다. 이를 요약하면 비록 Memtable이나 SSTable의 앞에 저장된 데이터가 Delete상태가 되었더라도 Tombstone이 marking되어있는 것일 뿐 실제로는 compaction이 일어나지기 전까지는 여전히 Disk에 존재하는 상태라는 것이죠. 따라서 SSTable의 데이터를 Sequencial하게 읽어야하는 Cassandra의 입장에선 이미 Tombstone에 marking 되어있는 데이터라 할지라도 일단은 읽고 넘어가게 됩니다. 이것은 상당히 중요한 부분인데, Cassandra를 절대 Queue와 같은 방식으로는 사용해선 안된다는 근거가 되기 때문입니다.



(그림 29 : 앞의 데이터가 삭제되었더라도 모두 확인하고 다음으로 넘어가게 된다.)

만약 하나의 Row를 Queue로 보고, 해당 Row에 Column을 Message처럼 차례대로 Write하는 상황을 가정해봅시다. 그러면 Message를 소비하고 나면 앞선 데이터들은 차례대로 Tombstone이 기록되겠죠. 그런데 Compaction을 통해 데이터가 채 사라지기도 전에 엄청난 양의 Message가 들어왔다가 소비되었다면? 소비된 Message Data들은 단지 Tombstone이 기록되어있을 뿐 여전히 남아있는 상태일 겁니다. 이때 새로운 메시지를 가져가려한다면 아직 남아있는 Message Data가 있는 곳까지 앞의 Tombstone Data들을 모두 거쳐야하는 상황이 발생합니다. 당연히 속도는 느려지겠죠.
이는 단지 Queue로 사용했을 때만 국한되는 것은 아닙니다. 대량의 데이터를 빈번하게 쓰고 지운다면, 당연히 해당 Row에 대한 읽기 성능은 떨어지겠죠. 따라서 Cassandra의 스키마를 설계하기 위해서는 해당 데이터를 어떤 방식으로 사용할 것인지 충분히 검토한 후 이를 반영할 수 있도록 노력해야할 것입니다.

(4) Memory Orverflow

지금 말씀드릴 내용은 아주 적은 내용이지만 매우 중요한 부분이기도 합니다. Cassandra는 매번 Hashing을 통해 계산되는 Row Key와는 달리, 모든 Keyspace와 Table에 대한 Metadata를 JVM 메모리에 올려놓고 사용하고 있습니다. 이것은 분산되지 않고, Ring을 구성하는 모든 노드가 동일하게 가지고 있는 데이터입니다. 즉, 너무 많은 Keyspace와 Table을 생성할 경우 Memory가 급격하게 소진될 수 있습니다. 이는 결국 필요에 따라 매번 Table 혹은 Keyspace를 생성하는 방식으로 Cassandra를 사용할 경우 Memory Orverflow로 인하여 Cassandra 노드들이 죽어버리는 현상이 발생할 수 있으므로 이와 같은 사용방식은 반드시 피해야한다는 점을 명심해야할 것입니다.

5. 마치며

이렇게 Cassandra 톺아보기를 3편으로 마무리지었습니다. 사실 톺아보기라는 제목이 무색할 정도로 미처 다루지 못한 내용들이 많지만, 모든 내용을 다 다루기에는 시간도 역량도 부족한 터라 개인적으로 Cassandra를 쓴다면 이것만큼은 알고 쓰면 좋겠다는 내용들을 위주로 글을 작성하였습니다. 다소 지루한 내용이었지만 이 글을 통해 단 한분이라도 조금이나마 도움이 되었으면 하는 바람입니다. 감사합니다.

'nosql' 카테고리의 다른 글

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