본문 바로가기

엔지니어링(TA, AA, SA)/아키텍처

[아키텍처] 소셜 네트워크 서비스 - Push, Pull 아키텍처

d2.naver.com/helloworld/551588 - 소셜 네트워크 서비스 아키텍처에 대하여

d2.naver.com/helloworld/809802 - LINE 소셜 네트워크 서비스의 아키텍처

 


 

웹서비스와 소셜 네트워크 서비스의 차이점. 특성. 현황.

엄청난 양과 속도 그리고 다양한 데이터를 처리해야 하는 서비스 환경 속에서 다르게 진화해옴.

아키텍처 - Facebook(Pull), 트위터(Push)

 

Facebook에서 약 7,600만명의 사람들이 좋아요를 클릭. 좋아요를 클릭한 사람들은 해당 페이지에 업데이트되는 콘텐츠를 자신의 뉴스피드에서 볼 수 있다. 트위터에 팔로워가 4,300만명인 유저의 트윗은 각 팔로워의 타임라인에 표시된다. 

 

SNS 이면에서 어떤 연산이 벌어지고 있을까? 왜 벌어지고 있을까? 더 효율적으로 하는 방법은 없을까? 이러한 호기심에 대한 답은 우리에게 익숙했던 기존의 서비스들과 소셜 네트워크 서비스의 차이점을 먼저 파악하고 그 차이에서 기인하는 문제를 어떻게 해결할지 고민하여 얻을 수 있다.

 

SNS의 정의를 찾아보면 SNS는 우리에게 익숙한 이메일, 메신저, 커뮤니티 서비스를 포함한다. 범위를 조금더 좁히면 그룹보다는 개인이 주체가 되어 관계를 맺고 다양한 주제에 대한 이야기를 공유할 수 있는 서비스를 SNS라고 정의한다. 귀납적으로 접근해서 트위터, Facebook, LINE에 기존의 서비스들과는 명확하게 다른 공통 기능이 있는지 생각해보는 것도 좋을것 같다. 결국 친구들의 이야기를 모아서 볼 수 있는 '모아 보기'(Facebook의 뉴스피드, 트위터와 LINE의 타임라인) 기능이다. 다시 말해, 기능적 관점에서 SNS와 기존 서비스의 차이를 설명한다면, 관계들의 연결 고리를 통해 친구들의 이야기를 모아 보여주는 기능이 그 차이의 본질이라고 할 수 있다.

 

모아 보기의 기본 구조 자체는 그리 복잡하지 않을 수 있다. Facebook의 주커버그도 LAMP 스택의 게시판 구조로 시작했듯이, 게시판 형태가 가장 쉽게 생각할 수 있는 구조일 것이다. 게시판에서 특정 사용자가 작성한 글을 검색하고 페이징하는 것은 그리 어렵지 않다. 이러한 작성자 검색 기능은 작성자 필드를 인덱싱하여 효율적으로 구현할 수 있다. 나의 모아보기 화면을 구성하기 위해서는 팔로잉하는 친구들의 식별자를 OR로 연결해서 작성자로 게시물을 검색하면 끝이기 때문이다.

 

작성자 OR 알고리즘 연산비용 계산

전체 게시물의 수를 l이라고 한다면 게시판 페이징 비용은 O(log(l))이다.

사용자별 평균 보유 게시물 수를 k라고 한다면, l은 k * n이다.

사용자 검색 + 게시판 페이징 연산 비용의 비교 => O(m log(n)) : O(log(kn))

 

Big O 분류 관점에서 m과 k는 상수이므로 두 복잡도는 같다고 볼 수 있다.

굳이 시간 비용을 비교하자면, m : log(k)/log(n)+1  => 평균 친구 수 : log(사용자별 평균 보유 게시물 수)/log(전체 사용자 수) + 1 이다.

 

Facebook의 통계 데이터를 대입해 보면,

  - 전체 사용자 약 10억 명

  - 평균 친구 수는 342명

  - 월간 평균 게시물 수 36건

  - 연간 누적 평균 432개의 게시물

 

약 342:1.3 작성자 검색은 게시판 페이징 대비 대략 260배 정도 더 무겁다고 추정할 수 있다. 즉, 모든 사용자가 자신의 모아 보기를 보는 매 순간 이런 비용이 발생한다는 것이다.

 

이런 시간 비용을 줄이는 방안은 무엇일까? 반사적으로 생각할 수 있는 대안은 바로 캐시를 적용하는 것이다. 소셜 피드에서 최신 데이터를 가장 빈번하게 요청하기 마련이다. 하지만 DB 수준의 캐시를 적용하면 다양한 서비스 쿼리 때문에 원하지 않는 데이터 블록이 자동으로 캐싱될 수 있으므로 프로그래밍으로 조절할 수 있는 캐시가 필요하다. LAMP 스택에서 가장 흔하게 쓸수 있는 memcached를 최신 피드에 대한 look-ahead 캐시로 사용한다면 DB로 가는 트래픽을 크게 줄이고 빠르게 반응할 수 있다. 이런 속도를 공짜로 얻을 수 있는 것은 아니다. 결국, 비즈니스 로직상에서 일관성을 유지하기 위한 상당한 코딩이 필요하게 된다.

 

이제 시간 복잡도는 어느 정도 줄였다. 하지만 엄청난 데이터양을 생각해본다. Facebook에는 하루에 10억 개의 상태 메시지가 올라온다. Facebook의 일간 데이터 저장량을 추정해보면, 상태 메시지에서 이미지 등의 부가 데이터를 제외한 순수 텍스트만 평균적으로 300바이트 정도를 쓴다고 가정할때, 상태 데이터 텍스트만 하루에 약 280GB가 쌓인다.

 

이 정도 데이터양을 지탱하기 위해서는 여러 서버를 사용할 수 밖에 없다. Facebook은 4,000대 이상의 MySQL 샤드와 9,000대 이상의 memcached 샤드를 구성해 사용하고 있다. 이정도 규모의 샤드라면 데이터를 찾아 사용하기 위한 또 다른 다계층 중간 서버들이 필요하고, 이러한 복잡한 구성때문에 프로그램 로직은 구성에 묶일 수 밖에 업삳.

 

이런 공간 복잡도는 SNS의 특성만으로 기인한 것은 아니라고 할 수도 있다. 진짜 문제는 거대한 클러스터를 구성하고 나서야 비로소 보이게 된다. 친구들의 데이터가 같은 샤드 내에만 존재한다면 쉬웠을 것이다. 하지만 샤드 내 데이터가 커진다면 결국 가 친구의 데이터가 곳곳에 분산될 수 밖에 없다. 친구의 샤드가 #3989에 있다면 이제 작성자 OR 알고리즘은 어떻게 돌릴 수 있을까?

 

가장 먼저 해결할 문제는 사용자의 데이터가 어디에 있는지 찾을수 있는 방법이 있어야 한다는 것이다. 어떤 서버에 데이터가 있는지 하나하나 관리할 수도 있고, 해시 함수를 사용해 해시 결과가 해당 서버로 바로 안내할 수도 있다. 전자는 주소의 유연성이 있지만 그 유연성을 지탱할 거대한 저장소가 필요하고, 후자는 단순하고 저장소가 필요없지만 유연성이 떨어진다. 이러한 문제를 해결하기 위해 분산 환경에서는 둘의 장점을 혼합해 제공하는 consistent hashing 기반의 구조를 자주 사용한다. 쉽게 설명하면, 각 데이터의 저장소 주소를 저장하기보다 한정된 전체 주소를 여러 범위로 나누어 각 저장소에 대응시켜 사용하는 방식이다. 그 주소 범위와 저장소의 대응 테이블은 HBase와 같이 master-slave 모델, 또는 Cassandra와 같이 peer-to-peer 모델로 공유할 수도 있다.

 

클라이언트 라이브러리에서 데이터가 어떤 샤드에 있는지 찾는 기능을 캡슐화해서 구현했다면 그 다음은 흩어져 있는 새로운 소식들을 어떻게 찾고 모을지 고민해야 한다. 사실, 같은 게시판 DB를 여러 서버에 올려두고 그 속의 데이터만 사용자 고유 식별자의 해시 값에 따라 저장되는 서버가 달라지는 구성이기 때문에 기존의 단일 서버 구성과 다중 서버 구성상 서버별 검색은 다르지 않다. 하지만 다중 서버를 사용하는 경우에는 먼저 친구들의 데이터가 어떤 서버에 있는 확인하고 병렬로 서버별 친구들을 묶어 검색한 후, 그 결과를 다시 모아 정렬하고 잘라내는 작업이 필요하다. 연산 비용이 커지게 되지만 그나마 다행스럽게 최신 데이터들이라면 앞서 적용한 캐시의 도움으로 빠르게 모아서 처리할 수 있을 것이다.

 

문제는 여기서 끝이 아니다. 소셜 피드의 수백 가지 형태에 대해 고민해보자. 근황, 사진, 비디오, 체크인, 좋아요, 공유, 댓글, 링크, 팔로잉, 추천 등등. 게시판 관점에서 본다면, 하나의 게시물에는 수백 개의 부가 필드가 필요할 것이다. 그리고 이 수백 개의 부가 필드를 위해 수천 대의 캐시와 DB 서버를 조회하여 구조를 변경하고 데이터를 구성해야 한다. 어떻게 하면 최소한의 구조로 최대한의 표현력을 가지며 해당 정보들을 빠르게 읽을 수 있을까? 먼저 게시물의 트랙백 기능을 생각해본다. 트랙백은 내부 게시물 관련 글을 외부 사이트에서 쓰면 정해진 프로토콜로 원래 글에 연결해 댓글처럼 표시되게 하는 것이다. 만약, 트래백을 외부 글에서 하는 것이 아니라 내부 간에 적용하면 어떻게 될까? 그리고 트랙백 종류별로 댓글, 위치, 첨부 파일 등으로 구분한 다면 어떻게 될까? 내부 자료 구조는 매우 단순해진다.

 

원객체와 관계명 그리고 대상 객체 트리플은 매우 단순한 구조이지만 'UserA friends UserB likes CarC likedBy UserD'와 같이 연결해 사용하면 표현력에 제한이 없다. 이는 그래프 자료 구조와 비슷하다. 이러한 그래프 자료 구조를 통해 최소한의 구조로 최대한의 표현력을 가질 수 있도 있을 것이다.

 

주커버그가 처음 Facebook을 만들기 시작했을때, 바로 이렇게 시작했다. 지금까지 설명한 내용처럼 진화시켜온 모델이 현재 Facebook 아키텍처가 발전해온 모습이다. 현재 버전에서 가장 크게 발전한 부분은 캐시 계층이다. 기존에 memcached를 look-ahead 캐시로 사용했다면 TAO(The Associtations and Objects)라는 그래프 자료 구조의 write-thru 캐시로 대체한 것이다. 기존에는 프로그램 로직에서 캐시도 관리하고 DB도 관리해야 했지만, TAO를 통해 내부적으로 알아서 영구 저장소(MySQL)에 저장하게 된다.

 

이제는 트위터 서비스를 살펴본다. 처음 트위터는 메시지 전달이라는 관점에서 서비스를 시작했다. 일종의 그룹 SMS 서비스로 시작되었다. 트위터와 닮은 서비스 형태는 이메일이다. 앞서 SNS의 핵심 특성이 모아보기라고 했다면, 이메일의 경우 여러 사람을 수신인으로 지정하여 그룹 내에서 의견을 주고 받을 수 있기에 받은 메일함이 그 역할을 한다고 볼 수 있다. 내가 중심이기보다는 상대방에게 메시지를 전달하는 것이 주목적이었으므로 SNS의 정의와 사뭇 다르지만, 트위터의 아키텍처를 이해하는데 도움을 줄 수 있다. 메시지 전달이라는 기본 사상을 가지고 시작한 서비스라면 이메일의 기본 구조를 모방하게 된다. 트위터는 이러한 태생적 차이 탓에, 게시판에서 시작한 Facebook과 아키텍처가 매우 달라진다.

나를 팔로우하는 사람들은 나의 메시지를 수신하는 사람들이다. 결국, 내가 트윗을 올린다는 것은 나를 팔로우하는 사람들의 받은 트윗함, 즉, 타임라인에 내 트윗이 배달되어야 한다는 것이다. 140바이트의 단문이니 큰 부담없이 이메일처럼 그대로 복사해 넣으면 된다고 생각할 수도 있을 것이다. 하지만 피크 시간에는 초당 143,199건이 유입되는 트윗과 트윗별로 배달해야 하는 평균 208명의 친구를 고려한다면 그 생각이 틀렸다는 것을 바로 알 수 있다. 이메일은 여러 다른 메일 서버들이 메시지를 수신하기 때문에 메시지가 복사되어 전달되지만, 트윗 메시지는 단일 서비스 내 사용자들에게 배달되기 때문에 그럴 필요가 없다. 트위터는 원본 메시지는 하나만 두고, 해당 메시지에 대한 참조 키만 팔로워들의 타임라인에 배달되는 방식을 사용한다. 4,300만명 정도의 팔로워에게 트윗을 배달하는데 걸리는 시간은 5분 안팎이다. 

 

트위터의 이러한 아키텍처 진화의 이유를 살펴보자. 먼저, 관계의 폭이 넓다는 것이다. Facebook의 경우 맺을 수 있는 친구의 수는 5천 명으로 한정돼 있다. 그에 반해 트위터는 충분한 팔로워가 있다면 훨씬 더 많은 수의 사람들을 팔로우할 수 있다. 트위터에서 12만명을 팔로우하고 있고, 12만 명이 하루에 하나씩 트윗을 쓴다면 타임라인은 12만 개의 트윗으로 채워진다는 뜻이다. 특위터에서 OR 체인 알고리즘은 전혀 가능한 대안이 아니다. 또, 거꾸로 생각해보면 Facebook 뉴스피드의 기본 정렬 순서가 Top Stories(인기 소식순)인지 알 수 있을 것이다. 유명 글은 즉 유명인의 글이라고 볼 수 있다. 따라서 작성자 OR 체인에서 전체 친구를 대상으로 하는 대신, 적은 수의 유명한 친구들을 대상으로 수행하기 때문에 시스템 전반적으로 부담을 줄일 수 있다. 5천명 이상 친구들을 구독한다는 것은 현실적으로 어렵고 또 의미가 없기 대문에 Facebook은 5천 명을 물리적 제약 조건으로 보았다. 하지만 나를 구독하는 사람의 수에는 제약을 두지 않아서 유명 인사들이 다수의 팬들에게 영향력을 행사하는데에는 지장이 없다. 이러한 차이로 인해 트위터가 Facebook보다는 관계의 폭이 넓지만 헐겁게 연결되어 있음을 알 수 있다.

 

다음으로 주목할 것은 사용 패턴이다. Facebook의 경우 읽기 요청과 쓰기 요청의 비율이 99.8:0.2이다. 트위터의 경우도 읽기 요청의 비율이 더 컸으면 컸지 적진 않을 것이다. 이렇게 읽기 요청이 대부분의 서비스 트래픽을 차지한다면 Facebook처럼 읽는 시점에 복잡한 연산을 하는 것이 좋은 아키텍처인지 반문해볼 필요가 있다. 트위터처럼 방문한 사용자를 위해 개인화된 타임라인을 즉시 보여주고 싶다면 이메일의 받은 메일함과 같이 전달된 메시지가 모여 있어야 한다. 하지만 이 또한 꽤 높은 비용이 든다. Facebook이 시간 복잡도라는 비용을 냈다면, 트위터는 공간복잡도라는 희생이 있다.

 

트위터는 저장하는 콘텐츠의 크기가 140바이트 고정으로, Facebook에 비해 매우 작다. 하지만 내가 팔로우하고 있는 누군가가 트윗을 쓸때마다 내 타임라인의 트윗의 참조 키가 배달된다. 물론, 그 역도 마찬가지다. 4,300만 명 팔로워 중 트위터에 자주 방문하지 않는 사용자들 또는 첫 페이지에서 밀리면 읽지도 않는 사용자들을 위해 트윗의 참조 키를 배달해야 한다. 참조 키의 크기라고 해봐야 long형 8바이트 정도라 크게 부담이 없다고 생각할 수 있지만, 거꾸로 유명인이 쓰는 140바이트 메시지의 실질 전송 크기는 4,300만 x 8바이트, 즉, 330MB나 된다는 것을 생각해야 한다. 트윗을 작성하는 순간에 엄청난 양의 데이터를 나누어 저장해야 하는데, DB로 이런 트래픽을 받아내는 것은 어렵기 때문에 사용자의 트윗 수신함, 즉 타임라인은 Redis 클러스터로 구성하여 결국에는 MySQL로 저장한다. 트위터도 이로 인한 공간 복잡도의 문제를 가지고 있으며 이를 해결하기 위해 Gizzard(주소를 찾기 위한 방법)와 MySQL 샤딩을 사용하고 있다.

 

LINE의 모아보기 기능인 타임라인도 트위터의 아키텍처를 사용하고 있다. 차이점이라면 메모리 캐시 계층에서 네이버에서 확장 개발한 memcached 클러스터인 Arcus를 사용하고, 받은 소식함 인덱스의 영구 저장소로는 쓰기 처리율이 높고 스케일 아웃이 용이한 Cassandra를 사용하고 있다는 것이다. 또한 천만 명 이상의 친구들을 가진 공식 홈 계정들을 위해 기존의 아키텍처를 확장했다.

 

게시판 facebook - pull 방식의 SNS 아키텍처

(검색) 시간 지불

 

이메일 twitter - push 방식의 SNS 아키텍처

(인덱스) 공간 지불

 

하지만 모든 상충의 이면에 이면을 좇다 보면 상충을 해소할 수 있는 방법이 있을 수 있다. 이 상충 관계를 pull과 push의 혼합으로 해결할 수 있다는 논문이 Yahoo!에서 나왔다. 결국, push를 하겠다는 것은 각 사용자별 수신함 인덱스를 만들겠다는 것인데, 이 인덱스를 현재의 트위터처럼 무조건 만드는 경우 공간 복잡도가 높아질 수 밖에 없기에 친구들의 활동 내역과 사용자의 방문 패턴에 따라 선택적으로 인덱스를 만드는 방식을 쓸 수 있다는 것이다. 물론, pull과 push 두 방식을 선별적으로 지원하는 아키텍처기 때문에 구현과 운영의 복잡도는 더 커질 수 밖에 없다. 하지만, 시스템 자체의 비용을 최적화할 수 있게된다.


LINE의 소셜 네트워크 서비스인 홈과 타임라인에서 급격하게 증가하는 트래픽과 데이터를 처리하기 위한 아키텍처와 기술을 살펴본다. 초기 설계부터 확장성을 과도하게 고려하면 오버엔지니어링일 수도 있고, 고려했다고 해도 그 증가 속도가 너무 빠르다면 속수무책이거나 인해전술과 물량전으로 다급하게 대응해야 하는 경우가 비일비재할 것이다. 그렇자면 사용자의 증가 속도가 Facebook이나 트위터를 압도하는 서비스라면 어떨까? 사용자가 5천만명이 되기까지 Facebook은 1,325일이 걸렸고, 트위터는 1,096일이 걸렸고, 라인은 399일이 걸렸다.

 

대표적인 SNS 아키텍처는 Pull 방식과 Push 방식이 존재한다. LINE의 소셜 네트워크 서비스가 어떤 아키텍처와 기술을 사용하는지 살펴본다. 이러한 대규모 SNS 아키텍처는 웹, 앱, 게임 등의 다양한 서비스에서 참고할 수 있기 때문에 더욱 유용할 것이다.

 

LINE의 SNS 기능은 '홈'과 '타임라인'이라고 부른다. 홈에서는 사용자가 자신의 글과 사진을 친구들과 공유할 수 있고, 타임라인에서는 친구들의 소식을 모아볼 수 있다.

SNS 데이터 사용 패턴

LINE 홈과 타임라인의 아키텍처를 이해하려면 먼저 SNS 사용자가 어떻게 데이터를 사용하는지 이해해야 한다. 홈과 타임라인을 포함한 일반적인 SNS에서 데이터를 사용하는 기능을 추상화하면 크게 내가 남긴 상태만 보여주는 기능과 친구들의 소식을 모아서 보는 기능으로 나눌 수 있다. 두 기능은 수평선과 수직선의 차이만큼이나 다르다.

 

간략하게 설명하기 위해 10명의 사용자가 같은 기간 동안 글을 작성했고, 인접한 슬롯에 친구를 가진 3명의 사용자가 오늘 LINE 홈과 타임라인에 방문한다고 가정한다. LINE에서 타임라인을 눌러 사용자가 자신의 타임라인을 방문할 때에는 친구들이 최근에 작성한 글의 목록을 보기 때문에 여러 친구의 최신 글을 넓고(친구들과 자신) 얕게(시간) 요청한다. 그에 반에 내가 작성한 글이 모여 있는 홈에서는 시간이 조금 지난 글을 좁고(자신) 깊게(시간) 요청하기 마련이다. 이러한 데이터 요청 패턴은 아래 그림과 같다.

수평축은 차례로 발급된 사용자 아이디로 생각할 수 있고, 수직축은 사용자가 저장한 데이터로 생각할 수 있다. 수직축에서 위로 갈수록 최신 데이터고, 아래로 갈수록 과거 데이터이다. 회색 영역은 전체 데이터를 나타내고, 푸른색 영역은 자주 요청되는 최신 데이터를 나타낸다. 푸른색 외의 데이터는 빈번하게 요청되지 않는 콜드 데이터이다. 타임라인과 홈 두 기능은 같은 전체 데이터 중, 빈번하게 요청하는 데이터가 사용자의 범위와 시간의 깊이에 따라 수평과 수직의 패턴으로 판이해진다. 이렇게 서로 다른 데이터 사용 패턴을 빠르고 안정적으로 서비스하려면 어떻게 데이터를 모델링해야 할까?

SNS용 데이터 모델링

전혀 다른 데이터 사용 패턴을 처리하면서 동시에 각 패턴에 맞추어 유연하게 성능을 확장하기 위한 하나의 데이터 모델을 설계하기 보다는, 콘텐츠를 나열하기 위한 인덱스를 콘텐츠 데이터에서 떼어내어 별도로 모델링하는 것이 문제를 훨씬 단순하게 할 수 있다. 또한, 다른 기준으로 컨텐츠를 나열하기 위해 기존 데이터를 건드리지 않고, 별도의 인덱스를 추가해 쓸수 있어 그 유연성이 더 높다고 할 수 있다. 

 

즉, 각각의 목록 구성을 위한 인덱스는 따로 가지지 있고 실제 목록 내 컨텐츠 데이터는 공유해서 사용하는 방식이다. 홈은 자신이 쓴 글의 ID를 모아둔 B+트리(B+ tree)가, 타임라인은 자신을 포함한 친구들이 쓴 글의 ID를 모아둔 B+ 트리가 인덱스가 될것이고, 목록 내 ID에 해당하는 실제 글의 데이터를 Key-Value 쌍으로 저장하는 딕셔너리를 저장소로 추상화해 모델링할 수 있다. 물론, 각 목록을 구성하는 인덱스에 홈에서의 글별 권한 관리나 분류별 보기, 타임라인에서의 인기도순 정렬이나 유사 글 병함 등과 같은 기능을 추가하고자 한다면 다중 B+ 트리를 쓰거나 복합 키를 써야 할 것이다.

콘텐츠 데이터에 대응하는 홈, 타임라인의 인덱스(수평축: 사용자, 수직축: 시간)

 

위 그림에서 B는 A와 C를 친구로 두고 있다. B의 타임라인 인덱스에서는 친구들의 최신 글이 시간순으로 저장된다. 위의 그림에서는 시간이 모두 같지만 ID 오름차순으로 우연히 저장되었다고 가정한다. 또 살펴보면 타임라인은 사용자별로 자신의 글을 포함한 친구들의 글을 저장할 수 있는 수가 제한되는데, 통상 이러한 제약 조건은 Facebook, 트위터에서도 공통으로 존재한다. 원래 기능의 목적 자체가 친구들의 최신 소식을 모아 보여주기 위함이기도 하고, 내부적으로는 게시글의 ID가 친구수만큼 복제되기 때문에 저장소의 용량에 대한 압박, 즉 공간 복잡도가 크기 때문이다. 만약, 타임라인의 인덱스에 기간이나 갯수를 제한하지 않는다면 '홈 인덱스 크기 x 평균 친구의 수'만큼의 인덱스 저장 용량이 필요하게 된다. 홈 인덱스는 타임라인과 달리 단순하게 전체 데이터에 일대일로 대응해야 한다. 그렇지 않을 경우 사용자가 자신의 과거 데이터를 볼수 없기 때문이다.

 

혹자는 다양한 형태로 보기 위해 다중 B+ 트리의 사용을 앞에서 전제했다면 홈과 타임라인을 굳이 나눌 필요가 있는가 반문할 수 있다. 게다가 B+ 트리의 리프(leaf)에 데이터를 저장하면 될것을 굳이 왜 데이터 저장소를 딕셔너리로 따로 모델링했는지도 의아할 것이다. 이미 이러한 모델을 완벽하게 지원하는 도구도 알수 있을수도 있다. 바로 기존의 관계형 데이터베이스다. 이렇게 관계형 데이터베이스로 손쉽게 구현할 수 있는 모델을 굳이 번거롭게 따로따로 설명한 이유는 앞서 언급했듯이 각 인덱스가 처리해야 하는 요청의 부하가 극단적으로 다르고, 또한 트래픽과 데이터의 증가에 따라 각 인덱스와 데이터 저장소의 성능을 독립적으로 확장할 수 있는 아키텍처를 설계해야 하기 때문이다.

 

이제 각 기능이 어떤 부하를 처리하는지, 그리고 그 부하에 어떤 아키텍처와 기술이 적합한지 살펴본다.

홈을 지탱하는 아키텍처와 기술들

먼저 홈의 주요 부하 특성을 살펴본다. 홈에서는 내 홈에 방문한 친구들에게 글 목록을 제공할 때 글별 조회 권한과 분류별 묶음 등의 복잡한 필터링을 수행한다. 그러므로 복잡한 질의를 실시간으로 처리해야 하는 특성이 있다. 현재 홈에서는 그룹 단위로 게시글의 조회 권한을 설ㅈ어할 수 있기 때문에, 친구 계정을 가족, 동아리, 팀 등의 특정 그룹에 지정한 후, 게시글에 해당 그룹의 조회 권한을 설정해 그 그룹에 속한 사람들만 볼 수 있는 글을 작성할 수 있다. 또한, 글을 게시한 후에도 그룹 내 사용자 추가 및 제거로 조회 권한을 관리할 수 있다. 물론, 아무런 조회 권한을 설정하지 않은 경우에는 내 모든 LINE 친구들이 내 홈 게시글을 볼 수 있다. 다시 말해 친구가 내 홈에 방문하는 시점에 내가 친구를 어떤 구르부에 지정했고 각 게시글의 조회 권한이 어떻게 부여되는 있는지에 따라 목록이 다르게 보일 수 있다.

 

조회 권한 처리는 복잡해 보이는 질의지만 비트 비교연산으로 간단하게 구현할 수 있다. 예를 들어, Long형 정수는 64개의 비트로 구성된다. 각 비트를 하나의 플래그로 사용하면 최대 64개의 그룹에 대해서 해당 글이 열감 가능한지 표시할 수 있다. 즉, 친구가 내 홈에 방문할때 친구가 소속된 그룹 정보를 구한 후 목록을 구성하는 질의에 비트 비교 연산의 인자로 전달해, 전체가 볼 수 있거나 친구가 속한 그룹이 볼수 있는 글의 목록을 구할 수 있다.

// flags
2^0 = Family, 2^1 = School, 2^2 = Tennis, 2^3 = Work, ...

// 전체(no flags)가 볼 수 있는 글 조회
select * from post where flags = 0

// 학교, 회사 그룹이 볼 수 있고, 전체(no flags)가 볼 수 잇는 글 조회
select * from post where (flags & (2^1 + 2^3)) or flgas = 0

 

위에서 설명한 복잡한 질의를 실시간으로 처리하며 성능 확장이 가능하고 영속 가능한 분산 저장소는 어떤 것이 있을까? 분산 저장소는 아니지만 친숙한 MySQL을 사용할 수 있다. 하지만 Facebook이나 트위터가 아직도 MySQL을 거대한 규모로 샤딩(sharding)해 사용하는 것을 보면, 여전히 쓸만한 선택이긴 한다.

 

MySQL에서 flags (bigint) 필드만 가지고 비트 비교 연산 쿼리를 하는 경우 인덱스를 활용할 수 없으므로 그리 효율적이지는 않다. 내부적으로는 특정 범위 내 한정된 갯수를 정해진 순서로 비교해 그 값이 참인 경우 결과를 반환하는 수밖에 없다. 그러므로 범위가 큰 데이터를 대상으로 비트 비교 연산 쿼리를 수행하기 보다는, 정해진 사용자의 데이터 내, 특정한 필드 순서로, 제한된 갯수만큼을 선택한다면 빠르게 결과를 얻을 수 있다. 물론, 찾고자 하는 조건이 데이터 세트의 처음과 마지막에 흩어져 있는 경우에는 해당 범위 내 전체 데이터를 모두 비교하게 되긴 할 것이다. 즉, 최악의 경우 O(n)이기 때문에 적절한 범위 제한을 통해 n을 줄이는 수밖에 없다. 또한, 비트의 수가 한정되어 있기 때문에 표현할 수 있는 속성의 플래그 수가 제한되어 있다. 이러한 제한은 여러 bingint (long) 컬럼을 사용해 우회할 수 있다.

 

위의 제약 사항 때문에 MySQL만으로는 빠른 응답 속도를 보장할 수 없으므로 캐시를 도입해야 한다. 하지만 MySQL이 제공하는 비트 비교 연산과 같은 연산을 제공하는 메모리 기반의 캐시는 현재 없다. 심지어 매우 다양한 자료구조를 제공하는 Redis에도 이러한 기능은 없다. 네이버에서 개발한 memcached 클러스터인 Arcus에서는 memcached와 달리 서버 측 자료구조를 지원하며, B+ 트리 항목별로 최대 32바이트, 즉 256비트의 플래그에 대해 MySQL과 같은 비트 비교 연산 기능을 제공한다. 또한 Consistent Hashing 구조로 노드 추가시 이웃 노드의 영역을 나누어 가지는 방식으로 유연한 성능 확장이 가능하며, Arcus가 제공하는 클라이언트 라이브러리를 사용하여 Arcus의 Zookeeper에만 연결하면 전체 클러스터 내 노드를 손쉽게 사용할 수 있다.

 

MySQL에는 사용자의 홈 목록 인덱스만 저장하고 있어, 급격히 용량을 늘릴 필요는 없겠지만, 언젠가는 증설해야 할것이므로 샤딩을 해야 한다. 당연히 사용자 아이디 기준으로 홈 목록 인덱스를 취득하기 때문에 사용자 아이디 기반으로 샤딩해야 한다. 현재 홈의 경우 데이터 핫스폿을 고려해, 사용자가 어떤 샤드에 저장되어 있는지 샤드 정보 데이터베이스에서 관리하는 아키텍처로 개발되었다. 어떤 샤드로 들어갈지는 최초 홈에 접근시 아이디에 의해 결정되지만, 결정된 이 정보가 저장된 후부터는 샤드 정보 데이터베이스에 따라 샤드가 지정된다. 즉, 유명한 사용자들이 특정한 샤드에 몰려 핫스폿이 발생하면 언제라도 옮길수 있는 구조다. 다만, 이러한 샤딩 방식은 강력한 유연성을 제공하나 샤드 정보 데이터베이스의 관리 비용을 수반한다.

타임라인을 지탱하는 아키텍처와 기술들

다음으로 타임라인의 주요 부하 특성을 살핀다. 타임라인에서는 내가 글을 게시하면 나와 친구들의 타임라인 인덱스에 내 글의 ID를 배달하는 Push 아키텍처를 사용하기에 대량의 갱신을 빠르게 처리해야 한다는 특성이 있다. Push 아키텍처 방식은 글을 작성한 사용자의 친구 수만큼 글의 ID를 친구들에게 배달해야 하므로 글을 게시하는 시점에 연산이 많이 필요하고, 사용자별로 친구들의 게시글 ID를 중복해서 저장하기 때문에 인덱스의 크기가 커진다는 문제가 있다. 하지만 Push 아키텍처의 장점은 목록을 조회하는 시점에 많은 연산이 필요없어 요청을 빠르게 처리할 수 있다는 것이다. 특히, 쓰기 요청보다는 읽기 요청이 압도적으로 많으므로 목록을 구성하는 시점에 많은 연산을 해야하는 Pull 방식보다는 더욱 직관적인 선택일 수 있다.

 

이렇게 쓰기 부하가 큰 특성에 가장 적합한 NoSQL을 꼽으라면 Cassandra일 것이다. Cassandra는 쓰기 속도가 빠를 뿐 아니라 적절한 데이터 일관성 수준을 사용하여, 클러스터 내 노드가 죽어도 서비스에는 영향을 주지 않고 노드 추가와 같은 운영이 쉬운 점도 장점이다. 물론, 압축만 풀고 간단하게 설정한 후 기본 데몬 하나만 구동하는 것으로 모든 준비가 끝나는 것은 덤이다. 처음에는 튜닝 포인트가 많이 보이지 않지만, 시간이 지나면서 조금씩 소소한 튜닝이 들어가야 하기 때문이다.

 

타임라인에서 사용하는 Cassandra 내 자료구조는 매우 단순하다. 사용자 아이디를 Row Key로 저장하고, 칼럼에 TTL(Time To Live)을 설정해 게시글의 ID를 삽입해 사용한다. TTL을 설정하는 이유는 적절한 제한을 두지 않을 경우 너무나 큰 저장 공간을 사용하기 때문이다.

 

라인의 Cassandra 복제본 수는 3이며, Read:QUORUM, Write:QUORUM으로 강한 데이터 일관성을 보장하는 설정을 사용하고 있다. Read:QUORUM은 읽을때마다 매번 정족수, 즉, 여기서는 두 복제본(replica)를 읽으라는 의미로, 데이터 일관성을 얻은 대신에 읽기 성능을 조금 포기하는 설정이다. Cassandra에서는 읽기와 쓰기 데이터 일관성 수준의 노드 합이 복제본 수보다 큰 경우(Read + Write > Replication Factor) 데이터 일관성을 보장한다. 이 특성을 이용해 Read:ONE, Write:ALL로 설정하면 데이터의 일관성을 유지하면서도 빠르게 데이터를 읽을 수 있다. 하지만 하나의 노드라도 장애가 발생하면 읽기는 정상적으로 동작해도 쓰기는 일관성 수준에 맞출수 없으므로 오류가 발생한다. Read:QUORUM, Write:QUORUM에서는 세 개의 노드 중 하나의 노드가 내려가도 서비스에는 아무런 영향이 없다. 즉, 약간의 읽기 성능을 포기하고 고가용성과 일관성을 구하는 설정을 사용하고 있다.

 

위와 같이 읽기 성능에 유리하지 않게 설정된 Cassandra만으로는 사용자들에게 빠르게 타임라인 목록 인덱스르 제공하기 어렵다. 캐시 적용이 필요하다. 이번에도 마찬가지로 Arcus의 B+트리 자료구조를 사용할 수 있으며, 추가로 Arcus는 전체 목록의 갯수를 제한하는 기능(capped b+ tree)을 제공한다.

 

타임라인에서 가장 도전적인 문제는 친구가 천만명 이상인 공식 홈 계정이 쓰는 글을 처리하는 것이다. 타임라인에 작성한 글의 ID를 배달하는 요청은 WAS가 받아서 메시지 큐 서버로 요청하고 비동기 서버의 컨슈머가 받아서 처리한다. 사실, 친구가 천만명인 경우도 친구가 한명일 경우와 같은 흐름으로 배달되지만, 속도를 높이기 위해서 메시지큐 서버에서 Fanout을 통해 여러 비동기 서버내 컨슈머가 데이터를 받을 수 있도록 요청한다. 또한 이때 일반 개인의 타임라인 글 전송에 미치는 영향을 최소화하기 위해 독립적인 메시지큐 서버와 별도의 비동기 컨슈머를 설정해 사용한다. 실제 천만 명 정도에게 배달되는 데 걸리는 시간을 5분 내외다.

홈, 타임라인 공통 컨텐츠 저장소

이제는 공통 컨텐츠 데이터를 어떻게 저장하고 서비스해야 할지 살펴본다. 홈과 타임라인의 공통 저장소가 받는 부하 특성을 쿼리로 표현하면 'select * from data where id in (...)'과 같다. 다만, 해당 쿼리는 단일한 데이터베이스를 대상으로 요청하는 것이 아니라 해당 ID의 콘텐츠 데이터가 있는 여러 노드로부터 가져올 수 있어야 한다. 홈, 타임라인의 목록 인덱스를 통해 보여줄 10개의 ID를 뽑아냈다면, 이제 해당 ID의 콘텐츠를 저장소로부터 실제로 가져와 목록을 구성해야 한다.

 

이와 같은 부하에 적합한 NoSQL은 HBase라고 많이들 생각할 것이다. 하지만 LINE은 Cassandra를 선택했다. 그 이유는 성능 확장과 운영의 용이성 때문이다. 소수의 인원이 모든 서비스를 만들고 운영하고 있기 때문에 인프라 튜닝과 운영에 많은 공을 들일수 없었다. 또한, 성능 테스트를 본격적으로 하기 전에는 Cassandra가 HBase에 비해 상대적으로 열위라고 생각했으나, 실제 'select * from data where id in (...)' 테스트 결과에서는 HBase와 비등하거나 더 나은 성능을 보여주었다고 한다.

 

Cassandra를 홈과 타임라인 공통 컨텐츠 저장소로 쓴다고 해도 여전히 자주 접근하는 데이터는 Cassandra에서 직접 가져오기 보다는 캐시에서 가져오는 편이 낫다. 공통 컨텐츠 저장소에도 Arcus 캐시로 자주 사용되는 컨텐츠를 더욱 빠르게 서비스하는 것이 서비스 반응 속도를 빠르게 하는데 유리하다. 콘텐츠는 크기가 크기 때문에 해당 데이터를 캐시와 저장소에 저장할때는 protostuff로 데이터를 직렬화하고 LZ4로 압축해 사용한다.

관계 서버

홈에서 사용한 방문 친구 그룹 정보와 타임라인에서 사용한 친구 관계 정보는 어디서 나온 것일까? 또, 공심홈의 친구 천만 명의 목록도 누군가 블록 단위로 비동기 컨슈머에 제공할 수 있어야 병렬로 배달이 가능할 것인데, 이는 어디서 제공한 것일까? 이러한 관계를 비정규화하여 Redis에 저장해서 제공하는 관계 서비스가 내부에 존재한다. 이 관계 서비스는 Redis 클러스터를 중심으로 구현되어 있으며, Redis 스냅샷의 백업으로 HBase를 사용하고 있다.

마치며

서비스 내부적으로 더 많은 기술적인 내용이 있지만, 실제 큰 아키텍처는 처음에 설명한 데이터와 그 데이터의 사용 패턴에 맞도록 모델링하고 그 모델에 적합한 저장소를 어떻게 사용하느냐에 따라 그 모습이 결정된다. 홈, 타임라인의 전체 저장소를 나타내면 다음과 같다. 홈 목록 인덱스(Arcus/Sharded MySQL), 타임라인 목록 인덱스 (Arcus/Cassandra), 콘텐츠 저장소(Arcus/Cassandra), 관계 서버(Redis/HBase)가 모여 LINE의 소셜 네트워크 서비스를 지탱하고 있다.

거대한 양의 데이터를 영속적으로 저장하고 성능 확장이 가능한 저장소와 상대적으로 적은 데이터를 휘발적으로 저장하지만 빠르게 제공할 수 있는 쌍이 결국 이 거대한 SNS를 지탱하고 있는 것이다. 이러한 거대 규모의 서비스 아키텍처 설계와 개발 외에도 Hive/Hadoop을 이용한 배치 분석과 Kafka와 Elastic Search를 이용한 실시간 분석과 검색, 그리고 분산 그래프 데이터베이스 Titan을 이용한 소셜 게임 추천 등 매우 다양한 최신 기술들을 적극적으로 서비스에 적용하여 발전시키고 있다.