블록체인의 특징
- 중앙 서버가 없다
- 누구나 시스템에 참여할 수 있다
- 심지어 일부는 악의적으로 행동한다
이 환경에서 과연 우리는 “이 거래가 진짜다”라고 말할 수 있을까?
이 질문에서 시작되는 이야기가 바로 블록체인이고, 그 근본에는 비잔티움 장군 문제가 있다.
---
1. 모두를 믿을 수 없는 세상
비잔티움 장군 문제는 꽤 직관적인 상황을 가정한다.
여러 장군이 동시에 공격해야 승리할 수 있다.
하지만 일부 장군은 배신자다.
어떤 장군은 공격하자고 말하고,
어떤 장군은 후퇴하자고 말한다.
심지어 같은 내용을 서로 다르게 전달하기도 한다.
이때 중요한 건 단 하나다.
모두가 같은 결정을 내려야 한다는 것.
이 문제를 분산 시스템에 그대로 옮기면 이렇게 바뀐다.
“신뢰할 수 없는 서버들이 서로 다른 데이터를 보내고 있을 때, 우리는 무엇을 기준으로 ‘정답’을 결정할 것인가?”
---
2. 기존 방식은 왜 통하지 않을까
우리는 이미 분산 시스템에서 합의를 만드는 방법을 알고 있다.
대표적으로 Raft 같은 알고리즘이 있다.
이 방식은 간단하다.
- Leader 하나를 정한다
- Leader만 데이터를 기록한다
- 나머지는 이를 따른다
문제는 전제가 있다.
Leader를 믿을 수 있어야 한다는 것이다.
하지만 블록체인의 세계에서는 이 전제가 성립하지 않는다.
누가 Leader가 되든, 그 사람이 거짓말을 하면 끝이다.
누군가 데이터를 조작해도 막을 방법이 없다.
즉, “누군가를 믿는 구조” 자체가 깨진다.
---
3. 발상의 전환: 신뢰하지 말자
블록체인은 여기서 완전히 다른 선택을 한다.
아예 아무도 믿지 않는 것이다.
대신 이런 질문을 던진다.
“거짓말을 하려면 엄청난 비용이 들게 만들면 어떨까?”
이게 바로 블록체인의 핵심 아이디어다.
---
4. Proof of Work: 정답은 ‘노력’으로 결정된다
비트코인은 아주 단순한 규칙을 만든다.
- 어려운 문제를 하나 던진다
- 가장 먼저 푼 사람이 기록을 추가한다
여기서 중요한 점은 두 가지다.
문제를 푸는 것은 매우 어렵다.
하지만 정답을 검증하는 것은 매우 쉽다.
이 구조가 만들어내는 효과는 명확하다.
- 정직하게 참여하면 보상을 얻는다
- 조작하려면 엄청난 계산 비용이 든다
결국 시스템은 이렇게 흘러간다.
가장 많은 연산을 수행한 기록이 쌓이고,
그 기록이 가장 신뢰할 수 있는 데이터가 된다.
---
5. “가장 긴 체인”이 진짜인 이유
블록체인에서는 “가장 긴 체인”을 정답으로 본다.
이게 처음 보면 이상하다.
왜 길이가 길다고 진짜일까?
이걸 이해하려면 이렇게 생각하면 된다.
체인이 길다는 것은
그만큼 많은 계산이 누적되었다는 의미다.
만약 누군가 과거 데이터를 조작하려면 어떻게 해야 할까?
- 기존 체인을 따라잡아야 한다
- 동시에 새로운 블록도 계속 만들어야 한다
즉, 전체 네트워크보다 더 많은 연산 능력이 필요하다.
현실적으로 거의 불가능하다.
그래서 “가장 긴 체인”은
“가장 많은 비용이 들어간 기록”이 되고,
결국 가장 신뢰할 수 있는 데이터가 된다.
---
6. 이것이 비잔티움 문제의 해답이 되는 이유
비잔티움 문제의 핵심은 이것이다.
누가 맞는지 알 수 없는 상황에서 어떻게 합의할 것인가
블록체인은 이 질문을 이렇게 바꾼다.
누가 맞는지는 중요하지 않다.
누가 가장 많은 비용을 지불했는지가 중요하다.
이 순간, 문제는 완전히 다른 형태가 된다.
신뢰의 문제가 아니라, 경제적 게임이 된다.
---
7. 다시 돌아보는 분산 시스템
이 흐름을 분산 시스템 이론과 연결해보면 더 명확해진다.
CAP 정리는 장애 상황에서 무엇을 포기할지 선택하게 만든다.
PACELC 정리는 평상시에도 성능과 정확성 사이의 균형을 요구한다.
Raft는 Leader를 통해 데이터 충돌을 막는다.
하지만 이 모든 것들은 기본적으로 “정직한 시스템”을 전제로 한다.
블록체인은 이 전제를 버린다.
그리고 그 위에 완전히 새로운 규칙을 만든다.
---
8. 마무리
블록체인을 이해하면서 가장 인상 깊었던 점은 이것이다.
신뢰를 강화하려고 하지 않았다.
아예 신뢰를 제거했다.
그리고 그 자리를 비용과 보상이라는 구조로 채웠다.
결국 분산 시스템은 기술적인 문제이면서 동시에 설계의 문제다.
무엇을 믿고, 무엇을 포기하고, 어떤 기준으로 합의를 만들 것인지에 대한 선택이다.
그리고 블록체인은 그 선택 중 하나의 극단적인 해답이다.
Dev Book Review
- “서버를 믿지 못하면 어떻게 할까?”: 블록체인으로 풀어보는 비잔티움 장군 문제 2026.03.18
- [DDIA Deep Dive] 트위터(X)는 어떻게 1억 명의 타임라인을 만들까? - 실습편 2026.01.12 1
- [DDIA Deep Dive] 트위터(X)는 어떻게 1억 명의 타임라인을 만들까? - 이론편 2025.12.17
“서버를 믿지 못하면 어떻게 할까?”: 블록체인으로 풀어보는 비잔티움 장군 문제
[DDIA Deep Dive] 트위터(X)는 어떻게 1억 명의 타임라인을 만들까? - 실습편
깃허브 주소
https://github.com/Rayakeem/study-ddia
GitHub - Rayakeem/study-ddia
Contribute to Rayakeem/study-ddia development by creating an account on GitHub.
github.com
데이터 중심 애플리케이션 설계 (DDIA) 스터디를 진행하며, 1장의 핵심 주제인 "부하(Load)에 따른 아키텍처 패턴"을 실제 코드로 구현해보았다. 단순한 DB 조회(Pull)에서 시작해, 성능 최적화를 위한 Fan-out(Push), 그리고 유명인 문제를 해결하기 위한 Hybrid 전략까지의 진화 과정을 기록한다.
Tech Stack
- Language: Java 17, Spring Boot 3.x
- Database: H2 (JPA/Hibernate)
- Cache: Redis (Docker)
- Test: JUnit5
1. 문제 정의: 왜 DB만으로는 안 되는가? (Pull Model)
초기 트위터처럼 유저가 피드를 조회할 때마다 SELECT * FROM tweets WHERE ... 쿼리를 날리는 방식은 Pull 모델이다.
- 장점: 구현이 매우 쉽다.
- 단점: 팔로우하는 사람이 많아질수록 쿼리가 무거워지고, 동시 접속자가 늘어나면 DB가 병목이 된다. (Read 부하)
이를 해결하기 위해 Redis를 활용한 Push 모델(Fan-out)을 도입했다.
2. 해결책 1: Push 모델 (Fan-out on Write)
글을 쓰는 시점에 팔로워들의 "우편함(Redis List)"에 미리 글 ID를 넣어주는 방식이다. 이렇게 하면 읽을 때는 Redis에서 꺼내기만 하면 되므로 속도가 획기적으로 빨라진다.
구현 코드 (FeedService.java)
// "내 글을 팔로워들의 우편함에 꽂아준다"
public void postTweet(Long userId, String content) {
// 1. DB에 원본 저장 (Single Source of Truth)
Tweet tweet = new Tweet(userId, content);
tweetRepository.save(tweet);
// 2. 나를 팔로우하는 사람 목록 조회
List<Follow> followers = followRepository.findByFolloweeId(userId);
// 3. Fan-out: 팔로워들의 타임라인(Redis)에 트윗 ID 배달 (Push)
for (Follow follow : followers) {
String key = "timeline:" + follow.getFollowerId(); // Key 생성 (예: timeline:200)
redisTemplate.opsForList().leftPush(key, tweet.getId()); // Value는 ID만 저장 (메모리 절약)
}
}
3. 한계점: "팔로워가 많은 유명 셀럽" 문제
하지만 Push 모델에도 치명적인 단점이 있다. 팔로워가 수천만 명인 초대형 인플루언서(VIP)가 글을 쓸 때다. 글 하나 썼는데 1,000만 번의 루프를 돌며 Redis에 데이터를 넣어야 한다. 시스템이 순간적으로 멈출 수 있다.
이를 해결하기 위해 Hybrid(혼합) 아키텍처를 적용했다.
4. 해결책 2: Hybrid 모델 (Push + Pull)
전략은 다음과 같다.
- 일반 유저: 기존처럼 Push(Fan-out) 방식을 사용한다. (빠른 읽기 속도 유지)
- VIP (연예인): Push를 하지 않고 DB에만 저장한다. (쓰기 부하 방지)
- 읽을 때: 일반인 친구 글(Redis)과 VIP 글(DB)을 합쳐서(Merge) 보여준다.
구현 코드 1: 쓰기 로직
private static final int CELEBRITY_THRESHOLD = 5; // (실습용) 팔로워 5명 이상이면 연예인 취급
public void postTweet(Long userId, String content) {
// ... DB 저장 로직 동일 ...
List<Follow> followers = followRepository.findByFolloweeId(userId);
// [Hybrid 핵심] 팔로워가 너무 많으면 Redis 배달을 생략한다! (Pull 대상이 됨)
if (followers.size() >= CELEBRITY_THRESHOLD) {
log.info(" 연예인(ID:{})입니다. Fan-out을 생략합니다.", userId);
return;
}
// 일반인인 경우에만 Fan-out 실행
for (Follow follow : followers) {
redisTemplate.opsForList().leftPush("timeline:" + follow.getFollowerId(), tweet.getId());
}
}
구현 코드 2: 읽기 로직 (Merge)
피드를 조회할 때 두 가지 소스(Redis + DB)를 가져와 메모리에서 합친다.
public List<Tweet> getFeed(Long fanId) {
// 1. [Redis] 일반인 친구들의 글 ID 가져오기 (Push 모델)
List<Object> redisResult = redisTemplate.opsForList().range("timeline:" + fanId, 0, -1);
// Redis의 Object 데이터를 Long으로 안전하게 변환
List<Long> redisTweetIds = new ArrayList<>();
if (redisResult != null) {
redisTweetIds = redisResult.stream()
.map(obj -> Long.valueOf(obj.toString()))
.toList();
}
// ID 목록으로 실제 내용 조회 (WHERE id IN (...))
List<Tweet> normalTweets = tweetRepository.findAllById(redisTweetIds);
// 2. [DB] 내가 팔로우한 연예인들의 글 가져오기 (Pull 모델)
List<Follow> followings = followRepository.findByFollowerId(fanId);
List<Long> vipIds = new ArrayList<>();
// 내가 팔로우한 사람 중 연예인만 골라냄
for (Follow follow : followings) {
if (followRepository.countByFolloweeId(follow.getFolloweeId()) >= CELEBRITY_THRESHOLD) {
vipIds.add(follow.getFolloweeId());
}
}
// 연예인 글 조회 (WHERE user_id IN (...))
List<Tweet> vipTweets = new ArrayList<>();
if (!vipIds.isEmpty()) {
vipTweets = tweetRepository.findAllByUserIdIn(vipIds);
}
// 3. [Merge] 합치기 및 최신순 정렬
List<Tweet> allTweets = new ArrayList<>();
allTweets.addAll(normalTweets);
allTweets.addAll(vipTweets);
// ID 역순(최신순) 정렬
allTweets.sort((t1, t2) -> t2.getId().compareTo(t1.getId()));
return allTweets;
}
💡 Retrospective (회고)
- Redis 데이터 타입: RedisTemplate<String, Object>를 사용할 때, 꺼내온 데이터가 Integer인지 Long인지 불확실하므로 toString() 후 파싱하는 과정이 필요했다.
- JPA의 In 쿼리: findAllById나 findAllByUserIdIn을 통해 N+1 문제를 피하고 한 번의 쿼리로 데이터를 가져오는 것이 성능의 핵심이었다.
- 트레이드오프: Hybrid 방식은 읽기 로직(getFeed)이 복잡해지는 단점이 있지만, 시스템 전체의 안정성을 위해 꼭 필요한 선택이었다.
DDIA 책의 이론을 실제로 코드로 짜보니, 왜 대규모 시스템들이 이런 복잡한 아키텍처를 선택하는지 피부로 느낄 수 있었다.
요약
- Push: 미리 배달해둔다. 읽기가 빠르다. (일반 유저용)
- Pull: 그때그때 찾는다. 쓰기가 빠르다. (VIP용)
- Hybrid: 둘을 섞어서 최적의 효율을 찾는다.
'Dev Book Review > 데이터 중심 애플리케이션 설계' 카테고리의 다른 글
| [DDIA Deep Dive] 트위터(X)는 어떻게 1억 명의 타임라인을 만들까? - 이론편 (0) | 2025.12.17 |
|---|
[DDIA Deep Dive] 트위터(X)는 어떻게 1억 명의 타임라인을 만들까? - 이론편
오늘은 책 1장에서 확성성을 설명하며 다룬 트위터(x) 사례를 집중 탐구하며 학습하고 실습을 해보겠습니다.
확장성에는 정답이 없고, 데이터의 특성(부하 매개변수)에 따라 설계가 완전히 달라져야 한다는 것을 보여줍니다.
1. 문제의 정의: 부하 매개변수(Load Parameter)
트위터의 주요 기능은 크게 두 가지입니다.
- 트윗 작성 (Post Tweet): 사용자가 글을 올린다. (평균 4.6k/sec, 피크 12k/sec)
- 홈 타임라인 조회 (Home Timeline): 팔로우한 사람들의 글을 모아서 본다. (300k/sec)
핵심 문제: 쓰기(Write)보다 읽기(Read) 요청량이 압도적으로(약 60배 이상) 많습니다. 이 비대칭성 때문에 아키텍처 고민이 시작됩니다.
2. 접근 방식 1: 관계형 데이터베이스 모델 (Pull 모델)
초창기 트위터가 사용한 방식이자, 우리가 일반적으로 생각하는 방식입니다.
- 동작 방식:
- 글을 쓰면 Tweets 테이블에 저장합니다. (단순 INSERT)
- 홈 타임라인을 볼 때, 내가 팔로우하는 사람 목록을 찾고 그들의 글을 시간순으로 정렬해서 가져옵니다.
- SQL로 표현하자면
SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
ORDER BY tweets.timestamp DESC;
- 장점: 글 쓰기 부하가 매우 적습니다.
- 치명적 단점: 읽기(홈 타임라인 조회)가 너무 느립니다. 팔로우하는 사람이 많아질수록 JOIN 비용이 기하급수적으로 늘어납니다. 초당 30만 건의 읽기 요청을 이 복잡한 쿼리로 처리하는 건 DB 입장에서 재앙입니다.
3. 접근 방식 2: 팬아웃 모델 (Push 모델)
트위터는 읽기 속도를 높이기 위해 아키텍처를 뒤집습니다. 읽을 때 고생하지 말고, 쓸 때 고생하자!는 전략입니다.
- 동작 방식 (쓰기 시점의 작업):
- 사용자가 트윗을 씁니다.
- 시스템은 그 사용자를 팔로우하는 모든 사람을 찾습니다.
- 각 팔로워의 '홈 타임라인 캐시(Redis 같은 In-memory DB)'에 해당 트윗 ID를 찔러 넣어줍니다(Push).
- 읽기 시점:
- 사용자는 그냥 자기 캐시(우편함)를 열어보기만 하면 됩니다. 복잡한 DB Join 없이 O(1)의 속도로 타임라인이 완성됩니다.
- 장점: 읽기 속도가 극단적으로 빠릅니다.
- 치명적 단점: 쓰기 부하가 폭발합니다(Fan-out).
- 팔로워가 100명인 사람이 글을 쓰면 100번의 쓰기 작업이 일어납니다.
- 만약 팔로워가 3,000만 명인 저스틴 비버(Justin Bieber)가 글을 쓰면? 단 한 번의 트윗으로 3,000만 건의 쓰기 요청이 찰나의 순간에 발생합니다. 이를 "핫스팟(Hot Spot)" 문제라고 합니다.

4. 트위터의 최종 해결책: 하이브리드(Hybrid)
"극단적인 두 가지 방식 중 하나만 고집하지 말라."
트위터는 두 방식을 섞었습니다.
- 일반 사용자 (팔로워 수가 적음): 접근 방식 2(Push/Fan-out)를 사용합니다. 쓰기 부하가 크지 않고 읽기는 빠르기 때문입니다.
- 유명인 (Celebrity, 팔로워가 매우 많음): 접근 방식 1(Pull)을 사용합니다. 저스틴 비버의 트윗은 팔로워들의 타임라인 캐시에 넣지 않습니다. 대신 팔로워가 타임라인을 조회할 때, "일반 사용자들의 캐시 데이터" + "유명인의 별도 트윗 데이터"를 그 시점에 합쳐서(Merge) 보여줍니다.
이것이 바로 시스템의 부하 특성(Load Parameter)에 맞춰 설계를 최적화한 사례입니다.
| 구분 | 일반 사용자 (그림의 모델) | 유명인 (예외 처리) |
| 방식 | Push (Fan-out) | Pull |
| 저장 위치 | 팔로워들의 Redis 캐시에 직접 꽂아줌 | 그냥 DB 테이블에만 저장함 |
| 쓰기 부하 | 높음 (팔로워 수만큼 복사해야 함) | 매우 낮음 (내 거 하나만 저장하면 됨) |
| 읽기 부하 | 매우 낮음 (캐시만 읽으면 됨) | 높음 (DB 조회 후 합치는 연산 필요) |
| 비유 | 우유 배달원이 집 앞 주머니에 우유를 넣어둠 (꺼내 먹기만 하면 됨) | 신문 가판대에 신문이 놓임 (내가 직접 가서 가져와야 함) |
일반 유저는 Push(캐시 미리 생성), 유명인은 Pull(조회 시 DB 조회)로 처리한다는 것을 알았습니다.
다음은 spring 환경에서 redis를 활용한 Push(fan out), Pull 실습 편을 기록하여 가져오겠습니다!
'Dev Book Review > 데이터 중심 애플리케이션 설계' 카테고리의 다른 글
| [DDIA Deep Dive] 트위터(X)는 어떻게 1억 명의 타임라인을 만들까? - 실습편 (1) | 2026.01.12 |
|---|