# Cassandra

Cassandra tombstone, 지웠는데 왜 더 느려질까?

@Hong2026-10-02

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

실습 코드 내려받기

질문
Cassandra에서 데이터를 지웠는데 왜 조회가 더 느려지거나 실패할까?
답
지운 행은 tombstone으로 남아 읽을 때 다시 훑게 되며, 하루치 33334건을 행 단위로 지우자 가장 오래된 50건 조회가 tombstone 20001개에서 실패했다.
조건
2026-10-02, Cassandra 5.0.9 노드 하나(힙 1GB, replication_factor 1), tombstone_failure_threshold 20000, 한 채널 100만 건

환경

측정일
2026년 10월 2일
Cassandra
5.0.9(cassandra:5.0.9) 노드 하나, 힙 1GB, replication_factor 1
설정
tombstone_failure_threshold 20000(기본값 100000에서 낮춤)
데이터
1번 채널 100만 건(9월 1일부터 30일 동안), 2~101번 채널 1,000건씩
실행
Docker Compose, 컨테이너 안의 cqlsh와 nodetool
시간 측정
노트북 한 대에서 다른 컨테이너와 함께
목차
  1. 실습 준비
  2. 실습 1: 지운 행은 tombstone으로 남습니다
  3. 실습 2: 경고를 넘어 쿼리가 실패할 때
  4. 실습 3: 날짜 테이블은 파티션을 통째로 지웁니다
  5. 정리

이번 시간에는 Cassandra로 채널별 메시지 테이블을 두 가지 키 설계로 만들고, 지운 데이터가 남기는 tombstone을 tracing과 서버 로그로 확인해 보며 배워가는 시간입니다.

예를 들어서 우리가 채팅 서비스의 메시지를 Cassandra에 저장하고 있다고 해볼게요. 처음 몇 달은 메시지 조회 API가 몇 밀리초 안에 응답했어요.

그런데 오래된 메시지를 지우는 배치를 돌리기 시작한 뒤로, 특정 채널의 예전 메시지를 불러오는 요청이 느려집니다. Cassandra 로그에는 tombstone이라는 단어가 든 경고가 쌓이고요.

채팅 화면에서 가장 많이 하는 조회는 "채널 하나의 최근 메시지 50건"이에요. 이 조회에 맞춘 테이블은 다음과 같습니다.

CREATE TABLE chat.messages_by_channel (
  channel_id int,
  sent_at    timestamp,
  message_id bigint,
  body       text,
  PRIMARY KEY ((channel_id), sent_at, message_id)
) WITH CLUSTERING ORDER BY (sent_at DESC, message_id DESC);
  • 파티션 키 (channel_id): 데이터를 어느 노드에 둘지 정하는 값이에요. 파티션 키가 같은 행의 묶음을 파티션이라고 불러요.
  • 클러스터링 키 sent_at, message_id: 파티션 안에서 행을 정렬하는 순서예요. CLUSTERING ORDER BY (sent_at DESC ...)로 최신 메시지가 파티션 앞쪽에 오게 했어요.

비교를 위해 같은 데이터를 날짜로 나눠 담는 테이블 messages_by_channel_day도 만들어요. 파티션 키에 day를 넣어서, 채널 하나의 메시지를 하루 단위 파티션으로 나눕니다.

실습 준비

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

  • compose.yaml: Cassandra 노드 하나(프로젝트 cass)
  • cassandra.yaml: 기본 설정에서 두 줄을 바꾼 Cassandra 설정
  • schema.cql: 키스페이스 chat과 테이블 두 개
  • gen.py: 적재용 CSV와 DELETE 문 파일 생성(함께 만드는 TTL용 CSV는 이 글에서는 쓰지 않아요)

Docker로 Cassandra 5.0 노드 하나를 띄워요. 다른 컨테이너와 같이 돌리려고 JVM 힙을 MAX_HEAP_SIZE로 1GB로 줄였어요. compose.yaml은 다음과 같아요.

name: cass
services:
  cassandra:
    image: cassandra:5.0.9
    environment:
      MAX_HEAP_SIZE: 1G
    ports:
      - "7280:9042"
    volumes:
      - ./cassandra.yaml:/etc/cassandra/cassandra.yaml
      - ./:/work
    mem_limit: 2560m

cassandra.yaml은 이미지에 든 기본 파일에서 두 줄만 바꿨어요. 그중 이 글에서 쓰는 것은 다음 값이에요.

  • tombstone_failure_threshold: 조회 한 번이 이 개수보다 많은 tombstone을 읽으면 쿼리를 실패시켜요. 기본값은 100000인데, 실습 데이터로 실패까지 보려고 20000으로 낮췄어요.

경고 기준인 tombstone_warn_threshold는 기본값 1000 그대로 둡니다. 바꾼 다른 한 줄(partition_size_warn_threshold 50MiB)은 이 글에서는 쓰지 않아요.

컨테이너가 시작될 때 이미지의 시작 스크립트가 이 파일의 주소 관련 줄(listen_address, seeds 등)을 컨테이너 IP로 고쳐 써요. 그래서 읽기 전용으로 마운트하면 시작하지 못하니 주의해 주세요.

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

  • cql: 컨테이너 안의 cqlsh로 CQL을 실행해요.
  • nt: 컨테이너 안의 nodetool을 실행해요.
  • trace: 쿼리를 tracing을 켜고 실행해요. tracing은 쿼리 하나가 서버 안에서 거친 단계를 시간과 함께 기록하는 기능이에요. 출력이 길어서 결과 행 수, 경고와 오류, tracing에서 볼 줄만 남겼어요. 왼쪽 숫자는 그 노드가 요청을 받은 뒤 지난 시간(마이크로초)입니다.

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

export COMPOSE_PROJECT_NAME=cass
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 !~ /^( |-|$|TRACING set|PAGING set|exit status)/) print; next }
    /Partition index|Key cache hit|Bloom filter|Merged|Read [0-9]|Request complete/ {
      sub(/^ +/, "", $1); sub(/ \[[^]]*\]$/, "", $1); printf "%9s  %s\n", $4, $1 }'
}

docker compose up -d
until nt status 2>/dev/null | grep -q '^UN'; do sleep 5; done
nt status | grep '^UN'
UN  172.26.0.2  114.69 KiB  16      100.0%            3d636216-0123-48cd-b32e-2622d1296b64  rack1

UN은 노드가 살아 있고(Up) 정상 상태(Normal)라는 뜻이에요. trace의 PAGING OFF는 cqlsh가 결과를 100행씩 나눠 받지 않게 해요.

데이터는 gen.py가 만듭니다. 메시지가 몰리는 1번 채널에 9월 1일부터 30일 동안 100만 건(2.592초에 한 건, 하루 약 3만 3천 건)을 넣고, 2~101번 채널에는 1,000건씩 넣어요. 두 테이블용 CSV와 DELETE 문 파일도 함께 만들어요. 컨테이너 안의 Python 3.13으로 실행합니다.

스키마를 만들고 cqlsh의 COPY ... FROM으로 적재할게요. 기본 설정으로 넣으면 노드 하나에 쓰기가 몰려 일부 묶음이 WriteTimeout으로 재시도돼서, 프로세스 수와 초당 행 수를 낮췄습니다.

cql -f /work/schema.cql
docker compose exec -T cassandra python3 /work/gen.py
wc -l gen/by_channel.csv gen/delete_day1.cql
cql -e "COPY chat.messages_by_channel (channel_id, sent_at, message_id, body) FROM '/work/gen/by_channel.csv' WITH NUMPROCESSES = 2 AND INGESTRATE = 50000" 2>&1 | tail -1
cql -e "COPY chat.messages_by_channel_day (channel_id, day, sent_at, message_id, body) FROM '/work/gen/by_channel_day.csv' WITH NUMPROCESSES = 2 AND INGESTRATE = 50000" 2>&1 | tail -1
nt flush chat
nt compact chat
 1100000 gen/by_channel.csv
   33334 gen/delete_day1.cql
 1133334 total
1100000 rows imported from 1 files in 0 day, 0 hour, 0 minute, and 22.114 seconds (0 skipped).
1100000 rows imported from 1 files in 0 day, 0 hour, 0 minute, and 24.986 seconds (0 skipped).

Cassandra는 쓰기를 먼저 메모리(memtable)에 모았다가, 디스크에 SSTable이라는 변경되지 않는 파일로 내려써요. nodetool flush는 memtable을 지금 바로 SSTable로 내려쓰고, nodetool compact는 테이블의 SSTable을 하나로 합칩니다.

실습 1: 지운 행은 tombstone으로 남습니다

채널 테이블에서 1번 채널의 9월 1일 메시지 33334건을 한 행씩 지워요. gen.py가 만든 delete_day1.cql에는 다음과 같은 문장이 33334줄 들어 있습니다.

DELETE FROM chat.messages_by_channel WHERE channel_id = 1 AND sent_at = '2026-09-01 00:00:00.000+0000' AND message_id = 1;
time cql -f /work/gen/delete_day1.cql
real	1m18.429s
user	0m0.068s
sys	0m0.047s

Cassandra의 DELETE는 데이터를 찾아가서 지우지 않아요. "이 행은 이 시간에 지워졌다"는 표시를 새로 씁니다. 이 표시가 tombstone이에요.

왜 이렇게 할까요? 데이터는 여러 노드에 복제되어 있고, SSTable은 한 번 쓰면 고치지 않는 파일이에요. 그래서 지운 사실도 일반 쓰기처럼 기록해 두었다가, 읽을 때 합쳐서 판단하는 거예요.

9월 1일 22시부터 9월 2일 2시까지를 오래된 순서로 50건 읽어 볼게요(발췌).

trace "SELECT sent_at, message_id FROM chat.messages_by_channel WHERE channel_id = 1 AND sent_at >= '2026-09-01 22:00' AND sent_at < '2026-09-02 02:00' ORDER BY sent_at ASC LIMIT 50;"
(50 rows)
Warnings :
Read 50 live rows and 2778 tombstone cells for query SELECT * FROM chat.messages_by_channel WHERE channel_id = 1 AND sent_at < '2026-09-02T02:00:00.000Z' AND sent_at >= '2026-09-01T22:00:00.000Z' ORDER BY sent_at ASC, message_id ASC LIMIT 50 ALLOW FILTERING; token -4069959284402364209 (see tombstone_warn_threshold)
    36260  Merged data from memtables and 1 sstables
    39581  Read 50 live rows and 2778 tombstone cells (see tombstone_warn_threshold)
    42175  Request complete

결과는 50행인데, tombstone 2,778개를 지나갔어요. 출력은 싣지 않았지만, 지우기 전에 같은 조회를 했을 때는 tombstone 0개, 9ms였어요.

9월 1일 22시부터 자정까지 2시간치(약 2,778건)는 이미 지운 행이라 tombstone만 남아 있고, 살아 있는 행은 9월 2일 0시부터 시작해요. 오래된 순서로 읽으니 살아 있는 행 50개를 찾기 전에 tombstone부터 모두 지나가야 하는 거죠.

지나간 tombstone이 tombstone_warn_threshold(1000)를 넘어서 cqlsh에 경고가 왔어요. 서버의 system.log에도 같은 문장이 WARN으로 남습니다.

cassandra.yaml의 설명에 따르면, 조회 중에 만난 tombstone은 다른 복제본에도 삭제 사실을 알리기 위해 코디네이터(클라이언트 요청을 받아 복제본들에 나눠 보내는 노드)로 돌려보낼 때까지 메모리에 들고 있어야 해요. 그래서 개수에 기준을 두고, 경고를 넘어 실패 기준까지 가면 쿼리를 끊습니다.

SSTable에서 차례로 읽음범위 조회ORDER BY sent_at ASC LIMIT 50tombstone09-01 00:00:00tombstone09-01 00:00:02…살아 있는 행09-02 00:00:011,000개 초과경고20,000개 초과쿼리 실패지나간 tombstone 수
살아 있는 행을 찾기 전에 지나간 tombstone이 모두 집계됩니다. 실패 기준 20,000은 이 실습에서 낮춘 값입니다.

실습 2: 경고를 넘어 쿼리가 실패할 때

이번에는 범위 없이 1번 채널에서 가장 오래된 메시지 50건을 읽어 볼게요. 9월 1일 하루치 33334건이 모두 tombstone이니까, 살아 있는 행을 하나도 찾기 전에 실패 기준 20000을 넘게 됩니다.

trace "SELECT sent_at, message_id FROM chat.messages_by_channel WHERE channel_id = 1 ORDER BY sent_at ASC LIMIT 50;"
docker compose logs cassandra --no-log-prefix --since 3m | grep -E 'tombstone' | tail -2
<stdin>:1:ReadFailure: Error from server: code=1300 [Replica(s) failed to execute read] message="Operation failed - received 0 responses and 1 failures: READ_TOO_MANY_TOMBSTONES from /172.26.0.2:7000" info={'consistency': 'ONE', 'required_responses': 1, 'received_responses': 0, 'failures': 1, 'error_code_map': {'172.26.0.2': '0x0001'}}
WARN  [ReadStage-5] 2026-10-02T01:53:52,956 ReadCommand.java:644 - Read 0 live rows and 20001 tombstone cells for query SELECT * FROM chat.messages_by_channel WHERE channel_id = 1 ORDER BY sent_at ASC, message_id ASC LIMIT 50 ALLOW FILTERING; token -4069959284402364209 (see tombstone_warn_threshold)
ERROR [ReadStage-5] 2026-10-02T01:53:52,958 StorageProxy.java:2248 - Scanned over 20001 tombstones during query 'SELECT * FROM chat.messages_by_channel WHERE channel_id = 1 ORDER BY sent_at ASC, message_id ASC LIMIT 50 ALLOW FILTERING' (last scanned row token was -4069959284402364209 and partion key was ((1), 2026-09-01T14:24:00.000Z, 20001)); query aborted

쿼리가 실패해서 결과도 tracing도 오지 않았어요. 클라이언트는 ReadFailure를 받았고, 원인 코드는 READ_TOO_MANY_TOMBSTONES예요. 서버 로그에는 두 줄이 남았습니다.

  • WARN ... Read 0 live rows and 20001 tombstone cells: 살아 있는 행은 하나도 못 찾고 tombstone 20001개를 지나갔어요.
  • ERROR ... Scanned over 20001 tombstones during query ... query aborted: 실패 기준을 넘어 쿼리를 멈췄어요.

기본 실패 기준인 100000이었다면 33334개는 기준 아래라 실패하지 않아요. 하지만 같은 식으로 사흘치 넘게 지워 두면 기본값에서도 같은 오류가 납니다.

애플리케이션이 받는 오류 메시지에는 tombstone이라는 단어가 원인 코드에만 들어 있어요. 그러니 읽기 실패가 늘면 서버 로그에서 Scanned over와 tombstone_warn_threshold를 찾아봐 주세요.

실습 3: 날짜 테이블은 파티션을 통째로 지웁니다

날짜 테이블에서 9월 1일을 지울 때는 파티션 키만 주면 돼요.

cql -e "DELETE FROM chat.messages_by_channel_day WHERE channel_id = 1 AND day = '2026-09-01';"
trace "SELECT sent_at, message_id FROM chat.messages_by_channel_day WHERE channel_id = 1 AND day = '2026-09-01' LIMIT 50;"
(0 rows)
     2139  Merged data from memtables and 0 sstables
     2177  Read 0 live rows and 0 tombstone cells
     2288  Request complete

파티션 전체를 지우면 partition tombstone 하나만 남아요. 조회는 memtable에서 이 표시를 보고, 그보다 오래된 데이터만 든 SSTable은 아예 읽지 않았어요(0 sstables). tombstone 수도 0으로 잡혔고 2ms에 끝났습니다.

행 단위로 지운 채널 테이블과 같은 하루치를 지웠는데 남는 tombstone과 읽기 비용이 전혀 달라요. 오래된 데이터를 기간 단위로 지울 계획이 있다면, 그 기간을 파티션 키에 넣어 두는 편이 삭제에도 유리합니다.

정리

범위: Cassandra 5.0.9 노드 하나(힙 1GB, replication_factor 1), tombstone_failure_threshold 20000으로 낮춤, 1번 채널 100만 건과 2~101번 채널 1,000건씩, 시간은 노트북 한 대에서 잰 값.

  • 9월 1일 33334건을 행 단위로 지우자 다음 범위 조회가 tombstone 2778개를 지나 경고가 났고, 가장 오래된 50건 조회는 tombstone 20001개에서 READ_TOO_MANY_TOMBSTONES로 실패했어요. 읽기 실패가 늘면 서버 로그에서 Scanned over와 tombstone_warn_threshold를 찾아보세요.
  • 날짜 테이블의 파티션 DELETE 뒤 조회는 SSTable을 읽지 않고 tombstone 0개로 2ms에 끝났어요. 오래된 데이터를 기간 단위로 지울 계획이면 그 기간을 파티션 키에 넣어 파티션째 지우세요.
  • 이 글에 싣지 않은 추가 측정에서는 compaction을 해도 tombstone이 gc_grace_seconds(기본 864000초) 동안 남아 오래된 순서 조회가 그대로 실패했고, 이 값을 0으로 낮춘 뒤 compaction하자 tombstone 0개가 됐어요. tombstone을 전달할 다른 복제본이 있는 클러스터에서는 이 값을 0으로 낮추지 마세요.
내 링크 만들기