
💡본 시리즈는 트위터를 모티브로 하는 대용량 SNS 서버에 대한 내용이다. 대규모 트래픽과 복잡한 데이터 처리 요구 사항을 효과적으로 다루면서, 확장성과 유지보수성을 갖춘 서버 아키텍처를 구현하는 데 중점을 두었다.
추산에 따르면, 트위터는 초당 약 480만 회 규모의 트윗 읽기 요청을 감당하고 있으며 쓰기 대비 약 800:1의 읽기쏠림 현상을 보인다. 서비스의 핵심기능은 '타임라인(트윗 모아보기)'이며, 데이터베이스에 부담을 줄 수 있는 무거운 연산을 요한다. 추가로 모든 요청에 대해 5초 이내 응답을 보장해야 한다. 어떻게 이런 어려운 요구사항을 충족할 있을까?
시작에 앞서 트래픽의 규모를 가늠하기 위해 수집한 통계자료의 트위터 성능 관련 주요 지표들은 다음과 같다.
2억 3780만 mDAU - 유료화 일간 활성 사용자수monetizable daily active users, 2022년 기준) [1]초당 475,000회 읽기 요청 - 타임라인 또는 모아보기, 트윗 10개 한 번에 로드, 2013년 기준이며 최근의 수치는 공개되지 않은 듯 하나 쓰기 비율이 거의 비슷하다고 가정. 2013년 쓰기 TPS 5700[2] 감안할 시 2022년 기준 초당 475,000회로 추산.초당 6,000회 쓰기 요청 - 트윗, 2022년 기준 [3]75 : 1 - 읽기 요청 vs. 쓰기 요청 비율750 : 1이라고 간주할 수도 있을 것 같다. 통상적으로 SNS의 읽기 쓰기 비율은 100:1 ~ 10,000:1라고 한다.)5초 이내 - 읽기 및 쓰기 요청에 대한 응답 지연 시간(latency) 상한너무 큰 숫자라 잘 와닿지 않을 수가 있을 것 같아, 국내 한 유명 서비스 배달의 민족의 트래픽 기록과 비교하자면
초당 50,000회 읽기 요청 ('배달의 민족' 서비스의 연간 프로모션 행사에서 발생한 역대급 트래픽이었다고 한다)초당 1,000회 ~ 1,500회 쓰기 요청 (동일 서비스의 MySQL 노드 한 대가 받아들일 수 있는 쓰기 요청 상한선이라고 한다)과 같다. 읽기 요청 규모를 기준으로 했을 때, 트위터에서 약 10~100배 넘는 트래픽이 '일상적'으로 발생하고 있는 것이다.
SNS의 공통 핵심 요구사항은 Facebook(현 메타)의 CEO 마크 저커버그를 모티브로 한 영화 'The Social Network'의 한 유명한 대사에 잘 나타나 있다.
"우리 페이스북과 다른 서비스들의 차이는, 우리 서버는 절대 멈춰선 안된다는 겁니다...한 사용자가 이탈하면 다른 사용자도 이탈하기 시작할 것이고, 그렇게 도미노처럼 한 순간에 무너지는 거에요!"
대사에서 처럼 무슨 일이 생겨도 항상 서비스가 가능한 상태, 즉
가용성/내결함성(availability/fault-tolerance)의 필요가 절대적이다(가용성을 보장하기 위한 방법은 다음 포스팅에서 알아볼 것이다).
또 무엇이 있을까? 당연할지 모르겠지만, 쾌적한 사용자 경험을 위한
낮은 지연(low latency)일 것이다.
더하여 SNS의 읽기와 쓰기의 비율을 고려했을 때 읽기 요청에 대한 부하가 훨씬 더 클 것이라고 예상할 수 있고, 이런 상황에서 읽기 요청에 대한 낮은 지연이 특히 중요할 것이다.
타임라인(모아보기)
읽기 초당 450,000 TPS, 트윗쓰기 초당 6,000 TPS의 대용량 데이터, 대규모 트래픽 상황에서 어떻게5초 미만의 낮은 지연을 항상 보장할 수 있을까?
읽기 성능 보장을 위해 크게 다음의 'pull 방식'과, 'push 방식' 두 가지 방식의 알고리즘을 구상하였다. 단, 알고리즘 자체에 집중하기 위해 다음과 같은 전제를 한다.
첫 번째로 떠올린 알고리즘은 가장 전형적인 알고리즘이라고 볼 수 있다.
흔히 게시판 서비스 페이징을 할 때 이런 방식을 채택한다(데이터를 필요한 당시 DB에서 조회해 '당겨 온다' 하여 'pull 방식'으로 칭하였다).
한 유저의 타임라인에 올라갈 콘텐츠가 결정되는 시점은 유저가 자신의 타임라인 읽기요청을 할 당시가 아니라, 자신이 팔로우하는 유저가 트윗을 쓰는 시점에 미리 결정된다. 이에 착안하여 두 번째로 떠올린 알고리즘은 읽기 요청을 '요청 시점에 허겁지겁 탐색해 처리하는 것이 아니라, 필요할 때 즉시 가져올 수 있도록 미리 데이터를 준비 해놓을 수는 없을까?'하는 아이디어에서 나왔다. 알고리즘은 다음과 같다.
쓰기 시:
- 각 유저별로 자신만의 타임라인 저장소를 갖고 있다.
- 트윗을 작성해 쓰기 요청한다.
- 작성자를 팔로우하는 사람들의 목록을 읽는다.
- 팔로우 목록의 각 타임라인 저장소에 트윗을 쓴다(fan-out).
이는 쓰기 시점 당시 수신자 각각의 저장소에 데이터를 보내두는 '메일함 서비스'와 비슷하다고 볼 수 있다. 유저의 타임라인(메일함)에 팔로잉 유저들이 쓴 트윗이 이미 정렬 및 저장돼 있고, 필요할 때 트윗 목록을 그대로 가져오기만 하면 된다(데이터를 적소에 미리 '밀어 넣어 둔다'고 하여 'push 방식'이라고 칭하였다).
원본 데이터를 여러 개로 복사해 '환기시키듯 퍼져나가게 한다'는 의미에서, 이런 연산을 흔히 'fan-out'이라 칭한다. 한편 데이터 탐색에 드는 연산을 미리 해둔다는 측면에서 'pre-computing', 미리 데이터를 로드해 둔다는 측면에서 'pre-loading', 필요한 데이터를 미리 캐싱해 둔다는 측면에서 'cache-warming' 또는 'look-ahead cache'라고도 할 수 있다.
Pull 방식과 push 방식 중 하나를 택해야 한다면 다음의 트레이드 오프들을 고려할 수 있다.
Pull 방식은 비용대비 moderate한 읽기 성능 제공(가성비)하며 데이터 일관성 확보가 용이한 반면, 읽기 당시에 탐색 비용을 지불해야 하고 비교적 무거운 탐색비용이 들며 일정 수준 이상으로 로우 수가 증가할시 인덱스상 쓰기에서 급격한 부하가 발생할 수 있는 단점이 있다.
Push 방식은 읽기 속도의 현저한 개선의 장점을 기대할 수 있으나, 쓰기 부하 증가, 중복 데이터로 인한 공간 비용 증가, 파생 데이터 다수 발생으로 인한 데이터 일관성 보장 난이도 증가, 애플리케이션 구현 복잡도 증가 등이 있다.
관계형 DB에서의 읽기 성능 개선 방법에는 대표적으로 인덱스 설정이 있다. 인덱스를 설정하면 full-table scan을 방지하고, 인덱스를 설정한 필드에 대해 범위 질의(range scan)을 효율적으로 할 수 있어 읽기 질의 성능을 크게 개선한다. 인덱스 설정시 원본 데이터 중 인덱스가 설정된 필드의 집합을 원본과 별개로 생성하고 정렬하며, 해당 정보를 '페이지'에 기록하고 관리한다. 데이터 삽입시마다 페이지를 갱신하는 연산 비용이 추가되며, 데이터의 규모가 커질 수록 인덱스 페이지 갱신 작업으로 인한 쓰기 성능이 급격히 악화된다는 단점이 있다.
스케일 업과 스케일 아웃, 데이터 분산처리, 데이터 저장소 선택(디스크 기반 저장소와 인메모리 저장소, SQL과 NoSQL) 등 구조적 성능 개선 방법에 대해선 다음 포스팅에서 다룬다.
SNS의 읽기 요청 vs. 쓰기 요청 비율상 읽기 성능이 최우선 요구사항임을 고려했을 때, 상술한 추가 비용을 지불하고서라도 push 방식을 도입하는 것이 합리적일 수 있다.
다만 결정에 앞서 추가로 해결해야할 중요한 문제가 한 가지 더 남아있다. 트위터의 핵심 기능인 팔로우 기능과 관련된 문제이다.
유저의 팔로잉 분포 범위는 팔로워 분포 범위보다 넓지 않다는 가설은 유효할 것이다.
예를 들어, 일론 머스크의 팔로워는 1억명이 넘는 반면, 한 유저가 팔로잉하는 수는 대체로 100명에서 많아야 1,000명 수준일 것이다(한 유저가 1억 명의 유저들을 팔로우하진 않는다).
따라서 '팔로잉이 많은 사람의 타임라인 읽기 문제는 크게 고려하지 않아도 된다'고 생각할 수도 있지만, 다음처럼 Pull 방식에서 타임라인 읽기의 시간 복잡도를 생각해보자(user, tweet, follow 테이블의 인덱스가 최적화 되어있다고 가정).
O(
f*log(N) * log(N*t))
f: 한 유저의 팔로잉 수N: 트위터 전체 유저 수t: 유저당 평균 트윗 수
N과 t의 값이 아무리 커진다고 해도 결과는 로그 증가인 반면, f의 경우 값에 비례해 선형 증가한다. 즉, 팔로잉의 수는 타임라인 읽기 연산에 큰 영향을 준다고 볼 수 있다.
그렇다면 과연 Pull 방식 채택 경우 모든 타임라인 읽기 요청에 대해 제한시간내 응답을 보장할 수 있을까...?
Push 방식
트위터의 대표적 셀럽 유저 일론 머스크는 1억 명 이상의 팔로워를 보유하고 있다. Push 방식을 구현할 경우 일론머스크가 트윗을 할 때마다 1억 명의 타임라인에 트윗을 1개 씩 총 1억 개를 써야하는 큰 부하가 발생하게 된다.
그렇다면 과연 Push 방식 채택 경우 모든 쓰기 요청에 대해 제한시간내 응답을 보장할 수 있을까...?
문제1
Pull 방식을 채택할 경우 팔로잉이 많은 유저의 타임라인 읽기시 부하가 쏠리고, Push 방식을 채택할 경우 팔로워가 많은 유저의 쓰기시 부하가 쏠리는데, 어떤 방식이 가장 합라적일까?
팔로잉이 많은 사람이 pull 방식의 타임라인 읽기를 할 시 follow 테이블과 tweet 테이블 탐색에서 큰 부하가 발생한다.
따라서 트윗을 작성할시 쓰는 유저의 팔로워의 팔로잉 수를 임계치로 삼아, 특정 수 이상인 유저에 대해서만 push 방식의 fan-out을 적용하도록 하여 읽기와 쓰기 부하의 균형을 찾을 수 있다.
이 방식에선 유저가 트윗을 쓸 시 임계치를 넘은 유저(헤비 유저) 목록을 탐색하는 비싼 비용이 발생하는데, 이 때 헤비 유저 목록을 캐싱해두면 쓰기시 부하를 추가로 줄일 수 있다.
알고리즘3
쓰기 시:
1. DB에 트윗을 쓴다.
2. 팔로워 목록 전체 중 헤비유저 목록을 추려서 불러온다.
3. 팔로워들 중 헤비유저 목록을 추린다.
4. 트윗을 헤비유저 목록의 타임라인 저장소에 쓴다.
읽기 시:
1. 본인이 헤비유저에 해당하는지 확인한다.
2. (본인이 헤비유저일시)파생 저장소에서 모든 트윗을 읽어온다.
(팔로우 완전 캐싱)
쓰는 사람 -> 팔로워 중 헤비 유저 목록 캐싱
읽는 라이트 유저 -> 팔로잉 전체 목록 캐싱 -> 읽기시 성능 개선
팔로워가 많은 사람이 push 방식의 트윗 쓰기를 할 시 fan-out으로 인해 큰 부하가 발생한다.
따라서 트윗 작성시 쓰는 유저의 팔로워 수를 임계치로 삼아, 특정 수 이하인 유저에 대해서만 push 방식의 fan-out을 적용하도록 하여 읽기와 쓰기 부하의 균형을 찾을 수 있다.
이 방식에선 유저가 타임라인을 읽을 시 임계치를 넘은 유저(셀럽) 목록을 탐색하는 비싼 비용이 발생하는데, 이 때 셀럽 유저 목록을 캐싱해두면 읽기시 부하를 추가로 줄일 수 있다.
알고리즘4
쓰기 시
1. DB에 트윗을 쓴다.
2. 본인이 비셀럽 유저에 해당하는지 확인한다.
3. (본인이 비셀럽일시)팔로워 목록 전체를 불러온다.
4. 트윗을 팔로워 목록의 타임라인 저장소에 쓴다.
읽기 시
1. 미리 저장해둔 비셀럽의 트윗목록을 타임라인 저장소에서 읽는다(유저아이디 하나면 ok).
2. 셀럽 목록을 파생 저장소에서 읽는다.
3. 셀럽의 트윗목록을 DB에서 읽는다.
4. 셀럽, 비셀럽 트윗목록을 병합한다.
(팔로우 완전 캐싱)
쓰는 사람 -> 팔로워 중 셀럽 유저 목록 캐싱
읽는 사람 -> 팔로잉 중 비셀럽 유저 목록 캐싱
알고리즘 2-A과 2-B는 기본적으로 비슷한 읽기와 쓰기 부하를 주도록 설계할 수 있다.
단지 알고리즘 2-A의 경우 팔로잉의 임계치를, 알고리즘 2-B의 경우 팔로워의 임계치를 어떻게 설정하는지에 따라 읽기와 쓰기의 부하 분산이 가능해진다.
나는 2-B의 알고리즘을 선택하였는데, 이유는
Pull과 Push 두 방식 모두 TweetService 인터페이스를 구현하도록 하여(다형성), 비즈니스 로직 계층의 스프링 빈 설정만 변경하면 간단히 해당 방식대로 애플리케이션이 동작하도록 하였다.
Pull 방식으로 구현시 비즈니스 계층에 대한 설정은 아래와 같다.
@RequiredArgsConstructor
@Configuration
public class ServiceConfig {
...
private final TweetRepository tweetRepository;
@Bean
public TweetService tweetService() {
return new TweetDefaultService(tweetRepository);
}
}
Pull+push 혼합 방식으로 구현시 비즈니스 계층에 대한 설정은 아래와 같다.
@RequiredArgsConstructor
@Configuration
public class ServiceConfig {
private final UserRepository userRepository;
private final TweetRepository tweetRepository;
private final FollowRepository followRepository;
private final FanOutRepository fanOutRepository;
@Bean
public TweetService tweetService() {
return new TweetFanOutService(userRepository, tweetRepository, followRepository,
fanOutRepository);
}
}
Pull+push 혼합 방식을 위해 데이터 접근 계층에 FanOutRepository라는 클래스에 대한 설정을 아래와 같이 추가하였다.
@RequiredArgsConstructor
@Configuration
public class PersistenceConfig {
private final TweetMapper tweetMapper;
private final RedisTemplate<String, Object> objectFanOutRedisTemplate;
private final RedisTemplate<String, String> stringFanOutRedisTemplate;
...
@Bean
public TweetRepository tweetRepository() {
return new TweetRepositoryV1(tweetMapper);
}
@Bean
public FanOutRepository fanOutRepository() {
return new FanOutPublisherRepository(tweetMapper, objectFanOutRedisTemplate,
stringFanOutRedisTemplate);
}
}
@Transactional(isolation = Isolation.READ_COMMITTED)
@RequiredArgsConstructor
@Service
public class TweetDefaultService implements TweetService {
private final TweetRepository tweetRepository;
// 트윗 작성하기
@Override
public Tweet composeTweet(String userId, TweetComposeRequestDto tweetComposeRequestDto) {
Tweet tweet = Tweet.builder()
.id(UUID.randomUUID().toString())
.text(tweetComposeRequestDto.getText())
.userId(userId)
.createdAt(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS))
.build();
// 관계형 DB에 트윗 쓰기
return tweetRepository.save(tweet);
}
...
}
@Transactional(isolation = Isolation.READ_COMMITTED)
@RequiredArgsConstructor
@Service
public class TweetFanOutService implements TweetService {
private final UserRepository userRepository;
private final TweetRepository tweetRepository;
private final FollowRepository followRepository;
private final FanOutRepository fanOutRepository;
// 트윗 작성하기
@Override
public Tweet composeTweet(String userId, TweetComposeRequestDto tweetComposeRequestDto) {
Tweet tweet = Tweet.builder()
.id(UUID.randomUUID().toString())
.text(tweetComposeRequestDto.getText())
.userId(userId)
.createdAt(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS))
.build();
// 관계형 DB에 트윗 쓰기
tweetRepository.save(tweet);
// (추가 로직)본인이 셀럽계정에 해당하지 않는 경우에 한해 팬아웃 실행
if (!checkIfCelebrity(userId)) {
// 팔로워 userId 목록 읽기
List<String> followerIds = followRepository.findFollowerIdsByFolloweeId(userId);
// 팬아웃 실행
fanOutRepository.operateFanOut(followerIds, tweet);
}
return tweet;
}
...
타임라인 불러오기는 UI 디자인상 최초 1회 로드 후 스크롤이 로드한 페이지의 끝에 도달하면 추가 로드 요청이 트리거된다고 가정하여, 최초 조회 요청과 이후 추가 요청으로 나누어 구현하였다.
@Transactional(isolation = Isolation.READ_COMMITTED)
@RequiredArgsConstructor
@Service
public class TweetDefaultService implements TweetService {
private final TweetRepository tweetRepository;
...
// 타임라인 불러오기(최초)
@Override
@Transactional(readOnly = true)
public List<Tweet> getInitialTweets(String userId) {
return tweetRepository.findInitialTimelinePageTweets(userId);
}
// 타임라인 추가 불러오기
@Override
@Transactional(readOnly = true)
public List<Tweet> getMoreTweets(String userId, LocalDateTime createdAtOfTweet) {
return tweetRepository.findNextTimelinePageTweets(userId, createdAtOfTweet);
}
@Transactional(isolation = Isolation.READ_COMMITTED)ISOLATION_DEFAULT, 즉, 사용하는 RDBMS의 기본 설정에 따르도록 되어있다. 관계형 DB로써 MySQL을 선택하였고 MySQL의 기본 격리 단계 설정은 REPEATABLE_READ이다. 타임라인 조회의 경우 엄격한 데이터의 일관성보다 성능이 우선된다고 판단하였고, 이에 격리 단계를 READ_COMMITTED로 설정하였다.Pull+Push 혼합방식 역시 타임라인 최초 조회 요청과 이후 추가 요청으로 나누어 구현하였다.
@Transactional(isolation = Isolation.READ_COMMITTED)
@RequiredArgsConstructor
@Service
public class TweetFanOutService implements TweetService {
private final UserRepository userRepository;
private final TweetRepository tweetRepository;
private final FollowRepository followRepository;
private final FanOutRepository fanOutRepository;
// 타임라인 불러오기(최초)
@Override
@Transactional(readOnly = true)
public List<Tweet> getInitialTweets(String userId) {
// 1. 팔로우중인 셀럽유저의 최신 트윗 목록 관계형 DB에서 탐색 및 읽기
List<Tweet> tweetsOfCelebFollowees = lookForTweetsOfCelebFollowees(userId);
// 2. 팔로우중인 일반유저의 최신 트윗 목록 타임라인 전용 팬아웃 저장소에서 읽기
Set<Object> tweetsObjectsOfNonCelebFollowees = fanOutRepository.findTweetsObjectsOfNonCelebFollowees(
userId,
INT_ZERO_AS_START_INDEX_OF_RANGE_SEARCH,
TWEET_LOAD_LIMIT);
// 3. 셀럽유저와 일반유저의 최신 트윗 목록 병합 및 반환
return mergeFolloweeTweets(tweetsOfCelebFollowees, tweetsObjectsOfNonCelebFollowees);
}
// 타임라인 추가 불러오기
@Override
@Transactional(readOnly = true)
public List<Tweet> getMoreTweets(String userId, LocalDateTime createdAtOfTweet) {
// 1. 팔로우중인 셀럽유저의 최신 트윗 목록 관계형 DB에서 탐색 및 읽기
List<Tweet> tweetsOfCelebFollowees = lookForTweetsOfCelebFollowees(userId);
// 2. 팔로우중인 일반유저의 최신 트윗 목록 타임라인 전용 팬아웃 저장소에서 읽기
Set<Object> tweetsObjectsOfNonCelebFollowees = fanOutRepository.findTweetsObjectsOfNonCelebFollowees(
userId,
Double.MIN_VALUE,
createdAtOfTweet.toEpochSecond(ZoneOffset.UTC),
INT_ZERO_AS_START_INDEX_OF_RANGE_SEARCH,
TWEET_LOAD_LIMIT);
// 3. 셀럽유저와 일반유저의 최신 트윗 목록 병합 및 반환
return mergeFolloweeTweets(tweetsOfCelebFollowees, tweetsObjectsOfNonCelebFollowees);
}
셀럽유저의 목록을 관계형 DB에서 탐색하고 읽어오기 위한 로컬함수 lookForTweetsOfCelebFollowees()가 아래와 같이 추가되었다.
private List<Tweet> lookForTweetsOfCelebFollowees(String userId) {
// 1. 팔로우중인 셀럽유저 id 목록 조회용 Redis Key 생성
String redisKeyForCelebFolloweeIdList = PREFIX_FOR_CELEB_FOLLOWEE_ID_LIST_KEY + userId;
// 2. 팔로우중인 셀럽유저 id 목록 Redis에서 조회
List<String> celebFolloweeIds = fanOutRepository.findCelebFolloweeIds(
redisKeyForCelebFolloweeIdList,
INT_ZERO_AS_START_INDEX_OF_RANGE_SEARCH,
INT_NEGATIVE_ONE_AS_END_INDEX_OF_RANGE_SEARCH);
// 3. 팔로우중인 셀럽유저의 최신 tweet 목록을 관계형 DB에서 조회
return fanOutRepository.findListOfTweetsByUserIds(celebFolloweeIds, TWEET_LOAD_LIMIT);
}
...
셀럽유저와 일반유저의 최신 트윗 목록 병합을 위한 로컬함수 mergeFolloweeTweets()가 아래와 같이 추가되었다.
private static List<Tweet> mergeFolloweeTweets(
List<Tweet> tweetsOfCelebFollowees,
Set<Object> tweetsObjectsOfNonCelebFollowees) {
// 일반유저의 최신트윗이 존재할 경우 셀럽유저와 일반유저의 최신 트윗 목록 병합 및 반환
if (tweetsObjectsOfNonCelebFollowees != null) {
return Stream.concat(
tweetsOfCelebFollowees.stream(),
tweetsObjectsOfNonCelebFollowees.stream()
.filter(obj -> obj instanceof Tweet)
.map(obj -> (Tweet) obj))
.sorted(Comparator.comparing(
Tweet::getCreatedAt,
Comparator.reverseOrder()))
.limit(TWEET_LOAD_LIMIT)
.collect(Collectors.toList());
}
// 일반유저의 최신트윗이 존재하지 않을 경우 셀럽유저의 최신 트윗 목록만 반환
return tweetsOfCelebFollowees();
}
...
지금까지 트위터 API의 타임라인(모아보기) 읽기 450,000 TPS, 트윗 쓰기 6,000 TPS의 대용량 데이터, 대규모 트래픽 상황에서 5초 미만의 지연시간을 보장하기 위한 SNS 알고리즘에 대해 고민하고 API 구현 과정에 대해 정리해보았다. 하지만 이는 어디까지나 단일 노드와 애플리케이션의 비즈니스 계층 단계에서의 고민들이었다.
다음 포스팅에선 여기에서 더 나아가 아키텍처 디자인 관점에서 성능을 최적화하는 방법, 그리고 SNS의 또 다른 핵심 요구사항인 가용성을 보장하는 방법, 트위터 API에 최적인 저장소를 선택하는 기준들, 선택 과정과 그 이유에 대해 알아보고자 한다.
