사건의 발단

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에서는 도큐먼트 스캔 시 스캔할 도큐먼트의 수를 최대한 줄이기 위해 다음과 같은 작업을 진행한다.

  1. 동일한 meta 필드와 비슷한 시간대의 time 필드를 갖은 도큐먼트가 있다면 하나의 도큐먼트로 압축한다. 이를 내부적으로 버킷 (도큐먼트)이라고 칭하며 비슷한 시간대의 기준은 granularity 설정값에 따라 상이하다.(영문)
  2. 압축된 도큐먼트는 풀스캔 시 소모하는 막대한 비용을 최대한 줄이기 위해 내부 데이터의 최솟값, 최댓값을 저장한다.

실제로 쿼리를 뷰단에서 실행하면 다음 과정을 거쳐 시스템 컬렉션에 질의하게 된다.

  1. 뷰의 쿼리 조건을 파싱하여 시스템 컬렉션에 맞는 조건으로 변경한다.
  2. 시스템 컬렉션의 인덱스 중 최적의 인덱스를 찾고, 인덱스를 사용한다면 이를 사용하여 스캔할 도큐먼트의 수를 줄인다.
  3. 버킷 도큐먼트 스캔 시에는 control의 값을 먼저 스캔한다. $lt는 min의 필드를 사용하며, $gt는 max의 필드를 사용한다.
  4. 버킷 도큐먼트 내 압축된 복수의 도큐먼트를 스캔하기 위해서는 위 버킷 도큐먼트의 필드별로 쪼개진 data를 병합해야 하므로 $_internalUnpackBucket을 호출한다.
  5. 마지막으로 복원된 복수의 도큐먼트를 상대로 일반적인 컬렉션의 도큐먼트처럼 도큐먼트 스캔을 진행한다.

본인은 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 값을 비교하는 연산자 없이 단독으로 사용할 수 없으므로 인덱스가 모든 쿼리 조건에 대응하지 못하게 되기 때문이다.

장애 상황 발생 요인 정리 및 마무리

장애 상황에서 발생한 모든 의문이 풀렸다. 하나씩 정리해 보자.

  1. timeseries collection에서 _id 필드는 필수가 아니며, _id_1 인덱스는 해당 collection에서 자동 생성되지 않는다. 이런 이유로 풀스캔을 진행하여 서버에 큰 부하가 가해졌고 장애의 주 원인이 되었다.
  2. time 필드가 아닌 다른 필드를 키로 갖는 인덱스는 버킷 구조의 특징으로 인해 동등 비교를 할 수 없다. 인덱스를 추가하는 이유가 동등 비교 성능을 올리기 위함이라면 인덱스 추가는 오히려 응답 시간을 지연하는 요인으로 돌아온다. _id_1 인덱스를 추가했음에도 스토리지 용량만 차지하고 오히려 성능이 더 떨어진 이유가 되겠다.

이 상황에서 할 수 있는 최선의 대책은 meta 필드를 인덱스 키로 갖는 인덱스를 통해 최대한 비교할 버킷 도큐먼트의 수를 줄이고 버킷 내 다수의 도큐먼트의 _id 값을 비교하는 방향으로 수정하는 방법이었다. 단 이 경우 버킷 도큐먼트의 크기가 크면 클 수록 부하가 커지는 구조가 된다. 그래서 버킷 도큐먼트의 크기를 정하는 granularity 설정(영문)이 중요한 이유가 되겠다.

이번에 언급한 로그성 데이터는 현재 재직 중인 회사에서 삭제가 잘 이뤄지지 않으면서 수가 많은 데이터 중 하나이다. 그만큼 서버의 신뢰성과 속도에 중대한 영향을 줌은 당연하니, 이 다량의 데이터를 장기적 관점에서 잘 관리되어야 할 필요성을 느꼈다. 그 방법의 일환으로 timeseries collection을 도입했었으나 고유한 특징으로 장애를 맞이하게 되었으니 굉장히 난감했었다.

한정된 시간 내에 모든 요소를 파악한다는 건 현실적으로 어려운 이야기고 파악하지 못한 요소로 인해 나비 효과처럼 생길 장애를 예측하는 건 더더욱 어렵다. 하지만 이 또한 일종의 성장통이라고 생각한다. 중요한 건 같은 실수를 반복하지 않아야 한다. 그래서 이번 장애를 다각적으로, 심도 있게 분석하려 특히 더 노력했던 거 같다. 앞으로는 분석 시간의 일부분을 내부 구조를 어느 정도 파악하는 시간으로 정하려고 한다. 워낙 본인은 내부 구조 파악을 좋아하는 편이라 큰 문제는 없을 거 같지만, 한정된 시간에서 이런 일련의 작업들을 소홀하게 여기기 쉽다는 사실을 깨달았다.

이번 글 전반적으로 timeseries collection의 특징에 본인의 의견을 달면서 더 개선해 볼 여지를 고민해 봤다. 개인적으로 이 글에서 설명하는 특징을 다루는 MongoDB 공식 문서가 있었으면 좋았을 거 같다. 이젠 괜찮다! 이제 본인이 글을 썼으니 본인과 같은 문제를 겪을 사람들이 줄어들 걸 생각하면 뿌듯해지는 거 같다. 다음 글에서는 timeseries collection와 유사한, 대용량 데이터 처리를 위한 컬렉션을 만들며 timeseries collection에서 겪은 문제를 개선해 보고 MongoDB에서 대용량 데이터 관리 시 고려해야 할 점 같은 인사이트를 나눠볼 예정이다.