
Appboy는 모바일 앱을 위한 세계 선도적인 마케팅 자동화 플랫폼입니다. 고객사의 모바일 앱에서 사용자 행동을 추적해 매달 수십억 개의 데이터 포인트를 수집하며, 이를 바탕으로 고객은 사용자의 행동이나 인구 통계 특성에 따라 이메일, 푸시 알림, 인앱 메시지 타깃팅을 수행할 수 있습니다. Appboy는 데이터베이스 스택 대부분에 MongoDB를 사용하고 있으며, ObjectRocket에서 여러 클러스터에 걸쳐 수십 개의 샤드를 운영하고 있습니다.
짧은 필드 이름으로 문서 크기 줄이기
MongoDB에서 흔히 활용되는 성능 최적화 전략 중 하나는 문서에 짧은 필드 이름을 사용하는 것입니다. 예를 들어 다음과 같은 문서 대신...
{first_name: "Jon", last_name: "Hyman"}다음처럼 더 짧은 필드 이름을 사용하는 방식입니다.
{fn: "Jon", ln: "Hyman"}MongoDB에는 관계형 데이터베이스의 컬럼이나 사전 정의된 스키마 개념이 없기 때문에, 필드 이름은 데이터베이스 내 모든 문서에 반복해서 저장됩니다. "first_name" 필드를 가진 문서가 100만 개라면 그 문자열이 백만 번 저장되는 셈입니다. 이는 문서 하나당 차지하는 공간을 늘리고, 결국 메모리에 적재할 수 있는 문서 수에 영향을 미치며, 대규모 환경에서는 MongoDB가 문서를 읽을 때마다 메모리에 매핑해야 하므로 성능 저하로도 이어질 수 있습니다.
커스텀 속성 문제와 '네임 스토어' 도입
Appboy는 이벤트 데이터 수집 외에도, 고객이 각 사용자에게 "커스텀 속성(custom attributes)"이라 부르는 데이터를 저장할 수 있게 지원합니다. 예를 들어 스포츠 앱은 사용자의 "최애 선수(Favorite Player)"를, 잡지나 신문 앱은 고객이 "연간 구독자(Annual Subscriber)"인지 여부를 저장할 수 있습니다. Appboy는 추적하는 앱의 모든 최종 사용자마다 하나의 문서를 두고, 이름·성 같은 기본 필드와 함께 이러한 커스텀 속성을 저장합니다.
공간 절약과 성능 향상을 위해 우리는 문서에 저장하는 모든 필드 이름을 축약합니다. 미리 알 수 있는 필드(이름, 이메일, 성별 등)는 직접 별칭(aliasing)을 지정하면 됩니다(예: "fn" = "first name"). 하지만 고객이 등록할 커스텀 속성의 이름까지는 예측할 수 없습니다. 고객이 "supercalifragilisticexpialidocious" 같은 이름의 커스텀 속성을 만들었다면, 그 긴 문자열을 모든 문서에 저장하고 싶지 않을 것입니다.
이 문제를 해결하기 위해 우리는 "네임 스토어(name store)"라 부르는 방식으로 커스텀 속성 필드 이름을 토큰화했습니다. 핵심은 "Favorite Player" 같은 값을 고유하고 예측 가능하며 아주 짧은 문자열에 매핑하는 MongoDB 문서입니다. 그리고 이 매핑은 MongoDB의 원자적(atomic) 연산자만으로 생성할 수 있습니다.
네임 스토어 문서 스키마
네임 스토어 문서의 스키마는 극도로 단순합니다. 고객마다 문서가 하나씩 있고, 각 문서에는 "list"라는 배열 필드 하나만 존재합니다. 배열에는 해당 고객의 모든 커스텀 속성 값이 담기며, 특정 문자열의 배열 인덱스가 곧 그 문자열의 토큰이 됩니다. 따라서 "Favorite Player"를 짧고 예측 가능한 필드 이름으로 변환하려면 "list" 배열에서 해당 문자열의 위치만 확인하면 됩니다. 없다면 원자적 $push로 배열 끝에 요소를 추가한 뒤(db.custom_attribute_name_stores.update({_id : X, list: {$ne : "Favorite Player"}}, {$push: {list: "Favorite Player"}})), 문서를 다시 읽어 인덱스를 파악합니다.
이상적으로는 $addToSet을 사용하는 것이 좋지만, $addToSet은 순서를 보장하지 않는 반면 $push는 기본적으로 배열 끝에 요소를 추가한다고 공식 문서에 명시되어 있어 후자를 선택했습니다.
이 시점에서 "Favorite Player" 같은 값을 정수로 변환할 수 있습니다. 그 값이 1이라면 사용자 문서는 다음과 같은 모습이 됩니다.
{
fn: "Jon",
ln: "Hyman",
custom: {
1: "LeBron James"
}
}필드 이름이 짧고 깔끔해졌습니다! 이 방식의 또 다른 장점은 달러 기호($)나 마침표(.)처럼 MongoDB가 이스케이프 없이는 지원하지 않는 문자를 고객이 필드 이름에 사용해도 문제없다는 점입니다.
무한정 커지는 문서 다루기: leastvalue 필드
"MongoDB는 계속 커지는 문서를 경계하는데, 네임 스토어 문서가 무한정 커지지 않을까?"라고 생각하실 수 있습니다. 실제로 우리는 고객당 여러 개의 문서를 저장할 수 있도록 구현을 확장했고, 새 문서를 생성하기 전까지 허용되는 배열 요소 수에 합리적인 상한을 두었습니다. 놀라운 점은 이 모든 과정 역시 MongoDB의 원자적 연산만으로 처리할 수 있다는 것입니다.
이를 위해 각 문서에 "leastvalue"라는 필드를 추가했습니다. "leastvalue"는 해당 문서가 생성되기 이전의 문서들에 추가된 요소 수를 나타냅니다. 예를 들어 어떤 문서의 "leastvalue"가 100이고 "list"가 ["Season Ticket Holder", "Favorite Player"]라면, "Favorite Player"의 토큰 값은 101입니다(0부터 시작하는 인덱스 사용). 이 예시에서는 새 문서를 만들기 전에 "list" 배열에 100개의 값만 저장합니다.
삽입 시에는 $push 연산을 수정해 "leastvalue"가 가장 큰 문서에 대해 동작하게 하고, 동시에 "list.99"(배열의 인덱스 99)가 존재하지 않는다는 조건도 확인합니다. 해당 인덱스에 이미 요소가 있다면 $push는 아무 작업도 수행하지 않으며, 이 경우 모든 문서에 걸친 총 요소 수를 "least_value"로 하는 새 네임 스토어 문서를 생성해야 한다는 것을 알 수 있습니다. 원자적 $findAndModify를 사용하면 문서가 없을 때 새로 생성하고, 이를 가져온 뒤 $push를 다시 시도할 수 있습니다.
토큰 캐싱으로 조회 비용 절감
고객이 많은 수의 커스텀 속성을 사용한다면, 값을 토큰으로 변환하기 위해 모든 네임 스토어 문서를 읽어오는 과정에서 대역폭과 처리 비용이 상당히 발생할 수 있습니다. 하지만 특정 필드의 토큰 값은 한 번 계산되면 항상 동일하므로, 우리는 토큰을 캐싱해 변환 속도를 크게 높였습니다.
값(value)에도 적용 가능한 토큰화
우리는 애플리케이션의 여러 부분에 "네임 스토어 토큰" 패러다임을 적용해, 유연한 스키마를 유지하면서도 필드 이름 크기를 줄였습니다. 이 기법은 값에도 유용하게 적용됩니다. 라디오 방송국 앱이 사용자가 가장 많이 듣는 상위 50명 아티스트 목록을 커스텀 속성 배열로 저장한다고 가정해 보겠습니다. 문자열 50개로 이루어진 배열 대신 방송국 이름을 토큰화해 정수 50개의 배열을 사용자 문서에 저장할 수 있습니다.
특정 아티스트를 좋아하는 사용자를 조회하려면 이제 토큰 조회가 두 번 필요합니다. 하나는 필드 이름용, 다른 하나는 값용입니다. 하지만 값-토큰 변환 결과를 캐싱해 두었기 때문에, 캐시 계층에서 멀티겟(multi-get)을 활용하면 몇 개의 값을 변환하든 캐시 왕복을 단 한 번으로 유지할 수 있습니다.
마치며
이 최적화는 분명 간접 참조(indirection)와 복잡성을 더합니다. 하지만 Appboy처럼 수억 명의 사용자 데이터를 저장하는 환경에서는 충분히 가치 있는 최적화입니다. 이 기법을 통해 우리는 수백 기가바이트에 달하는 값비싼 SSD 공간을 절약했습니다.
더 궁금하신가요? 저는 9월 18일 Cipriani에서 열리는 Rackspace Solve NYC 컨퍼런스에서 Appboy의 데브옵스에 대해 발표할 예정입니다.