# Cassandra

Cassandra에서 ALLOW FILTERING을 붙이면 왜 위험할까?

@Hong2026-10-14

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Cassandra 조회에 ALLOW FILTERING을 붙여 운영에 써도 괜찮을까?
답
파티션 키 없이 붙이면 결과가 27행이어도 100만 행 전체를 읽어 2.81~4.18초가 걸렸다. 조건을 파티션 키로 둔 조회 전용 테이블은 결과 행만 읽고 1.5~3.9ms였다.
조건
Cassandra 5.0.9 노드 1개(힙 1GB), Docker, 주문 10만·50만·100만 건, 조회 시간은 다섯 번씩 측정

환경

Cassandra
5.0.9(cassandra:5.0.9) 노드 하나, 힙 1GB, 복제본 1개, cassandra.yaml 기본값
실행
Docker Compose, 컨테이너 안의 cqlsh, nodetool, Python 3.13
데이터
주문 100만 건(사용자 50,000명), 10만, 50만, 100만 건 세 단계로 나눠 적재
조회 시간
times5로 다섯 번씩, 노트북 한 대에서 다른 컨테이너와 함께 잰 값
목차
  1. 실습 준비
  2. 실습 1: ALLOW FILTERING으로 실행하면
  3. 실습 2: 쿼리 전용 테이블
  4. 정리

이번 시간에는 Cassandra에 주문을 10만, 50만, 100만 건으로 늘려 가며 같은 조회를 ALLOW FILTERING으로 실행하고, 읽은 행 수와 시간을 tracing으로 측정하며 배워가는 시간입니다. tracing은 서버가 쿼리를 처리한 단계를 기록하는 기능이에요.

예를 들어서 우리가 주문 데이터를 Cassandra로 옮겼다고 해볼게요. 운영팀이 "어제 환불된 주문 목록"을 뽑아 달라고 해서, RDB에서 하던 대로 상태 컬럼으로 조회했어요. 그랬더니 결과 대신 오류가 돌아옵니다.

SELECT order_id, user_id, created_at FROM shop.orders WHERE status = 'REFUNDED';
<stdin>:1:InvalidRequest: Error from server: code=2200 [Invalid query] message="Cannot execute this query as it might involve data filtering and thus may have unpredictable performance. If you want to execute this query despite the performance unpredictability, use ALLOW FILTERING"

데이터가 없어도 같은 오류가 납니다. Cassandra는 데이터를 읽어 보기 전에 쿼리 모양만 보고 거절하기 때문이에요. 메시지 끝에 ALLOW FILTERING을 붙이라는 안내가 있죠. 붙이면 쿼리는 실행됩니다. 그럼 붙여도 괜찮은 걸까요?

Cassandra는 행을 파티션 키 값으로 묶어 파티션 하나로 저장하고, 파티션 키의 해시(토큰)로 그 파티션을 둘 노드를 정해요. 클러스터링 키는 파티션 안에서 행을 정렬하는 순서예요. 파티션 키와 클러스터링 키를 정하는 규칙이 더 궁금하면 Cassandra 파티션과 tombstone을 다룬 글을 참고하세요.

실습 준비

이 글의 명령은 모두 실습 코드의 cassandra-query-first-modeling/ 디렉터리에서 실행합니다.

  • compose.yaml: Cassandra 5.0.9 노드 하나(힙 1GB)
  • schema.cql: 키스페이스 shop과 파티션 키만 다른 주문 테이블 세 개
  • gen.py: 주문 100만 건을 order_id 구간별 CSV 세 개로 만드는 스크립트

Docker로 Cassandra 5.0 노드 하나를 띄워요. 노드 하나라 복제는 없지만(복제본 1개), 쿼리 하나가 읽는 행의 수는 그대로 볼 수 있습니다.

다른 컨테이너와 함께 돌리려고 JVM 힙을 1GB로 줄였고, cassandra.yaml은 이미지에 든 기본값을 그대로 써요. compose.yaml은 아래와 같아요. 실습 디렉터리를 컨테이너의 /work로 마운트해서 스크립트와 데이터 파일을 컨테이너 안에서 씁니다.

name: casqm
services:
  cassandra:
    image: cassandra:5.0.9
    environment:
      MAX_HEAP_SIZE: 1G
    ports:
      - "7650:9042"
    volumes:
      - ./:/work
    mem_limit: 2560m
    cpus: 2

자주 쓰는 명령은 셸 함수로 만들어 둘게요. compose 프로젝트 이름은 COMPOSE_PROJECT_NAME으로 정합니다.

  • cql: 컨테이너 안의 cqlsh로 CQL을 실행해요.
  • nt: 컨테이너 안의 nodetool을 실행해요.
  • trace: 쿼리를 tracing을 켜고 실행해요. 출력이 길어서 결과 행 수, 경고와 오류, tracing에서 볼 줄만 남겼어요. 왼쪽 숫자는 노드가 요청을 받은 뒤 지난 시간(마이크로초)이에요. PAGING OFF는 cqlsh가 결과를 100행씩 나눠 받지 않게 해서, 읽기가 tracing에 한 번만 찍히게 해 줍니다.
  • times5: 같은 쿼리를 다섯 번 실행해 tracing의 Request complete 시간만 모아 보여 줘요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export COMPOSE_PROJECT_NAME=casqm

cql() { docker compose exec -T cassandra cqlsh "$@"; }

nt() { docker compose exec -T cassandra nodetool "$@"; }

trace() {
  cql -e "PAGING OFF; TRACING ON; $1" 2>&1 | awk -F' *[|] *' '
    /^Tracing session/ { t = 1; next }
    !t { if ($0 ~ /^Warnings/) { w = 1; print; next }
         if (w && $0 != "") { print; next }
         w = 0; if ($0 ~ /^\([0-9]+ rows\)|Error/) print; next }
    /Submitting range|seq scan|Merged|using index|Index query accessed|Read [0-9]|Request complete/ {
      sub(/^ +/, "", $1); sub(/ \[[^]]*\]$/, "", $1); printf "%9s  %s\n", $4, $1 }'
}

times5() { for i in 1 2 3 4 5; do trace "$1" | awk '/Request complete/ { printf "%s ", $1 }'; done; echo; }

docker compose up -d
until nt status 2>/dev/null | grep -q '^UN'; do sleep 5; done
nt status | grep '^UN'
docker compose exec -T cassandra grep -nE '^# allow_filtering_enabled' /etc/cassandra/cassandra.yaml
UN  172.27.0.2  114.71 KiB  16      100.0%            0d2f2394-10b8-41e9-b49e-c9ab30d1b890  rack1
2173:# allow_filtering_enabled: true

UN은 노드가 살아 있고(Up) 정상 상태(Normal)라는 뜻이에요. 기본 cassandra.yaml에서 allow_filtering_enabled는 주석으로만 들어 있고, 주석에 적힌 값 true가 기본값이에요.

키스페이스 shop을 복제본 하나로 만들고, 같은 컬럼을 가진 테이블 세 개를 schema.cql에 넣어요. 테이블마다 파티션 키만 다릅니다.

  • orders: 주문 ID로 주문 하나를 찾는 테이블이에요. 기본 키는 order_id 하나라 파티션 하나에 주문 하나가 들어가요.
  • orders_by_user: "사용자 한 명의 최근 주문"을 위한 테이블이에요. 기본 키는 ((user_id), created_at, order_id)이고 사용자마다 파티션 하나예요.
  • orders_by_status_day: "어느 하루에 어떤 상태가 된 주문"을 위한 테이블이에요. 기본 키는 ((status, day), created_at, order_id)이고, 상태와 날짜를 함께 파티션 키로 써서 환불 주문이 하루 단위 파티션으로 나뉘어요. day는 created_at의 날짜(UTC)예요.

gen.py가 주문 100만 건을 만들어요. 사용자는 50,000명이고, 주문 시간은 2026년 9월 1일부터 30일 동안 고르게 퍼뜨렸어요.

상태는 대부분 DELIVERED, SHIPPED, PAID, CANCELED이고 REFUNDED는 1,000건 중 1건꼴이에요. 조건에 맞는 행은 적은데, 찾으려면 많이 읽어야 하는 상황을 만들려는 거예요.

데이터가 늘 때의 변화를 보려고 order_id 구간별로 CSV 세 개(1~10만, 10만~50만, 50만~100만)를 만들어요. 컨테이너 안의 Python 3.13으로 실행합니다.

cql -f /work/schema.cql
docker compose exec -T cassandra python3 /work/gen.py
wc -l gen/*.csv
  100000 gen/orders_1.csv
  400000 gen/orders_2.csv
  500000 gen/orders_3.csv
 1000000 total

세 테이블이 같은 CSV를 써요. load는 구간 하나를 세 테이블에 넣고, nodetool flush로 메모리에 있던 데이터를 디스크의 SSTable 파일로 내려써요. COPY의 전체 출력은 out 디렉터리에 남기고, 화면에는 적재 결과 줄만 보여 줍니다.

mkdir -p out
load() {
  for t in orders orders_by_user orders_by_status_day; do
    cql -e "COPY shop.$t (order_id, user_id, status, day, created_at, amount) FROM '/work/gen/orders_$1.csv' WITH NUMPROCESSES = 2 AND INGESTRATE = 50000" > out/copy-$1-$t.txt 2>&1
    grep "rows imported" out/copy-$1-$t.txt
  done
  nt flush shop
}

실습 1: ALLOW FILTERING으로 실행하면

"9월 15일에 환불된 주문"을 orders에서 ALLOW FILTERING으로 조회해 볼게요. 파티션 키가 없는 조건 두 개(status, created_at)를 함께 겁니다. 구간을 하나씩 넣을 때마다 같은 쿼리를 실행해요.

Q2="SELECT order_id, user_id, created_at FROM shop.orders WHERE status = 'REFUNDED' AND created_at >= '2026-09-15' AND created_at < '2026-09-16' ALLOW FILTERING;"
load 1
trace "$Q2"
times5 "$Q2"
load 2
trace "$Q2"
times5 "$Q2"
load 3
trace "$Q2"
times5 "$Q2"

100만 건을 넣은 뒤의 출력이에요(발췌).

(27 rows)
    13253  Submitting range requests on 17 ranges with a concurrency of 17 (56260.8 rows per range expected)
    14479  Executing seq scan across 1 sstables for (min(-9223372036854775808), min(-9223372036854775808)]
  3016472  Read 1000000 live rows and 0 tombstone cells
  3035570  Request complete
3240025 3653273 2812715 4181057 2998947 

tracing에서 보실 줄은 다음과 같아요.

  • Submitting range requests on 17 ranges: 파티션 키가 없으니 특정 노드를 찾아갈 수 없어서, 토큰 링 전체를 17개 범위로 나눠 요청을 보냈어요. 노드가 여러 대인 클러스터라면 이 범위들이 모든 노드에 흩어져 있으니 모든 노드가 읽기에 참여해요.
  • Executing seq scan across 1 sstables: SSTable을 처음부터 끝까지 차례로 읽었어요(seq scan).
  • Read 1000000 live rows and 0 tombstone cells: 이 숫자는 조건으로 거르기 전에 읽은 행 수예요. 결과는 27행인데 테이블 전체인 100만 행을 읽은 거죠.

세 단계를 모으면 다음과 같아요.

  • 10만 건: 읽은 행 100,000, 결과 2행, 0.38~1.25초
  • 50만 건: 읽은 행 500,000, 결과 8행, 1.17~1.71초
  • 100만 건: 읽은 행 1,000,000, 결과 27행, 2.81~4.18초

읽은 행 수가 언제나 테이블의 전체 행 수와 같았어요. 데이터가 10배가 되자 읽은 행도 정확히 10배가 됐고, 시간도 단계마다 늘었어요.

ALLOW FILTERING은 이 읽기를 허락할 뿐이고, 읽는 양은 그대로예요. 데이터가 늘면 노드를 늘리는 것이 Cassandra를 쓰는 방식인데, 이 쿼리는 노드를 늘려도 읽는 총량이 줄지 않아요.

실습 2: 쿼리 전용 테이블

쿼리 전용 테이블은 조회 하나의 조건을 그대로 파티션 키로 둔 테이블이에요.

"어느 하루에 환불된 주문"이라는 질문에 맞춘 테이블이 orders_by_status_day이고, 질문의 두 조건(상태, 날짜)이 그대로 파티션 키예요.

Q4="SELECT order_id, user_id, created_at FROM shop.orders_by_status_day WHERE status = 'REFUNDED' AND day = '2026-09-15';"
trace "$Q4"
times5 "$Q4"
(27 rows)
     2983  Merged data from memtables and 1 sstables
     3052  Read 27 live rows and 0 tombstone cells
     3326  Request complete
1474 2831 3914 2367 2980 

결과는 orders와 똑같이 27행이고, 읽은 행도 27로 결과 행 수와 같아요. Submitting range requests나 seq scan 줄 없이, 파티션 하나를 찾아 읽었다는 Merged data from memtables and 1 sstables가 나와요.

출력은 싣지 않았지만, 10만, 50만 건 단계에서 같은 조회를 했을 때도 읽은 행은 결과와 같은 2행, 8행이었어요. 읽은 행이 늘어난 건 그날의 환불 주문이 늘었기 때문이고, 테이블 전체 크기와는 상관이 없어요. 시간은 세 단계 모두 1.5~3.9ms 안에 있습니다.

9월 15일 환불 주문주문 100만 건orders+ ALLOW FILTERING모든 파티션읽은 행 1,000,000결과 27행2.8~4.2초상태·날짜 테이블orders_by_status_day파티션 하나읽은 행 27결과 27행1.5~3.9ms
결과는 같은 27행인데, 읽는 양은 테이블 전체와 파티션 하나만큼 차이가 납니다.

그래서 Cassandra에서는 조회마다 그 조회의 조건을 파티션 키로 가진 테이블을 따로 둡니다. 주문 ID로 찾는 orders, 사용자별로 찾는 orders_by_user, 상태와 날짜로 찾는 orders_by_status_day가 같은 주문을 각자의 키로 들고 있는 거예요.

정리

범위: Cassandra 5.0.9 노드 하나(힙 1GB), 주문 10만·50만·100만 건, times5로 다섯 번씩 잰 시간.

  • orders에 ALLOW FILTERING으로 조회하자 결과는 2, 8, 27행인데 읽은 행은 10만, 50만, 100만으로 늘 테이블 전체였고, 시간은 0.38~1.25초에서 2.81~4.18초로 늘었어요. 파티션 키가 없는 조건에 ALLOW FILTERING을 붙여 운영 조회로 쓰지 마세요.
  • 이 글에 싣지 않은 추가 측정에서는 파티션 키를 =로 준 ALLOW FILTERING 조회가 18행짜리 파티션에서 18행만 읽고 1.7~3.3ms에 끝났어요. 쿼리가 위험한지는 옵션 이름이 아니라 tracing의 seq scan과 Read N live rows로 판단하세요.
  • 조회 전용 테이블 orders_by_status_day는 세 단계 모두 결과 행 수만큼(2, 8, 27행) 읽고 1.5~3.9ms였어요. 조회마다 그 조건을 파티션 키로 가진 테이블을 두세요.
내 링크 만들기