사건의 발단
MongoDB timeseries collection에 저장되는 로그성 데이터 중 일부 데이터의 존재 여부를 조회해야 하는 업무를 받아 보통의 collection처럼 _id 필드를 기준으로 구현을 시도했다. 큰 문제가 없어 보여 구현을 모두 마치고 프로덕션 서버를 업데이트했다.
그로부터 수 시간 뒤, 서버에 장애가 생겼다. timeseries collection에 연관된 API를 호출할 때마다 서버의 응답 속도가 초 단위로 껑충 튀어 정상적인 처리 자체가 불가했고 DB의 스펙은 M10에서 오토 스케일링 정책에 의해 두 단계나 높은 M30까지 올라갔다. 본인과 협업하던 외부 개발자가 timeseries collection은 _id_1 인덱스가 자동 생성되지 않는다는 사실을 확인해서 해당 인덱스를 추가했다.
_id_1 인덱스를 추가해 문제를 해결하고자 했으나 오히려 해당 인덱스가 오버헤드가 되어 풀스캔보다 느려지는 대참사가 벌어졌다. 심호흡하고, 이 문제의 요인이 될 수 있는 가능성을 빠르게 파악해 보기로 했다.
시스템 컬렉션의 도큐먼트 저장 방식
이전 글에 언급했던 내용으로 timeseries collection은 실제 데이터가 압축되어 저장되는 시스템 컬렉션과 유저가 접근할 수 있는 영역인 뷰, 두 개의 영역으로 나뉘어 있다. MongoDB는 유저가 뷰에서 인덱스를 생성하면 시스템 컬렉션에 상응하는 인덱스를 만든다. 이 부분까지는 사전에 알고 있었다. 그런데 시스템 컬렉션 안에 정확하게 어떻게 저장되는지를 개발 소요 시간의 문제로 제때 확인하지 못한 게 패착이었다.
뷰를 통해 이런 데이터를 저장하려고 가정해 보자.
{
"createdAt": ISODate("2024-03-17T02:16:51.048Z"),
"metadata": {
"userId": ObjectId("65f652932f37c9b315d7a5cb")
},
"something": "something1",
_id: ObjectId("65f652932f37c9b315d7a5cc")
}시스템 컬렉션에서는 다음처럼 저장된다.
{
_id: ObjectId('65f6526043087da104fa2d5c'),
control: {
version: 1,
min: {
createdAt: 2024-03-17T02:16:00.000Z,
something: 'something1',
_id: ObjectId('65f652932f37c9b315d7a5cc')
},
max: {
createdAt: 2024-03-17T02:16:51.048Z,
something: 'something1',
_id: ObjectId('65f652932f37c9b315d7a5cc')
}
},
meta: {
userId: ObjectId('65f652932f37c9b315d7a5cb')
},
data: {
createdAt: {
'0': 2024-03-17T02:16:51.048Z
},
something: {
'0': 'something1'
},
_id: {
'0': ObjectId('65f652932f37c9b315d7a5cc')
}
}
}_id_1 인덱스가 생성되지 않은 이유를 뒷받침하듯, 애당초에 timeseries collection에서는 _id가 필수 필드가 아니었다! mongoose를 사용하고 있었기 때문에 해당 라이브러리가 필드를 자동으로 채우므로 눈치채지 못했을 뿐이다.
데이터의 형태를 분석해 보면 timeseries collection에서는 도큐먼트 스캔 시 스캔할 도큐먼트의 수를 최대한 줄이기 위해 다음과 같은 작업을 진행한다.
- 동일한 meta 필드와 비슷한 시간대의 time 필드를 갖은 도큐먼트가 있다면 하나의 도큐먼트로 압축한다. 이를 내부적으로 버킷 (도큐먼트)이라고 칭하며 비슷한 시간대의 기준은 granularity 설정값에 따라 상이하다.(영문)
- 압축된 도큐먼트는 풀스캔 시 소모하는 막대한 비용을 최대한 줄이기 위해 내부 데이터의 최솟값, 최댓값을 저장한다.
실제로 쿼리를 뷰단에서 실행하면 다음 과정을 거쳐 시스템 컬렉션에 질의하게 된다.
- 뷰의 쿼리 조건을 파싱하여 시스템 컬렉션에 맞는 조건으로 변경한다.
- 시스템 컬렉션의 인덱스 중 최적의 인덱스를 찾고, 인덱스를 사용한다면 이를 사용하여 스캔할 도큐먼트의 수를 줄인다.
- 버킷 도큐먼트 스캔 시에는 control의 값을 먼저 스캔한다. $lt는 min의 필드를 사용하며, $gt는 max의 필드를 사용한다.
- 버킷 도큐먼트 내 압축된 복수의 도큐먼트를 스캔하기 위해서는 위 버킷 도큐먼트의 필드별로 쪼개진 data를 병합해야 하므로 $_internalUnpackBucket을 호출한다.
- 마지막으로 복원된 복수의 도큐먼트를 상대로 일반적인 컬렉션의 도큐먼트처럼 도큐먼트 스캔을 진행한다.
본인은 data 부분을 저장할 때 모든 데이터를 오브젝트로 한꺼번에 저장하는 방식이 더 효율적이라고 생각하는데, 위처럼 필드마다 오브젝트를 나눠 저장하는 방식이 정말 효율적인지 잘 모르겠다. 압축 효율을 높이기 위해서라는 나름대로의 가설을 세워보았으나 중복 값도 동일하게 저장하므로 이 이유는 아닌 거 같다.
시스템 컬렉션의 도큐먼트 저장 방식을 분석하면서 이 장애의 원인은 애당초에 _id 필드가 필수고 그에 상응하는 _id_1 인덱스가 생성되리라 예측하여 발생하였다고 정리할 수 있겠다. 그러나 _id_1 인덱스를 수동으로 생성하면 오히려 쿼리 성능이 후퇴하는 모습을 보이는데 이 부분은 어떤 요인으로 발생했는지에 대해선 좀 더 파악이 필요했다. 그래서 시스템 컬렉션의 인덱스 생성 방식을 파악해 봤다.
시스템 컬렉션의 인덱스 생성 방식
timeseries collection을 생성할 때 time 필드와 meta 필드를 명시해야 한다. 이렇게 명시된 필드들은 버킷 도큐먼트를 분할할 때 기준이 되며, 읽기 성능 향상을 위해 MongoDB가 meta_field_1_time_field_1 인덱스를 자동으로 생성한다.
[
{
v: 2,
key: { metadata: 1, createdAt: 1 },
name: 'metadata_1_createdAt_1'
}
]위에서 확인했듯, 시스템 컬렉션은 도큐먼트 스캔에 소모되는 비용을 최대한 줄이기 위해 다수의 도큐먼트를 압축하여 버킷 도큐먼트를 생성한다. 여기에 모든 도큐먼트를 탐색하는 걸 방지하고자 고유한 meta 필드를 제외한 모든 필드에 대해 최솟값, 최댓값을 추적하여 관리하게 된다. 그래서 meta 필드가 아닌 다른 필드가 인덱스 키가 되면 다음과 같이 변환되어 인덱스가 생성된다.
[
{
v: 2,
key: { meta: 1, 'control.min.createdAt': 1, 'control.max.createdAt': 1 },
name: 'metadata_1_createdAt_1'
}
]min, max에 인덱스가 적용되었으며, max는 복합 인덱스로 정의되어 있어 min이 쿼리 조건에 같이 존재해야만 인덱스를 사용해 max를 질의할 수 있다는 사실을 확인할 수 있다. 거기에다 쿼리 조건에 동등 조건이 있더라고 하더라도 meta 필드가 아니면 동등 조건이 아닌 범위 조건이 되고 실제 내부 도큐먼트를 하나씩 스캔한다는 사실을 확인해 동등 조건 상대로 인덱스가 의미가 없다는 사실을 알 수 있었다.
본인은 시스템 컬렉션에 인덱스를 추가하게 되면 min_1_max_1 복합 인덱스 형태를 그대로 생성하면서 max 필드를 대응하는 단일 인덱스가 하나 더 생성되면 좋겠다고 생각한다. 왜냐하면 복합 연산자 하나만 존재할 때 $gt, $gte 같은 max 값을 비교하는 연산자는 $lt, $lte 등의 min 값을 비교하는 연산자 없이 단독으로 사용할 수 없으므로 인덱스가 모든 쿼리 조건에 대응하지 못하게 되기 때문이다.
장애 상황 발생 요인 정리 및 마무리
장애 상황에서 발생한 모든 의문이 풀렸다. 하나씩 정리해 보자.
- timeseries collection에서 _id 필드는 필수가 아니며, _id_1 인덱스는 해당 collection에서 자동 생성되지 않는다. 이런 이유로 풀스캔을 진행하여 서버에 큰 부하가 가해졌고 장애의 주 원인이 되었다.
- time 필드가 아닌 다른 필드를 키로 갖는 인덱스는 버킷 구조의 특징으로 인해 동등 비교를 할 수 없다. 인덱스를 추가하는 이유가 동등 비교 성능을 올리기 위함이라면 인덱스 추가는 오히려 응답 시간을 지연하는 요인으로 돌아온다. _id_1 인덱스를 추가했음에도 스토리지 용량만 차지하고 오히려 성능이 더 떨어진 이유가 되겠다.
이 상황에서 할 수 있는 최선의 대책은 meta 필드를 인덱스 키로 갖는 인덱스를 통해 최대한 비교할 버킷 도큐먼트의 수를 줄이고 버킷 내 다수의 도큐먼트의 _id 값을 비교하는 방향으로 수정하는 방법이었다. 단 이 경우 버킷 도큐먼트의 크기가 크면 클 수록 부하가 커지는 구조가 된다. 그래서 버킷 도큐먼트의 크기를 정하는 granularity 설정(영문)이 중요한 이유가 되겠다.
이번에 언급한 로그성 데이터는 현재 재직 중인 회사에서 삭제가 잘 이뤄지지 않으면서 수가 많은 데이터 중 하나이다. 그만큼 서버의 신뢰성과 속도에 중대한 영향을 줌은 당연하니, 이 다량의 데이터를 장기적 관점에서 잘 관리되어야 할 필요성을 느꼈다. 그 방법의 일환으로 timeseries collection을 도입했었으나 고유한 특징으로 장애를 맞이하게 되었으니 굉장히 난감했었다.
한정된 시간 내에 모든 요소를 파악한다는 건 현실적으로 어려운 이야기고 파악하지 못한 요소로 인해 나비 효과처럼 생길 장애를 예측하는 건 더더욱 어렵다. 하지만 이 또한 일종의 성장통이라고 생각한다. 중요한 건 같은 실수를 반복하지 않아야 한다. 그래서 이번 장애를 다각적으로, 심도 있게 분석하려 특히 더 노력했던 거 같다. 앞으로는 분석 시간의 일부분을 내부 구조를 어느 정도 파악하는 시간으로 정하려고 한다. 워낙 본인은 내부 구조 파악을 좋아하는 편이라 큰 문제는 없을 거 같지만, 한정된 시간에서 이런 일련의 작업들을 소홀하게 여기기 쉽다는 사실을 깨달았다.
이번 글 전반적으로 timeseries collection의 특징에 본인의 의견을 달면서 더 개선해 볼 여지를 고민해 봤다. 개인적으로 이 글에서 설명하는 특징을 다루는 MongoDB 공식 문서가 있었으면 좋았을 거 같다. 이젠 괜찮다! 이제 본인이 글을 썼으니 본인과 같은 문제를 겪을 사람들이 줄어들 걸 생각하면 뿌듯해지는 거 같다. 다음 글에서는 timeseries collection와 유사한, 대용량 데이터 처리를 위한 컬렉션을 만들며 timeseries collection에서 겪은 문제를 개선해 보고 MongoDB에서 대용량 데이터 관리 시 고려해야 할 점 같은 인사이트를 나눠볼 예정이다.