[Diary] Apache Cassandra에서 MongoDB 전환기
본문 바로가기

Development/Diary

[Diary] Apache Cassandra에서 MongoDB 전환기

저는 이커머스 개발 프로젝트에서 배송 도메인의 대한 데이터베이스로 Apache Cassandra를 처음에 선택했는데요, 얼마 지나지 않아 MongoDB로의 마이그레이션을 결정했습니다.

이 글에서는 데이터베이스를 선택하면서 했던 고민들을 작성합니다.

일단, 왜 NoSQL?

쓰기 성능 최적화

NoSQL은 설계상 많은 양의 쓰기 작업에 대해 높은 성능을 발휘하도록 최적화되어 있다. NoSQL의 쓰기 성능이 RDBMS보다 빠른 이유는 데이터 일관성 요구 사항을 완화하고, 복잡한 스키마 및 트랜잭션 처리를 배제한 설계 덕분에 쓰기 시 오버헤드가 적기 때문이다.

유연한 스키마와 데이터 모델링

배송 경로가 자주 추가, 업데이트된다면 NoSQL의 유연한 스키마가 적합하다. NoSQL에서는 구조가 고정되지 않아 데이터를 효율적으로 저장하고 변경할 수 있다. 
또한 JSON 또는 BSON 형태로 데이터를 저장하는 문서 기반 NoSQL(DB)에서는 경로 업데이트 시 중첩된 데이터를 다루기 편리하며, 여러 경로를 한 번에 저장하고 업데이트하는 작업이 유연하다.
배송 경로의 경우 택배사 외부 api에 의존할 수 있기 때문에, 유연한 스키마가 필요하다.

확장성 및 성능 유지

NoSQL은 수평 확장이 가능해 데이터량이 급격히 증가하더라도 성능 저하 없이 클러스터를 통해 확장할 수 있다.

Apache Cassandra를 선택한 이유?

배송 도메인은 실시간 배송 경로와 같은 서비스를 포함하며, 대량의 데이터를 고가용성 및 확장성을 가지고 처리할 수 있는 데이터베이스가 필요하다고 판단했다.

위와 같은 점에서 Apache Cassandra가 적합하다고 생각한 이유는 다음과 같다.

  1. 높은 쓰기 및 읽기 처리량: 배송 시스템은 상태 업데이트, 위치 추적과 같은 데이터가 끊임없이 생성된다. Cassandra는 높은 처리량을 처리하도록 설계되어 다수의 읽기 및 쓰기 작업을 동시에 처리하면서 성능 저하 없이 운영할 수 있다.
  2. 확장성: Cassandra의 분산 아키텍처는 클러스터에 노드를 추가하여 수평 확장이 가능하다. 이는 사용자 수와 서비스가 확장됨에 따라 데이터 양이 빠르게 증가하는 배송 도메인에 필요하다.
  3. 최종 일관성 모델: Cassandra의 최종 일관성은 절대적인 즉시 일관성이 항상 필요하지 않은 배송 도메인에 적합하다. 예를 들어, 추적 정보 업데이트에 약간의 지연이 있더라도 시스템이 지속해서 응답성을 유지하고 가용성이 보장되면 충분하다고 생각했다.
  4. 시계열 데이터 처리: 배송 추적에는 서로 다른 시간에 발생한 이벤트가 기록되는 시계열 데이터가 포함된다. Cassandra는 시계열 데이터를 효율적으로 저장하고 조회할 수 있어, 시간이 지남에 따라 배송 상태를 추적하는 데 적합하다.

문제 상황

이론적으로는 적합해 보였지만, 막상 구현을 해보니 Cassandra의 강점을 제대로 사용하지 못하는 문제가 생겼다.

문제: Cassandra의 데이터 모델링 철학

Cassandra에서는 데이터 모델링이 쿼리 중심으로 이루어진다. 이는 데이터를 어떻게 조회해야 하는지에 따라 테이블을 설계한다.

관계형 데이터베이스에서 정규화가 중요하다면, Cassandra에서는 읽기 성능을 최적화하기 위해 비정규화를 권장한다.

따라서 동일한 데이터를 여러 테이블에 저장하는 것은 Cassandra에서 허용되며 일반적이다.

예를 들어, 배송 정보 조회용 테이블 따로, 배송 상세 조회용 테이블 따로, 배송 경로만 보여주는 조회용 테이블 따로 이런 식으로 말이다.

 

하지만 이는 유연한 스키마 설계가 가능하다고 볼 수 없다. 

결국 Cassandra는 각 테이블이 미리 정의된 고정된 스키마를 따르며 컬럼을 동적으로 추가하거나 스키마를 자주 변경하는 것이 비효율적이기 때문에, 새로운 데이터 요구 사항이 발생하면 기존 테이블을 변경하기보다는 새로운 테이블을 생성하는 방식으로 대응해야 한다.

 

문제: Cassandra의 데이터 저장 구조

https://medium.com/systemdesign-us-blog/difference-between-partition-key-composite-key-and-clustering-key-in-cassandra-b68cb024017a

Cassandra의 데이터 저장 구조는 위 그림처럼 생겼다.

Primary Key는 파티션 키(Partition Key)와 클러스터링 키(Clustering Key)로 구성된다. 위 그림에서는 Sort Key로 되어있는데, 주로 클러스터링 키라고 부른다.

이 두 가지 요소가 결합되어 Cassandra의 데이터 분산 및 정렬 방식을 결정한다.

  • Partition Key (파티션 키): 데이터가 분산될 파티션을 결정하는 핵심 요소이다. 파티션 키에 따라 데이터가 클러스터의 노드들에 분산 저장된다.
  • Clustering Key (클러스터링 키): 파티션 내에서 데이터를 정렬하는 데 사용된다. 파티션 키와 함께 Primary Key를 구성하며, 클러스터링 키는 파티션 내에서 데이터를 특정 순서로 정렬할 수 있게 해 준다.

Cassandra는 데이터를 효율적으로 분산하기 위해 파티션 키 기반의 데이터 모델링을 강조한다.

따라서 배송 ID를 UUID 타입으로 설정하고 파티션 키로 설정하였다. 그러면 이 배송 건의 대한 배송 경로는 이 파티션 키에 저장이 될 것이다.

하지만 이는 기껏 해봐야 5-7개 정도의 데이터가 저장된다.

문제: ALLOW FILTERING 사용의 위험성

그러면 파티션 키를 특정 날짜로 하는 것은 어떨까?

그러면 특정 배송 ID에 대한 배송 경로 쿼리를 하려면 FILTER을 걸어주어야 한다. 하지만 이는 Cassandra에서 권장되지 않는다.

 

Apache Cassandra에서 ALLOW FILTERING은 특정 조건에 따라 데이터를 필터링할 때 사용되는 키워드이다.

Cassandra는 분산 시스템으로, 데이터 조회 시 파티션 키를 기준으로 검색하는 것을 기본으로 한다. 따라서 Cassandra는 데이터를 읽을 때 가능한 한 특정 파티션에 접근하는 방식으로 최적화되어 있어야 성능이 높다. 그러나 특정 필터 조건이 파티션 키에 포함되지 않은 경우 직접적인 조회가 불가능하여 ALLOW FILTERING을 사용하여 데이터베이스에게 필터링을 허용하도록 명시해야 한다.

 

ALLOW FILTERING은 성능에 대한 보장을 하지 않기 때문에, 예기치 못한 상황에서 성능이 급격히 저하될 수 있다.

파티션 키를 사용하지 않는 FILTERING 쿼리는 성능 저하와 리소스 낭비를 초래할 수 있다. 필터링이 필요한 쿼리는 각 노드의 데이터를 스캔해야 하기 때문에 속도가 느려질 수 있다.

 

또한 필터링 쿼리는 각 노드에서 모든 데이터 파티션을 스캔해야 하는 상황이 발생할 수 있다. 이는 작은 데이터셋이라면 문제가 되지 않지만, 대규모 데이터셋에서는 노드의 부하가 크게 증가하게 된다. 이로 인해 조회 시간이 길어지며, Cassandra의 고성능 특성을 저해할 수 있다.

결국 MongoDB로 전환

스키마 유연성

MongoDB는 스키마리스(NoSQL) 특성을 가지므로, 배송 경로에 새로운 필드가 추가되거나 기존 필드가 변경되는 경우, MongoDB에서는 이를 별도의 마이그레이션 없이 손쉽게 반영할 수 있다.

도큐먼트 기반 저장

배송 데이터는 일반적으로 단일 주문에 대한 경로와 상태 정보를 함께 관리하는 경우가 많다. MongoDB의 도큐먼트 기반 저장 방식은 배송 정보를 하나의 도큐먼트로 저장하여 경로, 상태, 수령인 정보 등을 논리적으로 한 번에 관리할 수 있다.

따라서 복잡한 join query 가 어려운 NoSQL의 단점을 고려하여 하나의 배송 쿼리로 필요한 정보들을 모두 가져오도록 배송 스키마를 설계했다.

코드 수정

위 처럼 Spring Data Cassandra를 사용하여 배송 도메인을 구현했었다.

기본 배송 정보를 포함하는 Delivery, 그리고 각 경로 데이터를 저장하는 DeliveryDetail 테이블로 나눴고, DeliveryDetail의 파티션 키는 deliveryId, 클러스터링 키는 deliveryDetailTime으로 설정하여 자동으로 정렬되게 끔 했다.

하지만 실제로 배송 경로는 많아봤자 7개 정도이기 때문에, 파티션 별로 데이터가 많이 쌓이지 않는다. 그리고 Delivery와 DeliveryDetail을 같이 보여주려면 2번 나눠서 각각 쿼리를 해야한다.

위 코드는 MongoDB로 전환하면서 Delivery 클래스를 수정한 코드이다.

주요 바뀐 점은 @Table 대신 @Document로, @Column 대신 @Field로 바뀌었다.

특히 스키마 구조도 바뀌었는데, 문서 형식으로 저장되는 Mongo DB의 특성에 따라 DeliveryDetail을 List로 포함시켰다. 

마치며

다행히 개발 단계에서 알아챈 문제라 크게 바꿀 건 없었지만, 선택한 기술에 더 조사해보고 어떻게 적용할 수 있을지에 대해 시나리오를 고려해야 한다고 느꼈다.