
SNS나 플랫폼 서비스의 애플리케이션은 반드시 증가하는 트래픽에 효과적으로 대처할 수 있도록 설계되어야 한다. 최근, 세계적으로 신규 런칭 서비스의 유저 1억 명 달성 기간 주기가 점점 짧아지고 있다. 바이트댄스사의 'Tiktok'은 서비스 런칭후 가입자 1억 명 달성까지 9개월, 오픈AI사 ‘ChatGPT’는 2개월, 메타(전 페이스북)사 ’Threads’는 불과 5일이 소요되었다.
이전 포스팅에서 언급했듯이, SNS나 플랫폼 서비스에서 유저들은 니즈의 수요자, 공급자로서 참여하고 서로 수혜를 주고 받게 되며, 유저 수가 많아질 수록 외부 유저의 유입 메리트 또한 커지는 선순환 구조(네트워크 효과)를 갖는다. 이는 반대로 유저의 이탈이 그만큼 서비스에 치명적일 수 있음을 의미하기도 한다.
트래픽이 증가해 부하가 커지면 지연시간이 늘어나거나 시스템에 장애가 생겨 서비스가 중단될 수 있다. 이는 유저 이탈을 유발하기 때문에 부하 증가에 효과적으로 대처할 수 있는 애플리케이션의 '확장성'이 필수이다.
이번 포스팅에서는 이와 관련하여:
- 트래픽과 부하가 증가하는 상황에서 성능을 유지하는 방법
- 결함내성을 위한 분산 데이터 시스템과 제약조건, 트레이드 오프
- 애플리케이션에 적합한 데이터 저장소 선택의 기준들
등 확장성 있는 애플리케이션을 위한 고민, 설계 및 구현 과정을 정리해보고자 한다.
계속하여 늘어나는 트래픽에 대처하고 성능을 개선하기 위해서는 서버를 확장해야 한다. 서버 확장에는 어떤 방법들이 있을까?
서버의 확장의 방법에는 스케일업과 스케일아웃이 있다.
- 스케일업: 수직 확장이라고도 하며, 단일 서버(하드웨어)의 성능을 증가시켜서 더 많은 요청을 처리하는 방법이다. 단일 하드웨어의 성능을 높이기 위해 CPU, 메모리, 하드디스크를 업그레이드 하거나 추가하는 것이다.
- 스케일아웃: 수평 확장이라고도 하며, 동일 사양의 새로운 서버(하드웨어)를 추가하는 방법이다.
스케일업과 스케일아웃에는 각각의 트레이드오프가 존재한다. 이를 정리하자면 다음과 같다.

다음을 근거로 스케일아웃이 타임라인 API에 더 적합하다고 판단하였다. 트위터의 경우 단일 요청에 대한 연산이 단순한 반면 개별 요청의 빈도가 높다는 특징을 갖고 있다. 이런 경우 스케일 아웃과 함께 로드밸런싱으로 부하를 분산할 수 있으므로 트래픽 증가에 효과적인 대처가 가능하다. 반면 스케일업 방식으로는 트위터가 요구하는 연산 특징상 효율적이지 않고, 비용효과적이지도 않으며, 물리적 확장에 한계가 있다. 또한 SNS 특성상 스케일업을 위한 서비스 중단은 감수하기에 너무나 큰 비용이다.
이런 상황에서는 한 노드에 연산 능력을 집중하는 것보다, 여러 노드에 데이터를 분산시키고 로드밸런싱을 통해 부하를 고르게 분산할 수 있는 스케일 아웃이 적합하다고 판단하였다.
여기에 더해 데이터의 가용성을 보장하기 위해서도 스케일 아웃이 반드시 필요하다.
스케일 아웃은 곧 여러 노드로의 데이터 분산을 의미한다. 데이터 분산에는 크게 복제와 파티셔닝이 있다.
복제는 동일한 데이터를 중복하여 다른 노드에 추가로 저장하는 방법이다. 복제는 크게 두 가지 이점이 있다. 첫 째로는 데이터를 여러 노드에 복제함으로써 데이터 접근 부하를 분산시켜 성능을 개선할 수 있고, 둘 째로 한 노드가 장애로 단절된다고 하여도 다른 노드에서 데이터에 접근할 수 있다. 즉, 가용성을 높인다.
보통 한 대 이상의 리더(마스터) 노드와 한 대 이상의 팔로워(또는 레플리카) 노드를 두며, 주로 쓰기 요청은 리더 노드에서, 읽기 요청은 팔로워 노드에서 처리한다. 리더 노드나 네트워크에 문제가 생겨 리더가 단절될 시 그 외의 정족수(quorum) 노드의 동의하에 팔로워 노드를 리더로 승격시켜 쓰기 작업을 할 수 있도록 한다.
파티셔닝은 데이터를 여러 노드에 중복없이 작은 단위로 나누어 저장하는 방법이다. 파티셔닝은 쓰기 성능 개선에 기여한다. 각 노드는 더 작은 단위의 데이터를 갖게 되므로, 데이터 갱신시 색인 조정에 드는 부하도 줄어들게 된다. 또한 여러대의 노드에서 동시에 쓰기 작업을 할 수 있게 되므로 쓰기 부하를 분산할 수 있다. 후술할 레디스 같은 싱글 스레드 기반 데이터 저장소는 노드에 CPU가 다수 개 있어도 하나밖에 사용하지 않게 되므로, 파티셔닝으로 여러 노드를 사용하면 쓰기 성능에 추가 이점이 생긴다.
데이터를 분산하는 방법에는 대표적으로 키를 정렬된 순서로 나누어 저장하고 범위 스캔을 사용하는 키 범위 기준 파티셔닝과, 키의 해시값을 무작위로 나누는 키 해시값 기준 파티셔닝(일관성 해싱(consistent hashing)이라고도 한다)이 있다. 전자의 경우 범위 질의를 효율적으로 실행할 수 있고, 후자의 경우 트래픽 쏠림과 핫스팟을 방지할 수 있으며, 이러한 각 특징이 서로의 트레이드오프가 된다.
분산 시스템에서는 노드간의 단절로 인해 여러 제약사항이 따르게 된다. 분산 환경은 네트워크를 통해 여러 대의 노드가 서로 연결되어 구축된다. 불행히 네트워크의 장애와 지연은 일상적이며, 또한 개별 노드에 문제가 생겨 해당 서버가 중단되거나 지연이 발생할 수도 있다. 이로 인해 개발자는 서비스의 요구사항에 따라 다음중 선택과 포기를 하게 된다.
- 일관성(consistency): 모든 시스템은 특정 순간 항상 동일한(일관된) 데이터를 갖는다.
- 가용성/내결함성(availability/fault-tolerance): 시스템에 대한 모든 요청에 항상 응답을 반환할 수 있다.
- 분단 내성(partition-tolerance): 네트워크 분단(network partition) 등 어떤 상황에서도 시스템은 동작할 수 있다.
분산 시스템에서 네트워크가 올바르게 동작할 때는 시스템이 일관성(선형성)과 완전한 가용성 모두를 제공할 수 있다. 하지만 네트워크 장애는 일종의 결함이고 상시적이며 선택할 수 있는 것이 아니므로, 네트워크 분단이 생겼을 때 일관성과 가용성 중 하나를 선택할 수 밖에 없다(원래 CAP 정리는 일관성과 가용성, 분단 내성 셋 중 두 가지를 고르라'는 내용이지만, 현실에서 네트워크 분단은 선택 사항이 아니다).
선형성(linearization)을 구현하는 애플리케이션에서는 쓰기 작업이 모든 리더와 팔로워 노드에 동기화 되어 특정 순간 항상 동일한 데이터를 가지며 일관성을 보장한다. 애플리케이션은 모든 요청에 대한 일관성 있는 응답을 보장하거나, 불가시 아예 반환하지 않는다(장애가 복구될 때 까지 기다리거나 오류 반환). 이 때,
반면, 가용성을 지원하는 시스템에서는 모든 요청에 대한 응답을 반드시 보장한다. 네트워크 분단 상황에서 일관성이 아닌 가용성을 택할시 트레이드 오프가 발생하는데, 데이터가 최신이 아닐 수도 있게 된다. 즉, 데이터의 일관성을 포기한다. 다만 시간이 흐르면서 장애가 복구되거나 하여 분산 노드간 데이터는 최종적으로 일관성을 갖게 되는데 이를 최종적 일관성(eventual consistency)라고 한다.
현실의 분산 시스템에서는 성능과 결함에 대한 취약성 이슈로 선형성을 구현하는 애플리케이션은 드물며, 대부분의 분산 시스템이 가용성을 택한다. 가용성을 제공하는 분산 시스템의 데이터와 관련한 특징은 흔히 'BASE'로 정리되기도 한다.
BASE의 반대 개념으로 'ACID'를 꼽기도 하는데, 이는 다음 포스팅의 '분산 트랜잭션' 주제에 관해 다룰 때 함께 정리하고자 한다.
유저들 모두의 타임라인이 반드시 '동시에' 동일해야 할까? 이같은 높은 일관성을 요구하는 서비스의 대표적 예로는 계좌 이체 서비스가 있다. 계좌 이체 서비스에는 아주 잠깐이라도 계좌의 잔고가 다르게 조회될시 치명적인 문제를 야기할 수 있다.
반면 타임라인의 경우, 수 초에서 수 분동안 서로 다르게 보이더라도 유저의 경험에 큰 해악이 없다. 즉 높은 일관성이 요구되지 않는다. 다만 그 동일하지 않은 시간이 너무 길어지지 않도록 일정 시간이 흐르면 결과적으로 모든 유저가 일관성 있는 데이터를 접하게 되는 결과적 일관성으로 타협이 가능하다.
일관성을 포기함으로써 낮은 지연시간으로 응답할 수 있는 성능이 얻을 수 있다. 더하여 트위터는 글로벌 서비스이므로, 세계 각 지역에 데이터 센터를 구축해 관리하는 것이 지연시간 단축에 유리할 수 있다.
또한 장비의 결함이나 네트워크의 장애는 일상적으로 일어나는 통제 범위 밖의 영역이다. 전술했듯 가용성은 트위터에서 가장 민감한 이슈이다. 장애에 빠르게 대처가 불가하다면 이는 곧 사용자 이탈로 이어질 수 있기 때문이다. 일관성을 포기함으로써 어떤 상황에서라도 요청에 대해 응답할 수 있는 가용성을 확보하는 것이 중요하다.
💡 데이터 저장소의 선택은 곧 정보를 어떻게 구성하고 접근하며 분산(확장)할 것인지를 결정한다. 이는 애플리케이션의 성능과 구현 복잡도, 유지보수에까지 광범위한 영향을 미친다. 그렇다면 데이터 저장소의 선택 기준에는 어떤 것들이 있으며, 어떻게 애플리케이션에 맞는 데이터 저장소를 고를 수 있을까?
데이터 모델 관점에서 저장소의 발전 과정(맥락)에 대해 알아보고, 다양한 데이터 모델 저장소들의 특징을 비교해 봄으로써 애플리케이션의 성능과 확장성 요구사항에 맞는 합리적인 결정을 내릴 수 있다.
역사 초기의 저장소들은 트리 구조로 데이터의 일대다 관계를 표현했지만 다대다 관계라는 한계에 부딪혀 지원하지 않거나, 지원하더라도 개발자가 수동으로 관리해야 하는 등 유지에 큰 비용이 발생하였다. 이후 관계형 모델을 통해 다대다 관계를 효율적으로 표현하고 질의 최적화기로 관리를 추상화하면서 편의와 접근성을 끌어올렸다. 최근엔 대용량, 비정형 데이터에 대한 새로운 니즈가 대두되면서 확장성을 강조한 비관계형 저장소가 새롭게 떠오르게 되었다.
주요 데이터 모델, 저장소의 발전과정은 아래와 같다.
계층 모델: 1960년대 IBM의 정보 관리 시스템(IMS)의 ‘계층 모델’은 모든 데이터를 레코드 내에 중첩된 레코드 트리로 표현하였다. 이는 일대다 관계에서는 잘 동작했지만 다대다 관계 표현은 어려웠고, 조인 또한 지원하지 않았다. 개발자는 (비정규화된)데이터를 중복할지, 다른 레코드의 참조를 수동으로 해결할지 결정해야 했다(현재 문서 데이터 저장소의 문제와 매우 유사).네트워크 모델: 네트워크 모델은 계층 모델을 일반화했으며, 계층 모델의 트리 구조에서 모든 레코드가 정확히 하나의 부모를 가졌던 것과 다르게 다중 부모를 허용하여 다대다 관계를 모델링할 수 있었다. 하지만 이 방식에서 레코드에 접근하는 유일한 방법은 최상위 레코드(root record)에서부터 연속된 연결 경로(접근 경로)를 따르는 것이었다. 다양한 다른 경로가 같은 레코드로 이어질 수 있기 때문에, 개발자는 이러한 접근 경로를 계속 추적하고 변경시 재작성 해야 했으며, 애플리케이션의 데이터 모델을 바꾸는 일이 매우 어려웠다.관계형 모델: 관계(테이블)는 단순히 튜플(로우)의 컬렉션이 전부이고, 새로운 방식으로 데이터에 질의하고 싶으면 색인을 선언하기만 하면 된다. 관계형 데이터 저장소의 큰 차이점은 질의 최적화기(query optimizer)인데, 질의의 순서와 사용할 색인을 자동으로 결정해주며, 따라서 “접근 경로”를 따로 생각할 필요가 없다.문서 모델: 문서 데이터 모델에서는 데이터가 문서 자체에 포함돼 있으며, 하나의 문서와 다른 문서 간 관계가 거의 없는 사용 사례를 대상으로 한다. 문서 데이터 저장소는 한 가지 측면에서 계층 모델로 회귀하였는데, 다대다 관계 표현시 별도 테이블이 아닌 상위 레코드 내 중첩된 레코드를 저장한다는 점이 그 것이다. 또한 관계형 데이터 저장소와 문서 데이터 저장소는 다대일과 다대다 관계를 표현할 때 본질적으로 다르지 않은데, 관련 항목을 고유 식별자(전자의 경우 ‘외래 키’, 후자의 경우 ‘문서 참조’)로 참조하고 이를 조인이나 후속 질의를 통해 읽기 시점에 확인한다는 점에서 그러하다.그래프 모델: 그래프형 모델은 다대다 관계가 매우 일반적인 경우에 사용하며, 모든 것이 잠재적으로 관계가 있다는 사례를 주요 대상으로 한다는 점에서 문서 데이터 저장소와 정반대이다. 그래프는 정점(vertex)와 간선(edge)의 두 객체로 이뤄지며, 간선에 의해 모든 유형(관계형 모델에서의 테이블 또는 관계)의 정점이 연결될 수 있다. 이는 완전히 다른 유형의 객체를 일관성 있게 저장할 수 있는 강력한 방법을 제공한다. 키 하나로 데이터 하나를 저장하고 조회할 수 있는 단일 키-값 구조를 가진다. 대부분의 키-값 모델 저장소는 단순한 저장구조로 인해 복잡한 조회 연산을 지원하지 않으며, 고속 읽기 및 쓰기에 최적화된 경우가 많다. 비교적 API가 단순하여 쉬운 접근성을 제공한다. 대부분 저장된 데이터에 대한 검증이나 데이터 내용에 기반한 조회를 지원하지 않으며, 저장 값을 의미 없는 바이너리 데이터로 처리한다.
주의할 점: 한 사용자의 구매 목록이 많을 때는 더 많은 연산이 필요하게 되어 NoSQL의 장점을 살리지 못하게 된다. 이 같은 요구사항을 처리하기 위해 키-값 모델 NoSQL을 사용해 반환을 적용한다면 시말서를 쓸지도 모른다. 이럴 땐 다른 모델의 NoSQL을 선정하는 것이 타당하다.
지정하는 스키마가 없이 사용된다.
하나의 키(로우키+컬럼키 조합)에 여러 개의 컬럼 이름과 컬럼 값의 쌍을 데이터로 저장하고 조회하며, 저장과 조회의 기본 단위는 컬럼이다. 컬럼 모델 데이터 저장소는 대부분 구글 빅테이블의 영향을 받아 로우키, 컬럼키, 컬럼 패밀리, 테이블(로우의 집합/키 스페이스) 같은 개념을 공통적으로 사용한다.
관계형 모델과 유사하지만 관계형 데이터 저장소는 물리적으로 데이터를 저장할 때 컬럼의 집합인 레코드를 기준으로 데이터 파일을 생성하지만, 컬럼 모델 데이터 저장소는 컬럼 패밀리를 기준으로 데이터 파일을 생성한다. 따라서 하나의 로우에 저장된 모든 컬럼을 조회하는 것보다 로우키에 해당하는 컬럼 패밀리를 조회하는 것이 더 빠르며, 하나의 로우에 많은 컬럼 패밀리를 지정하면 응답시간이 늘어나게 된다.
또 다른 차이점은 컬림의 타임스탬프(버전)인데, 컬럼 모델에서 모든 컬럼은 항상 타임스탬프값과 함께 저장되며, 동일한 로우키와 컬럼키를 가진 필드를 추가하고 유지할 수 있다. 이미 저장되어 있는 컬럼과 동일한 로우키와 컬럼키를 가진 필드를 추가하면 최근의 타임스탬프를 가진 새로운 컬럼이 추가되어 동일한 이름을 가진 컬럼에 두 개의 값이 유지된다.
컬럼 모델은 로우키의 사전식 정렬 저장을 기본 전제로 하며, 범위 질의가 가능하다. 마찬가지로 컬럼도 저장시 동일한 컬럼 패밀리 내에서 컬럼 이름(컬럼키) 순으로 저장한다.
스키마 레벨에서 저장 가능한 필드 개수가 고정인 관계형 데이터 저장소와 달리, 컬럼 모델 데이터 저장소의 스키마는 테이블의 이름과 컬럼 패밀리의 이름을 지정하는 스키마를 생성하며(느슨한 스키마 제공), 로우키 하나에 저장 가능한 컬럼 개수 제한이 없다.
이번엔 데이터를 저장하는 주요 방법과 데이터를 다시 찾을 수 있는 방법들에 대해 알아보겠다. 크게 SS테이블과 LSM 트리 기반의 로그 구조(log-structured) 계열 저장소 엔진과 B-tree 기반의 페이지 지향(page-oriented) 계열 저장소 엔진이 있다.
페이지 지향 계열 저장소는 색인을 위해 대표적으로 B-tree를 기반으로 한다. B-tree는 가장 널리 사용되는 색인 구조이다. 대부분의 관계형 데이터베이스에서 표준 색인 구현으로 사용하며, 많은 비관계형 데이터베이스에서도 사용한다.
전통적으로 4KB의 고정 크기 블록이나 페이지로 나누고 한 번에 하나의 페이지에 읽기 또는 쓰기를 한다. 디스크가 고정 크기 블록으로 배열되기 때문에 이런 설계는 근본적으로 하드웨어와 조금 더 밀접한 관련이 있다. 각 페이지는 주소나 위치를 이용해 식별할 수 있다. 이 방식으로 하나의 페이지가 다른 페이지를 참조할 수 있다(포인터와 비슷하지만 메모리 대신 디스크에 있음).
한 페이지는 B-tree의 루트(root)로 지정된다. 색인에서 키를 찾으려면 루트에서 시작한다. 페이지는 여러 키와 하위 페이지의 참조를 포함한다. 각 하위 페이지는 키가 계속 이어지는 범위를 담당하고 참조 사이의 키는 해당 범위 경계가 어디인지 나타낸다.
최종적으로는 개별 키(리프 페이지(leaf page))를 포함하는 페이지에 도달한다. 이 페이지는 각 키의 값을 포함하거나 값을 찾을 수 있는 페이지의 참조를 포함한다.
B 트리의 한 페이지에서 하위 페이지를 참조하는 수를 분기 계소(branching factor)라고 부른다. 실제로 분기 계수는 페이지 참조와 범위 경계를 저장할 공간의 양에 의존적인데, 보통 수백 개에 달한다.
B 트리에 존재하는 키의 값을 갱신하려면 키를 포함하고 있는 리프 페이지를 검색하고 페이지의 값을 바꾼 다음 페이지를 디스크에 다시 기록한다(페이지에 대한 모든 참조는 계속 유효하다). 새로운 키를 추가하려면 새로운 키를 포함하는 범위의 페이지를 찾아 해당 페이지에 키와 값을 추가한다. 새로운 키를 수용한 페이지에 충분한 여유 공간이 없다면 페이지 하나를 반쯤 채워진 페이지 둘로 나누고 상위 페이지가 새로운 키 범위의 하위 부분들을 알 수 있게 갱신한다.
이 알고리즘은 트리가 계속 균형을 유지하는 것을 보장한다. n개의 키를 가진 B 트리는 깊이가 항상 O(log n)이다. 대부분의 데이터베이스는 B 트리의 깊이가 3이나 4단계 정도면 충분하므로 검색하려는 페이지를 찾기 위해 많은 페이지 참조를 따라가지 않아도 된다(분기 계수 500의 4KB 페이지의 4 단계는 256TB까지 저장할 수 있다(500^4 * 4096B = 256TB)).
로그 구조 계열 저장소는 색인을 위해 주로 SS테이블을 활용한 LSM 트리를 기반으로 한다.
SS테이블은 키-값 쌍의 연속으로 이루어져 있다. 이 쌍은 쓰여진 순서대로 나타난다. 같은 키를 갖는 값 중 나중의 값이 이전 값보다 우선한다. 일련의 키-값 쌍을 키로 정렬한다. 키-값 쌍의 연속이며 키로 정렬된 형식.
로그 구조화 병합 트리(log-structured merge-tree)(또는 LSM 트리)는 로그 구조화 파일 시스템의 초기 작업의 기반이 됐으며, 이렇게 정렬된 파일 병합과 컴팩션 원리를 기반으로 하는 저장소 엔진을 LSM 저장소 엔진이라 부른다. 쓰기가 들어오면 인메모리 균형 트리(balanced tree 또는 멤테이블(memtable)) 자료구조에 추가한다. 멤테이블이 보통 수 메가바이트 정도의 임곗값보다 커지면 SS테이블 파일로 디스크에 기록한다. 읽기 요청은 최신에서 오래된 순의 세그먼트에서 찾는다. 백그라운드에서 병합과 컴팩션 과정을 수행한다.
LSM 트리는 백그라운드에서 연쇄적으로 SS테이블을 지속적으로 병합한다. 데이터셋이 가능한 메모리보다 훨씬 더 크더라도 여전히 효과적이다. 데이터가 정렬된 순서로 저장돼있기 때문에 범위 질의를 효율적으로 실행할 수 있으며, 디스크의 쓰기는 순차적이기 때문에 매우 높은 쓰기 처리량을 보장할 수 있다.
B-트리가 LSM 트리보다 일반적으로 구현 성숙도가 더 높지만 LSM 트리도 그 성능 특성 때문에 관심을 받고 있다. 경험적으로 LSM 트리는 보통 쓰기에서 더 빠른 반면, B 트리는 읽기에서 더 빠르다고 여긴다. 읽기가 보통 LSM 트리에서 더 느린 이유는 각 컴팩션 단계에 있는 여러 가지 데이터 구조와 SS테이블을 확인해야 하기 때문이다.

*벤치마크는 보통 결정적이지 않고 작업부하의 세부 사항에 민감하다. 비교가 유효하려면 실제 필요한 작업부하로 시스템을 테스트해야 한다.
디스크는 메인 메모리와 비교해 다루기 어렵다. 자기 디스크와 SSD 사용시 읽기 및 쓰기의 좋은 성능을 위해서는 주의해 데이터를 디스크에 배치해야 한다. 그럼에도 디스크를 사용하는 이유는 지속성과 용량당 가격 때문이다.
하지만 램이 점점 저렴해졌고 데이터셋 대부분은 그리 크지 않아서, 메모리에 전체를 보관하는 방식이 현실적인 범위 안으로 들어왔다. 더하여 여러 장비 간 분산 보관도 가능하다. 이런 이유로 인메모리 데이터베이스가 개발됐다.
멤캐시드 같은 일부 인메모리 키-값 저장소는 데이터 손실을 허용하는 캐시 용도로만 사용된다. 하지만 다른 인메모리 데이터베이스는 지속성을 목표로 한다. 이 목표를 달성하는 방법에는
디스크는 전적으로 지속성을 위한 추가 전용 로그로 사용되고 읽기는 전적으로 메모리에서 제공된다.
레디스(Redis)와 카우치베이스(Couchbase)는 비동기로 디스크에 기록하기 때문에 약한 지속성을 제공한다.
직관에 어긋나지만 인메모리 데이터베이스의 성능 장점은 디스크에서 읽지 않아도 된다는 사실 때문은 아니다. 디스크 기반 저장소 엔진도 운영체제가 최근에 사용한 디스크 블록을 메모리에 캐시하기 때문에 충분한 메모리를 가진 경우 디스크에서 읽을 필요가 없기 때문이다. 그보단 인메모리 데이터 구조를 디스크에 기록하기 위한 형태로 부호화하는 오베헤드를 피할 수 있어 더 빠를 수도 있다.
성능 외에도 디스크 기반 색인으로 구현하기 어려운 데이터 모델을 제공한다. 예를 들어 레디스는 우선순위 큐와 셋(set) 같은 다양한 데이터 구조를 데이터베이스 같은 인터페이스로 제공한다. 또한 메모리에 모든 데이터를 유지하기 때문에 구현이 비교적 간단하다.
최근 연구에 따르면 인메모리 데이터베이스 아키텍처가 디스크 중심 아키텍처에서 발생하는 오버헤드 없이 가용한 메모리보다 더 큰 데이터셋을 지원하게끔 확장할 수 있다. 소위 안티 캐싱(anti-caching) 접근 방식은 메모리가 충분하지 않을 때 가장 최근에 사용하지 않은 데이터를 메모리에서 디스크로 내보내고 나중에 다시 접근할 때 메모리에 적재하는 방식으로 동작한다. 이것은 운영체제가 가상 메모리와 스왑 파일에서 수행하는 방식과 유사하지만 데이터베이스는 전체 메모리 페이지보다 개별 레코드 단위로 작업할 수 있기 때문에 OS보다 더 효율적으로 메모리를 관리할 수 있다. 하지만 이 접근 방식은 여전히 전체 색인이 메모리에 있어야 한다.
저장소를 선택함에 있어 관계형 저장소와 비관계형 저장소를 필요에 맞게 혼합해 사용하기로 하였다.
데이터 모델링에 있어 핵심 요소에 유저, 좋아요, 팔로우, 트윗 엔티티가 있고 이는 서로 일대다, 다대다의 다양한 관계를 맺으며, 관계형 저장소로 이러한 다양한 관계를 표현하기에 용이했기 때문에 관계형 저장소를 선택하였다.
더하여 타임라인 API의 Pull과 Push 방식중 Pull 방식의 구현을 위해 관계형 저장소를 사용하였다.
관계형 저장소로는 MySQL을 선택하였다. 오픈소스이고 트랜잭션과 확장성을 위한 JTA(Java Trnasactiona API)를 지원하며, 공식문서가 잘 정리되어 있고 많은 유저들에게 가장 광범위하게 사용되어 커뮤니티의 정보 접근에도 이점이 있다. 또한 MySQL 제공하는 기능들로 트위터의 핵심 API를 구현함에 무리가 없었다.

Push 방식의 타임라인 API 구현시 읽기 성능, 쓰기 성능 모두와 데이터를 쉽게 분산할 수 있는 능력 측면에서 비관계형 저장소를 고려해 보았다. 대표적 키-값 저장소로서 Redis와 Memcached, 문서 기반 저장소로서 MongoDB, 그리고 컬럼 기반 저장소로서 Cassandra를 검토하였다.
최종적으로 이러한 비교와 더불어 아래와 같은 이유로 레디스를 비관계형 저장소로서 선택하였다.

클러스터를 선택 한 이유는 레디스의 아키텍처 때문이다. 레디스는 싱글스레드 기반 이벤트루프를 사용하며, 그건 곧 서버의 CPU가 몇 개가 있다고 하더라도 하나만 사용한다는 것이다. 파티셔닝을 통해 더 많은 CPU 자원을 사용할 수 있으며 쓰기 속도를 높일 수 있다.
@Configuration
@EnableTransactionManagement
@PropertySource("classpath:/application.yml")
public class DataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "primary-mysql")
public DataSource primaryMySqlDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties(prefix = "replica-mysql")
public DataSource replicaMySqlDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource(
@Qualifier(value = "primaryMySqlDataSource") DataSource primaryMySqlDataSource,
@Qualifier(value = "replicaMySqlDataSource") DataSource replicaMySqlDataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("primary-mysql", primaryMySqlDataSource);
targetDataSources.put("replica-mysql", replicaMySqlDataSource);
AbstractRoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(primaryMySqlDataSource);
return routingDataSource;
}
@Bean
public DataSource lazyConnectionProxyDataSource(
@Qualifier(value = "routingDataSource") DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
@Bean
public PlatformTransactionManager transactionManager(
@Qualifier(value = "lazyConnectionProxyDataSource") DataSource lazyConnectionProxyDataSource) {
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
transactionManager.setDataSource(lazyConnectionProxyDataSource);
return transactionManager;
}
}
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean isCurrentTransactionReadOnly
= TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return isCurrentTransactionReadOnly
? DataSourceConstant.REPLICA_MYSQL
: DataSourceConstant.PRIMARY_MYSQL;
}
}
spring:
datasource:
primary-mysql:
url: jdbc:mysql://127.0.0.1/twitterlocal-primary
username: ...
password: ...
replica-mysql:
url: jdbc:mysql://127.0.0.1/twitterlocal-replica
username: ...
password: ...
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
...
@Value("${spring.redis.fan-out.host}")
private String redisFanOutHost;
@Value("${spring.redis.fan-out.port}")
private int redisFanOutPort;
@Value("${spring.redis.fan-out.password}")
private String redisFanOutPassword;
@Bean
public RedisConnectionFactory redisFanOutConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration
= new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(redisFanOutHost);
redisStandaloneConfiguration.setPort(redisFanOutPort);
redisStandaloneConfiguration.setPassword(redisFanOutPassword);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean
public RedisTemplate<String, Object> objectFanOutRedisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisFanOutConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
@Bean
public RedisTemplate<String, String> stringFanOutRedisTemplate(
RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisFanOutConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
...
}
spring:
redis:
...
fan-out:
host: ...
port: ...
password: ...

@Component
@ConfigurationProperties(prefix = "spring.redis.cluster")
public class RedisClusterProperties {
private List<String> nodes;
public List<String> getNodes() {
return nodes;
}
public void setNodes(List<String> nodes) {
this.nodes = nodes;
}
}
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
...
private final RedisClusterProperties clusterProperties;
@Bean
public RedisConnectionFactory redisFanOutClusterConnectionFactory() {
LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder()
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build();
RedisClusterConfiguration clusterConfiguration
= new RedisClusterConfiguration(clusterProperties.getNodes());
return new LettuceConnectionFactory(clusterConfiguration, clientConfiguration);
}
...
}
스프링 부트 설정은 아래와 같다.
spring:
redis:
...
cluster:
nodes:
- {host1-address:port-number}
- {host2-address:port-number}
- {host3-address:port-number}
- {host4-address:port-number}
- {host5-address:port-number}
- {host6-address:port-number}
지난 포스팅에서 Pull+Push 혼합 방식의 fan-out을 비즈니스 계층(Business Layer)의 비즈니스 로직을 중심으로 구현을 살펴보았다. 이번 포스팅에서는 MySQL과 Redis를 활용한 영속성 계층(Persistence Layer)의 데이터 접근 로직을 중심으로 구현을 살펴본다.

타임라인 조회의 흐름은 아래 시퀀스 다이어그램과 같다.

다이어그램의 FanOutRepository라는 구현이 추가되어 기존의 Fan-out된 타임라인 관련 정보를 레디스로부터 읽어오는 로직이 주를 이루고 있다.
@RequiredArgsConstructor
@Repository
public class FanOutDefaultRepository implements FanOutRepository {
private final TweetMapper tweetMapper;
private final RedisTemplate<String, Object> objectFanOutRedisTemplate;
private final RedisTemplate<String, String> stringFanOutRedisTemplate;
@Override
public List<String> findCelebFolloweeIds(String redisKey, int startIndex, int endIndex) {
return stringFanOutRedisTemplate.opsForList().range(redisKey, startIndex, endIndex);
}
@Override
public List<Tweet> findListOfTweetsByUserIds(List<String> userIds, int loadLimit) {
return tweetMapper.findByListOfTweetsByUserIds(userIds, loadLimit);
}
@Override
public Set<Object> findTweetsObjectsOfNonCelebFollowees(
String userId, int startIndex, int endIndex) {
// 팔로우중인 '일반유저 최신 tweet 목록(fanned-out to Redis)' 조회
return objectFanOutRedisTemplate.opsForZSet().range(userId, startIndex, endIndex);
}
@Override
public Set<Object> findTweetsObjectsOfNonCelebFollowees(
String userId, double minScore, double maxScore, int startIndex, int endIndex) {
// 팔로우중인 '일반유저 최신 tweet 목록(fanned-out to Redis)' 추가 조회
return objectFanOutRedisTemplate.opsForZSet().rangeByScore(
userId, minScore, maxScore, startIndex, endIndex);
}
...
}
유저가 트윗을 작성해 데이터가 모든 팔로잉 유저의 타임라인으로 Fan-out되기까지의 과정은 아래와 같다.

우선 MySQL에 트윗을 영구 저장하는 로직은 아래와 같이 TweetRepository의 구현체에서 담당하게 된다.
@RequiredArgsConstructor
@Repository
public class TweetRepositoryV1 implements TweetRepository {
private final TweetMapper tweetMapper;
@Override
public Tweet save(Tweet tweet) {
tweetMapper.save(tweet);
return tweet;
}
...
}
이후 Redis로의 Fan-out과 관련된 작업은 주로 아래의 FanOutRepository의 구현체가 담당하게 된다.
이 때, 여러 유저의 타임라인에 트윗을 쓰는 반복 작업이 발생하는데, 네트워크를 통한 복수 번 접근 부하를 줄이기 위해 Redis Pipeline으로 한 번 접근시 모든 작업을 처리하도록 구현하였다.
또한, 해당 쓰기 작업은 시간이 오래 걸릴 수 있으므로 비동기로 별도 스레드를 통해 처리하도록 하였다.
@RequiredArgsConstructor
@Repository
public class FanOutDefaultRepository implements FanOutRepository {
private final RedisTemplate<String, Object> objectFanOutRedisTemplate;
...
@Async
@Override
public void operateFanOut(List<String> followerIds, Tweet tweet) {
// Redis Pipelining 처리
objectFanOutRedisTemplate.execute((RedisCallback<Object>) connection -> {
followerIds.forEach(followerId -> {
// userId(followerId)를 키로써, SortedSet 자료구조를 값으로써 작업할 것임을 정의
BoundZSetOperations<String, Object> objectZSetOperations
= objectFanOutRedisTemplate.boundZSetOps(followerId);
// 트윗의 createdAt 필드값을 Redis의 날짜 표현형식인 Double로 변환
double timestampDouble = tweet.getCreatedAt().toEpochSecond(ZoneOffset.UTC);
// Redis에서 userId를 키로, 타임라인 트윗목록을 값(createdAt 필드를 스코어로 하는 SortedSet)으로 하여
// 자신을 팔로우하는 유저별로 순회하며 트윗 쓰기 작업
objectZSetOperations.add(tweet, timestampDouble);
});
return null;
});
}
...
}
마지막 구현에서 한 가지 문제가 남아 있다. Fan-out의 쓰기를 비동기로 처리해 응답 속도를 줄였고, 부하를 줄이기 위해 Redis Pipeline을 통해 일괄작업을 하도록 하였다. 해당 작업은 시간이 오래 걸릴 수 있으며, 또한 비즈니스 로직 계층에서는 MySQL과 Redis 동시에 쓰기를 지시한 상태이다.
여기에서 두 가지 문제가 있다.
이런 문제들로 데이터의 정합성을 지키기 어려워진다. 이런 문제를 어떻게 해결할 수 있을까?
