Twitter Table Structure Guide
🐦 트위터 - Cassandra 테이블 설계 가이드
📋 개요
이 문서는 트위터와 같은 대규모 소셜 미디어 플랫폼의 데이터베이스 설계에 대한 종합 가이드입니다.
왜 이렇게 설계해야 하는지
각 테이블의 역할은 무엇인지
어떻게 동작하는지
단계별로 이해할 수 있도록 작성되었습니다.
🤔 왜 Cassandra를 사용하는가?
트위터의 특징
대량의 쓰기: 초당 수십만 개의 트윗 생성
빠른 읽기: 타임라인 조회 시 즉시 응답 필요
수평 확장: 사용자 증가에 따른 서버 확장 필요
시간 기반: 데이터 최신 트윗부터 시간순 정렬 중요
Cassandra의 장점
📊 관계형 DB (MySQL) vs Cassandra
관계형 DB:
복잡한 JOIN 연산 → 느린 읽기 성능
수직 확장 (서버 성능 향상) → 비용 ↗️
ACID 보장 → 일관성은 좋지만 성능 ↓
Cassandra:
비정규화된 테이블 → 빠른 읽기 성능 ⚡
수평 확장 (서버 대수 증가) → 비용 효율적
최종 일관성 → 성능 우선, 일관성은 eventual
📊 4개 핵심 테이블 상세 설명
1. 🏠 tweets 테이블 - 트윗의 원본 저장소
역할: 모든 트윗의 "진실의 원천(Source of Truth)"
-- Cassandra CQL
CREATE TABLE tweets (
tweet_id UUID PRIMARY KEY, -- 트윗 고유 ID
user_id UUID, -- 작성자 ID
tweet_text TEXT, -- 트윗 내용
created_at TIMESTAMP, -- 생성 시간
modified_at TIMESTAMP -- 수정 시간
);
Java 엔티티:
@Table("tweets")
public class Tweet extends BaseEntity {
@PrimaryKey
private UUID tweetId; // 파티션 키
@Column("user_id")
private UUID userId;
@Column("tweet_text")
private String tweetText;
}
데이터 예시:
| tweet_id | user_id | tweet_text | created_at |
|-----------|---------|-----------------|---------------------|
| tweet-123 | user-A | 안녕하세요! | 2025-01-15 14:30:00 |
| tweet-124 | user-B | 점심 맛있다 | 2025-01-15 14:31:00 |
| tweet-125 | user-A | 날씨 좋네요 | 2025-01-15 14:32:00 |
🔍 주요 사용 패턴:
POST /tweets
: 새 트윗 저장GET /tweets/{tweetId}
: 특정 트윗 조회다른 테이블에서 트윗 상세 정보 참조
⚠️ 중요한 점:
이 테이블은
user_id
로 직접 조회하지 않습니다!"특정 사용자의 모든 트윗"은 다른 테이블에서 처리합니다.
2. 👥 followers_by_user 테이블 - 팔로우 관계
역할: "특정 사용자를 팔로우하는 모든 사람들"을 빠르게 찾기
복합 Primary Key 클래스
@PrimaryKeyClass
public class FollowersByUserKey {
@PrimaryKeyColumn(name = "followed_user_id", type = PrimaryKeyType.PARTITIONED)
private UUID followedUserId; // 파티션 키: 팔로우 당하는 사람
@PrimaryKeyColumn(name = "follower_id", type = PrimaryKeyType.CLUSTERED)
private UUID followerId; // 클러스터링 키: 팔로우 하는 사람
}
메인 엔티티
@Table("followers_by_user")
public class FollowersByUser extends BaseEntity {
@PrimaryKey
private FollowersByUserKey key;
@Column("followed_at")
private LocalDateTime followedAt;
}
데이터 예시:
🎯 user-A를 팔로우하는 사람들 (한 파티션에 저장)
| followed_user_id | follower_id | followed_at |
|------------------|-------------|---------------------|
| user-A | user-1 | 2025-01-10 09:00:00 |
| user-A | user-2 | 2025-01-11 10:30:00 |
| user-A | user-3 | 2025-01-12 15:20:00 |
🎯 user-B를 팔로우하는 사람들 (다른 파티션에 저장)
| followed_user_id | follower_id | followed_at |
|------------------|-------------|---------------------|
| user-B | user-4 | 2025-01-13 11:15:00 |
| user-B | user-5 | 2025-01-14 16:45:00 |
🔍 핵심 쿼리:
// user-A가 트윗을 작성했을 때, 누구에게 알림을 보내야 할까?
List<FollowersByUser> followers = repository.findByKeyFollowedUserId("user-A");
// 결과: [user-1, user-2, user-3] - 한 번의 쿼리로 모든 팔로워 조회!
💡 왜 이렇게 설계했나요?
❌ 나쁜 설계 (관계형 DB 방식):
SELECT follower_id FROM follows WHERE followed_user_id = 'user-A'
→ 인덱스 스캔 + 여러 노드 접근 필요
✅ 좋은 설계 (Cassandra 방식):
followed_user_id를 파티션 키로 사용
→ user-A의 모든 팔로워가 한 노드에 저장됨
→ 한 번의 네트워크 요청으로 모든 데이터 조회 가능!
3. 📱 user_timeline 테이블 - 개인 타임라인 (Fan-out on Write)
역할: 각 사용자의 개인 타임라인을 미리 생성하여 저장
복합 Primary Key 클래스
@PrimaryKeyClass
public class UserTimelineKey {
@PrimaryKeyColumn(name = "follower_id", type = PrimaryKeyType.PARTITIONED)
private UUID followerId; // 파티션 키: 타임라인 소유자
@PrimaryKeyColumn(name = "created_at", type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING)
private LocalDateTime createdAt; // 첫 번째 클러스터링 키: 시간 (내림차순)
@PrimaryKeyColumn(name = "tweet_id", type = PrimaryKeyType.CLUSTERED)
private UUID tweetId; // 두 번째 클러스터링 키: 고유성 보장
}
메인 엔티티
@Table("user_timeline")
public class UserTimeline extends BaseEntity {
@PrimaryKey
private UserTimelineKey key;
@Column("author_id")
private UUID authorId; // 트윗 작성자
@Column("tweet_text")
private String tweetText; // 트윗 내용 (비정규화)
}
🔄 Fan-out on Write 과정:
1단계: user-A가 "안녕하세요!" 트윗 작성
// tweets 테이블에 원본 저장
Tweet tweet = new Tweet("user-A", "안녕하세요!");
tweetRepository.save(tweet);
2단계: user-A의 팔로워들 조회
// followers_by_user 테이블에서 팔로워 목록 조회
List<UUID> followers = followRepository.findFollowersByUserId("user-A");
// 결과: [user-1, user-2, user-3]
3단계: 각 팔로워의 타임라인에 트윗 추가
// 각 팔로워의 user_timeline에 저장
for (UUID followerId : followers) {
UserTimeline timeline = new UserTimeline(
followerId, // user-1, user-2, user-3
tweet.getTweetId(), // tweet-123
"user-A", // 작성자
"안녕하세요!" // 내용
);
timelineRepository.save(timeline);
}
결과 데이터:
🎯 user-1의 타임라인 (follower_id = user-1인 파티션)
| follower_id | created_at | tweet_id | author_id | tweet_text |
|-------------|---------------------|-----------|-----------|---------------|
| user-1 | 2025-01-15 14:30:00 | tweet-123 | user-A | 안녕하세요! |
| user-1 | 2025-01-15 14:25:00 | tweet-122 | user-B | 점심 맛있다 |
| user-1 | 2025-01-15 14:20:00 | tweet-121 | user-C | 날씨 좋네요 |
🎯 user-2의 타임라인 (follower_id = user-2인 파티션)
| follower_id | created_at | tweet_id | author_id | tweet_text |
|-------------|---------------------|-----------|-----------|---------------|
| user-2 | 2025-01-15 14:30:00 | tweet-123 | user-A | 안녕하세요! |
| user-2 | 2025-01-15 14:28:00 | tweet-124 | user-D | 커피 한 잔 |
🎯 user-3의 타임라인 (follower_id = user-3인 파티션)
| follower_id | created_at | tweet_id | author_id | tweet_text |
|-------------|---------------------|-----------|-----------|---------------|
| user-3 | 2025-01-15 14:30:00 | tweet-123 | user-A | 안녕하세요! |
| user-3 | 2025-01-15 14:26:00 | tweet-125 | user-E | 운동 갔다 와야지 |
🔍 타임라인 조회 (매우 빠름!):
// GET /timeline - user-1의 타임라인 조회
List<UserTimeline> timeline = repository.findByKeyFollowerIdOrderByCreatedAtDesc("user-1", 20);
// 결과: user-1의 최신 트윗 20개를 한 번의 쿼리로 조회!
// 복잡한 JOIN이나 정렬 연산 없이 이미 정렬된 데이터를 바로 반환
⚡ 성능 특징:
쓰기: O(팔로워 수) - 팔로워 수만큼 쓰기 발생
읽기: O(1) + 조회할 트윗 수 - 매우 빠름!
4. 🌟 celebrity_tweets 테이블 - 인플루언서 트윗 (Fan-out on Read)
역할: 팔로워가 많은 인플루언서의 트윗을 별도 저장
복합 Primary Key 클래스
@PrimaryKeyClass
public class CelebrityTweetKey {
@PrimaryKeyColumn(name = "author_id", type = PrimaryKeyType.PARTITIONED)
private UUID authorId; // 파티션 키: 인플루언서 ID
@PrimaryKeyColumn(name = "created_at", type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING)
private LocalDateTime createdAt; // 첫 번째 클러스터링 키: 시간 (내림차순)
@PrimaryKeyColumn(name = "tweet_id", type = PrimaryKeyType.CLUSTERED)
private UUID tweetId; // 두 번째 클러스터링 키: 고유성 보장
}
메인 엔티티
@Table("celebrity_tweets")
public class CelebrityTweet extends BaseEntity {
@PrimaryKey
private CelebrityTweetKey key;
@Column("tweet_text")
private String tweetText; // 트윗 내용
}
🤔 왜 별도 테이블이 필요한가요?
문제 상황:
아이유(팔로워 1,000만 명)가 트윗을 작성한다면?
Fan-out on Write 방식으로 처리하면:
1. tweets 테이블에 원본 저장 (1번 쓰기)
2. 1,000만 명의 user_timeline에 모두 저장 (1,000만 번 쓰기) 💥
결과:
- 서버 과부하 💻💥
- 비용 폭발 💸💸💸
- 응답 시간 증가 ⏰
해결책: Fan-out on Read:
아이유가 트윗 작성:
1. tweets 테이블에 원본 저장 (1번 쓰기)
2. celebrity_tweets 테이블에 저장 (1번 쓰기)
3. 팔로워들의 user_timeline에는 저장하지 않음 ❌
사용자가 타임라인 조회 시:
1. user_timeline에서 일반 트윗들 조회
2. 팔로우하는 셀럽들의 celebrity_tweets에서 최신 트윗 조회
3. 두 결과를 시간순으로 병합
데이터 예시:
🎯 아이유의 트윗들 (author_id = 아이유-id인 파티션)
| author_id | created_at | tweet_id | tweet_text |
|-----------|---------------------|-----------|-------------------|
| 아이유-id | 2025-01-15 14:30:00 | tweet-456 | 새 앨범 발매했어요! |
| 아이유-id | 2025-01-15 12:15:00 | tweet-455 | 콘서트 재밌었어요 |
| 아이유-id | 2025-01-15 10:20:00 | tweet-454 | 좋은 아침이에요 |
🎯 BTS의 트윗들 (author_id = BTS-id인 파티션)
| author_id | created_at | tweet_id | tweet_text |
|-----------|---------------------|-----------|-----------------|
| BTS-id | 2025-01-15 15:00:00 | tweet-460 | 새 뮤비 공개! |
| BTS-id | 2025-01-15 13:30:00 | tweet-459 | 팬 여러분 감사해요 |
🔄 하이브리드 전략: Fan-out on Write + Fan-out on Read
전체 프로세스 시나리오
시나리오 1: 일반 사용자(user-A)가 트윗 작성
// 1. 원본 저장
Tweet tweet = new Tweet("user-A", "오늘 날씨 좋네요");
tweetRepository.save(tweet);
// 2. 팔로워 조회 (소수)
List<UUID> followers = followRepository.findFollowersByUserId("user-A");
// 결과: [user-1, user-2, user-3] - 100명
// 3. Fan-out on Write
for (UUID followerId : followers) {
UserTimeline timeline = new UserTimeline(followerId, tweet);
timelineRepository.save(timeline);
}
// 100번의 쓰기 발생 (비용 적음)
시나리오 2: 인플루언서(아이유)가 트윗 작성
// 1. 원본 저장
Tweet tweet = new Tweet("아이유-id", "새 앨범 발매했어요!");
tweetRepository.save(tweet);
// 2. 인플루언서 여부 확인
if (celebrityService.isCelebrity("아이유-id")) {
// 3. celebrity_tweets에만 저장
CelebrityTweet celebrityTweet = new CelebrityTweet("아이유-id", tweet);
celebrityTweetRepository.save(celebrityTweet);
// 1번의 쓰기만 발생 (비용 절약!)
}
시나리오 3: 사용자(user-1)가 타임라인 조회
// 1. 일반 트윗들 조회 (user_timeline)
List<UserTimeline> regularTweets = timelineRepository
.findByKeyFollowerIdOrderByCreatedAtDesc("user-1", 20);
// 2. 팔로우하는 셀럽들의 트윗 조회
List<UUID> followedCelebrities = followRepository.getFollowedCelebrities("user-1");
List<CelebrityTweet> celebrityTweets = new ArrayList<>();
for (UUID celebrityId : followedCelebrities) {
List<CelebrityTweet> tweets = celebrityTweetRepository
.findByKeyAuthorIdOrderByCreatedAtDesc(celebrityId, 10);
celebrityTweets.addAll(tweets);
}
// 3. 시간순으로 병합
List<TweetResponse> finalTimeline = mergeAndSort(regularTweets, celebrityTweets);
성능 비교
📊 일반 사용자 vs 인플루언서
일반 사용자 (팔로워 100명):
- 쓰기: 100번 (적음)
- 읽기: 1번 (매우 빠름)
- 저장공간: 100배 (적음)
인플루언서 (팔로워 1,000만 명):
- 쓰기: 1번 (매우 적음) ⭐
- 읽기: 2-3번 (빠름)
- 저장공간: 1배 (매우 적음) ⭐
결론: 상황에 따라 최적의 전략 선택!
🔍 Cassandra 핵심 개념 이해
파티션 키 vs 클러스터링 키
파티션 키 (Partition Key):
역할: 데이터를 어느 노드에 저장할지 결정
예시: user_timeline 테이블의 follower_id
follower_id = "user-1" → Node A에 저장
follower_id = "user-2" → Node B에 저장
follower_id = "user-3" → Node C에 저장
장점: 각 사용자의 타임라인이 한 노드에서 처리되어 빠름
클러스터링 키 (Clustering Key):
역할: 같은 파티션 내에서 데이터 정렬 순서 결정
예시: user_timeline 테이블의 (created_at DESC, tweet_id)
user-1의 파티션 내부:
├── created_at: 2025-01-15 14:30:00, tweet_id: tweet-123
├── created_at: 2025-01-15 14:25:00, tweet_id: tweet-122
└── created_at: 2025-01-15 14:20:00, tweet_id: tweet-121
장점: 이미 정렬되어 저장되므로 조회 시 빠름
커서 기반 페이지네이션
기존 OFFSET 방식의 문제점:
-- 관계형 DB 방식
SELECT * FROM timeline ORDER BY created_at DESC LIMIT 20 OFFSET 1000;
문제점:
- OFFSET이 클수록 성능 저하 (1000개를 모두 스캔해야 함)
- 실시간으로 데이터가 추가되면 중복/누락 발생 가능
Cassandra 커서 방식:
// 1페이지: 최신 20개 조회
List<UserTimeline> page1 = repository.findByKeyFollowerIdOrderByCreatedAtDesc("user-1", 20);
String cursor = generateCursor(page1.get(19)); // 마지막 아이템으로 커서 생성
// 2페이지: 커서 이후 20개 조회
List<UserTimeline> page2 = repository.findByKeyFollowerIdAndCreatedAtBefore("user-1", cursor, 20);
장점:
- 항상 O(1) 성능 (OFFSET 크기와 무관)
- 실시간 데이터 추가와 상관없이 일관된 결과
🚀 구현 시 고려사항
1. 인플루언서 판단 기준
@Service
public class CelebrityService {
private static final int CELEBRITY_FOLLOWER_THRESHOLD = 10_000;
public boolean isCelebrity(UUID userId) {
// 방법 1: 팔로워 수 기준
long followerCount = followRepository.countFollowers(userId);
if (followerCount > CELEBRITY_FOLLOWER_THRESHOLD) {
return true;
}
// 방법 2: 수동 지정된 VIP 계정
return vipAccountService.isVipAccount(userId);
}
}
2. 배치 처리 최적화
// Fan-out 시 배치 삽입으로 성능 향상
@Async
public void fanOutTweetBatch(Tweet tweet, List<UUID> followers) {
List<UserTimeline> timelineEntries = followers.stream()
.map(followerId -> new UserTimeline(followerId, tweet))
.collect(toList());
// 배치로 한 번에 저장 (개별 저장보다 빠름)
timelineRepository.saveAll(timelineEntries);
}
3. 캐시 전략
@Service
public class TimelineService {
@Cacheable(key = "#userId", value = "timeline")
public TimelineResponse getTimeline(UUID userId, String cursor) {
// 자주 조회되는 타임라인은 Redis에 캐시
// TTL: 5분 (실시간성과 성능의 균형)
}
}
4. 모니터링 지표
// 중요한 메트릭들
- Fan-out 시간: 트윗 작성 후 모든 팔로워 타임라인 업데이트 완료 시간
- 타임라인 조회 응답시간: 95 percentile < 100ms 목표
- 저장 공간 사용량: 특히 user_timeline 테이블 모니터링
- 셀럽 트윗 병합 성능: Fan-out on Read 시 병합 시간
🔧 실제 구현 코드 예시
Repository 계층
// UserTimelineRepository.java
public interface UserTimelineRepository extends CassandraRepository<UserTimeline, UserTimelineKey> {
@Query("SELECT * FROM user_timeline WHERE follower_id = ?0 ORDER BY created_at DESC LIMIT ?1")
List<UserTimeline> findLatestTimeline(UUID followerId, int limit);
@Query("SELECT * FROM user_timeline WHERE follower_id = ?0 AND created_at < ?1 ORDER BY created_at DESC LIMIT ?2")
List<UserTimeline> findTimelineBeforeCursor(UUID followerId, LocalDateTime cursor, int limit);
}
Service 계층
// TimelineService.java
@Service
@RequiredArgsConstructor
public class TimelineService {
private final UserTimelineRepository userTimelineRepository;
private final CelebrityTweetRepository celebrityTweetRepository;
private final FollowRepository followRepository;
public TimelineResponse getTimeline(UUID userId, String cursor, int size) {
// 1. 일반 트윗 조회
List<UserTimeline> regularTweets = getRegularTweets(userId, cursor, size);
// 2. 셀럽 트윗 조회
List<CelebrityTweet> celebrityTweets = getCelebrityTweets(userId, cursor, size);
// 3. 시간순 병합
List<TweetResponse> mergedTweets = mergeByTimestamp(regularTweets, celebrityTweets);
// 4. 응답 생성
return TimelineResponse.builder()
.tweets(mergedTweets.subList(0, Math.min(size, mergedTweets.size())))
.nextCursor(generateNextCursor(mergedTweets))
.hasMore(mergedTweets.size() > size)
.build();
}
@Async
public void fanOutTweet(Tweet tweet) {
if (celebrityService.isCelebrity(tweet.getUserId())) {
// Fan-out on Read: celebrity_tweets에만 저장
saveToCelebrityTweets(tweet);
} else {
// Fan-out on Write: 모든 팔로워의 타임라인에 저장
List<UUID> followers = followRepository.findFollowersByUserId(tweet.getUserId());
fanOutToFollowers(followers, tweet);
}
}
}
💡 트러블슈팅 가이드
자주 발생하는 문제들
1. 타임라인 조회가 느려요
원인: Celebrity 트윗 병합 시 너무 많은 인플루언서 조회
해결:
- 팔로우하는 셀럽 수 제한 (최대 100명)
- 셀럽 트윗 캐시 적극 활용
- 비동기로 병합 후 WebSocket으로 푸시
2. Fan-out 쓰기가 너무 느려요
원인: 팔로워가 많은 사용자를 일반 사용자로 처리
해결:
- 인플루언서 판단 임계값 조정 (1만 명 → 5천 명)
- 배치 사이즈 최적화 (한 번에 1000명씩 처리)
- 비동기 처리 with 메시지 큐
3. 저장 공간이 부족해요
원인: user_timeline 테이블의 과도한 데이터 중복
해결:
- 오래된 타임라인 데이터 정리 (3개월 이상)
- 압축 정책 적용
- 더 많은 사용자를 celebrity로 분류
4. 일관성 문제가 발생해요
문제: 트윗 삭제했는데 타임라인에 아직 보임
해결:
- 트윗 삭제 시 관련된 모든 타임라인에서도 삭제
- 최종 일관성 특성상 약간의 지연은 정상
- 중요한 경우 강한 일관성 레벨 사용 (QUORUM)
📚 참고 자료
🎉 마무리
대규모 소셜 미디어 플랫폼의 데이터베이스 설계 원칙의 핵심은 사용 패턴에 맞는 최적화와 트레이드오프의 균형.
핵심 포인트:
읽기 최적화: 자주 조회되는 데이터는 미리 계산하여 저장
쓰기 분산: 비용과 성능을 고려한 하이브리드 전략
확장성: 사용자 증가에 따른 유연한 아키텍처
Last updated