시간 감쇠 데이터
GitLab v19.4요약
이 문서는 time-decay 패턴을 설명합니다. 일부 데이터셋은 강한 time-decay(시간 감쇠) 효과를 받습니다. 이러한 효과는 대체로 제품이나 애플리케이션의 의미 체계와 맞물려 있습니다. 먼저 데이터에 시간과 관련된 편향이 본질적으로 없는 엔터티를 생각해 봅니다.
이 문서는 time-decay 패턴을 설명합니다. 이 패턴은 Database Scalability Working Group에서 도입했습니다. 여기서는 time-decay 데이터의 특성을 살펴보고, 이 컨텍스트에서 GitLab 개발 시 고려할 모범 사례를 제안합니다.
일부 데이터셋은 강한 time-decay(시간 감쇠) 효과를 받습니다. 즉 최근 데이터가 오래된 데이터보다 훨씬 자주 조회됩니다. time-decay에는 다른 측면도 있습니다. 시간이 지나면서 일부 유형의 데이터는 덜 중요해집니다. 따라서 오래된 데이터를 내구성이 조금 낮은(가용성이 낮은) 스토리지로 옮기거나, 극단적인 경우에는 삭제할 수도 있습니다.
이러한 효과는 대체로 제품이나 애플리케이션의 의미 체계와 맞물려 있습니다. 오래된 데이터가 조회되는 정도, 그리고 오래된 데이터가 사용자나 애플리케이션에 얼마나 유용하거나 필요한지에 따라 달라질 수 있습니다.
먼저 데이터에 시간과 관련된 편향이 본질적으로 없는 엔터티를 생각해 봅니다.
사용자나 프로젝트 레코드는 생성 시점과 관계없이 동일하게 중요하고 자주 조회될 수 있습니다.
사용자의 id 나 created_at으로는 해당 레코드가 얼마나 자주 조회되거나 업데이트되는지
예측할 수 없습니다.
반면 time-decay 효과가 극단적으로 나타나는 데이터셋의 좋은 예는 로그와 시계열 데이터입니다. 사용자 동작을 기록하는 이벤트가 여기에 해당합니다.
대부분의 경우 이러한 유형의 데이터는 며칠 또는 몇 주가 지나면 업무상 쓰임새가 없어지고, 데이터 분석 관점에서도 빠르게 덜 중요해집니다. 이 데이터는 스냅숏에 해당하므로 애플리케이션의 현재 상태와의 관련성이 빠르게 줄어들고, 어느 시점에는 실질적인 가치가 없어집니다.
두 극단의 중간에는, 보관해 두고 싶은 유용한 정보를 담고 있으면서도 생성 후 초기의 (짧은) 기간이 지나고 나면 오래된 레코드는 거의 조회되지 않는 데이터셋이 있습니다.
time-decay 데이터의 특성#
다음 특성을 보이는 데이터셋을 대상으로 합니다.
- 데이터셋 크기: 상당히 큽니다.
- 접근 방식: 데이터셋에 접근하는 쿼리의 대부분을 시간 관련 차원이나 time-decay 효과가 있는 범주형 차원으로 필터링할 수 있습니다.
- 불변성: time-decay 상태가 바뀌지 않습니다.
- 보존: 오래된 데이터를 보관할지 여부, 그리고 오래된 데이터를 애플리케이션을 통해 사용자가 계속 조회할 수 있어야 하는지 여부입니다.
데이터셋 크기#
강한 time-decay 효과를 보이는 데이터셋은 크기가 다양할 수 있지만, 이 블루프린트에서는 데이터셋이 상당히 큰 엔터티에 초점을 맞춥니다.
작은 데이터셋은 데이터베이스 관련 리소스 사용량에 크게 기여하지 않고, 쿼리에 상당한 성능 부담을 주지도 않습니다.
반면 약 5,000만 레코드 또는 100GB를 넘는 큰 데이터셋은, 아주 작은 부분집합에 계속 접근하는 데에도 상당한 오버헤드를 더합니다. 이런 경우에는 time-decay 효과를 활용해 실제로 조회되는 데이터셋을 줄이는 편이 좋습니다.
데이터 접근 방식#
time-decay 데이터의 두 번째이자 가장 중요한 특성은, 대부분의 경우 날짜 필터를 사용해 암묵적으로 또는 명시적으로 데이터에 접근할 수 있고, 시간 관련 차원을 기준으로 결과를 제한할 수 있다는 점입니다.
이러한 차원은 여러 가지가 있을 수 있지만, 여기서는 생성 날짜만 다룹니다. 가장 널리 쓰이면서도 직접 통제하고 최적화할 수 있는 기준이기 때문입니다. 생성 날짜는 다음과 같은 특성을 가집니다.
- 불변입니다.
- 레코드가 생성될 때 설정됩니다.
- 레코드를 옮기지 않고도 물리적 클러스터링과 연결할 수 있습니다.
덧붙여, 애플리케이션이 기본적으로 time-decay 데이터를 그런 방식으로 조회하지 않더라도 대부분의 쿼리가 데이터를 명시적으로 그렇게 필터링하도록 만들 수 있다는 점이 중요합니다. time-decay와 관련된 접근 방식이 없는 time-decay 데이터는 확장 패턴을 정하고 따를 방법이 없으므로 최적화 관점에서는 쓸모가 없습니다.
정의를 항상 time-decay 관련 접근 방식으로만 조회되는 데이터로 한정하지는 않습니다. 예외적인 작업이 있을 수 있기 때문입니다. 이러한 작업은 필요할 수 있고, 나머지 접근 방식이 확장 가능하다면 확장되지 않는 상태를 감수할 수 있습니다. 예를 들면 다음과 같습니다. 관리자가 특정 유형의 과거 이벤트를 전부 조회하는 반면, 다른 모든 작업은 과거 6개월 이내로 제한된 범위에서 최대 한 달치 이벤트만 조회하는 경우입니다.
불변성#
time-decay 데이터의 세 번째 특성은 time-decay 상태가 바뀌지 않는다는 점입니다. 한 번 "오래된" 것으로 간주되면 다시 "새로운" 상태나 관련 있는 상태로 돌아가지 않습니다.
이 정의는 사소해 보일 수 있지만, 다시 관련성을 갖게 되어 중요한 애플리케이션 작업의 성능이 떨어지는 상황을 걱정하지 않고 "오래된" 데이터에 대한 작업을 더 비싸게(예를 들어 아카이빙하거나 더 저렴한 스토리지로 옮겨서) 만들 수 있어야 하기 때문에 중요합니다.
time-decay 데이터 접근 패턴의 반대 사례로, 업데이트 시점 기준으로 이슈를 보여 주는 애플리케이션 화면을 생각해 봅니다. "업데이트" 관점에서도 가장 최근 데이터에 관심이 있지만, 그 정의는 유동적이어서 실행 기준으로 삼을 수 없습니다.
보존#
마지막으로, time-decay 데이터를 선택 가능한 접근 방식이 조금씩 다른 하위 범주로 더 나누는 특성은 오래된 데이터를 보관할지 여부 (예를 들어 보존 정책), 그리고 오래된 데이터를 애플리케이션을 통해 사용자가 조회할 수 있는지 여부입니다.
(선택 사항) time-decay 데이터의 확장된 정의#
참고로 앞의 정의를 클러스터링 속성을 기준으로 잘 정의된 데이터 부분집합으로 접근을 제한하는 접근 패턴까지 확장하면, 다른 여러 유형의 데이터에도 time-decay 확장 패턴을 적용할 수 있습니다.
예를 들어 완료로 표시되지 않은 To-Do, 머지되지 않은 머지 리퀘스트의 파이프라인처럼 활성으로 표시된 동안에만 조회되는 데이터나, 이와 비슷한 비시간 기반 제약을 생각해 봅니다. 이 경우에는 감쇠를 정의하는 데 시간 차원 대신 범주형 차원(즉 유한한 값 집합을 사용하는 차원)을 사용해 관심 있는 부분집합을 정의합니다. 그 부분집합이 데이터셋 전체 크기에 비해 작기만 하면 동일한 접근법을 사용할 수 있습니다.
마찬가지로 6개월보다 이전에 실패한 CI 파이프라인처럼, 시간 차원과 추가 상태 속성을 함께 사용해 데이터를 오래된 것으로 정의할 수도 있습니다.
time-decay 데이터 전략#
테이블 파티셔닝#
순수하게 데이터베이스 관점에서 time-decay 데이터를 다루는 가장 권장되는 모범 사례입니다. PostgreSQL의 테이블 파티셔닝에 대한 자세한 내용은 테이블 파티셔닝 문서 페이지에서 확인할 수 있습니다.
날짜 구간(예를 들어 월, 연) 단위로 파티셔닝하면 각 날짜 구간마다 훨씬 작은 테이블(파티션)을 만들 수 있고, 애플리케이션 관련 작업에서는 가장 최근 파티션에만 접근하면 됩니다.
파티셔닝 키는 관심 있는 날짜 구간을 기준으로 설정해야 하며, 이는 두 가지 요인에 따라 달라질 수 있습니다.
-
과거 어느 시점까지 데이터를 조회해야 하는지입니다. 항상 1년 전 데이터까지 조회한다면 주 단위 파티셔닝은 쓸모가 없습니다. 매번 52개의 서로 다른 파티션(테이블)에 대해 쿼리를 실행해야 하기 때문입니다. GitLab 사용자 프로필의 활동 피드를 그 예로 생각할 수 있습니다.
반대로 생성된 지 7일 이내의 레코드만 조회하려는 경우, 연 단위 파티셔닝은 각 파티션에 불필요한 레코드를 너무 많이 포함하게 됩니다.
web_hook_logs가 이러한 사례입니다. -
생성되는 파티션의 크기가 어느 정도인지입니다. 파티셔닝의 주된 목적은 가능한 한 작은 테이블에 접근하는 것입니다. 파티션 자체가 너무 커지면 쿼리 성능이 떨어지기 시작합니다. 이 경우 더 작은 파티션으로 다시 파티셔닝(분할)해야 할 수 있습니다.
이상적인 파티셔닝 방식은 데이터셋에 대한 모든 쿼리가 거의 항상 단일 파티션 안에서 처리되도록 하는 것입니다. 일부 경우에 두 개 파티션에 걸치고 드물게 여러 파티션에 걸치는 정도면 받아들일 만한 균형입니다. 또한 파티션은 가능한 한 작게, 각각 최대 500만~1,000만 레코드 또는 10GB 이하를 목표로 삼아야 합니다.
파티셔닝은 오래된 파티션을 정리(드롭)하거나, 데이터베이스 내부의 더 저렴한 스토리지로 옮기거나, 데이터베이스 외부로 옮기는(아카이빙하거나 다른 유형의 스토리지 엔진을 사용하는) 다른 전략과 결합할 수 있습니다.
오래된 레코드를 보관할 필요가 없고 파티셔닝을 사용한다면, 오래된 데이터를 정리하는 비용은 거대한 테이블에서 데이터를 삭제하는 경우(다음 하위 절에서 설명)와 비교해 사실상 0에 가까운 상수입니다. 해당 파티션 안의 데이터가 모두 보존 정책 기간을 벗어날 때마다 오래된 파티션을 드롭하는 백그라운드 워커만 있으면 됩니다.
예를 들어 6개월이 넘지 않은 레코드만 보관하려 하고 월 단위로 파티셔닝했다면, 항상 최신 7개 파티션(현재 월과 과거 6개월)만 안전하게 유지하면 됩니다. 즉 매월 초에 8번째로 오래된 파티션을 드롭하는 워커를 둘 수 있습니다.
같은 데이터베이스 안에서 파티션을 더 저렴한 스토리지로 옮기는 작업은 PostgreSQL에서 테이블스페이스를 사용해 비교적 간단하게 처리할 수 있습니다. 각 파티션마다 테이블스페이스와 스토리지 파라미터를 따로 지정할 수 있으므로, 이 경우의 접근법은 다음과 같습니다.
- 더 저렴하고 느린 디스크에 새 테이블스페이스를 생성합니다.
- PostgreSQL 옵티마이저가 디스크가 느리다는 점을 알 수 있도록 새 테이블스페이스의 스토리지 파라미터를 더 높게 설정합니다.
- 백그라운드 워커를 사용해 오래된 파티션을 느린 테이블스페이스로 자동으로 옮깁니다.
마지막으로 파티션을 데이터베이스 외부로 옮기는 작업은 데이터베이스 아카이빙을 사용하거나 파티션을 다른 스토리지 엔진으로 직접 내보내서 수행할 수 있습니다(자세한 내용은 별도의 하위 절에서 다룹니다).
오래된 데이터 정리#
오래된 데이터를 어떤 형태로도 보관할 필요가 없다면, 정리 전략을 구현해 오래된 데이터를 삭제할 수 있습니다.
정리 워커로 과거 데이터를 삭제하는, 구현하기 쉬운 전략입니다. 아래에서 더 자세히 분석하는
예시로, 90일이 지난 오래된 web_hook_logs를 정리하고 있습니다.
파티셔닝되지 않은 큰 테이블에서 이 방식의 단점은, 더 이상 관련 없다고 판단되는
레코드를 전부 직접 조회하고 삭제해야 한다는
점입니다. PostgreSQL의 다중 버전 동시성 제어 때문에
이는 매우 비용이 큰 작업입니다. 또한 새 레코드가 생성되는 속도가 임계치를 넘어서면
정리 워커가 그 속도를 따라가지 못하는 결과로 이어집니다. 이 문서를 작성하는 시점의
web_hook_logs가 그러한 사례입니다.
앞에서 설명한 이유로, 데이터 보존 전략을 구현할 때는 그렇게 하지 않을 강력한 이유가 없는 한 파티셔닝을 기반으로 삼을 것을 제안합니다.
오래된 데이터를 데이터베이스 외부로 이동#
대부분의 경우 오래된 데이터도 가치가 있다고 보기 때문에 정리하고 싶지 않습니다. 동시에 데이터베이스 관련 작업(예를 들어 직접 조회하거나 조인 및 다른 유형의 쿼리에서 사용하는 작업)에 필요하지 않다면, 데이터베이스 외부로 옮길 수 있습니다.
이는 사용자가 애플리케이션을 통해 해당 데이터를 직접 조회할 수 없다는 뜻이 아닙니다. 메타데이터를 분리하는 방식과 비슷하게, 다만 오래된 데이터에 한해 데이터를 데이터베이스 외부로 옮기고 다른 스토리지 엔진이나 접근 방식을 사용할 수 있습니다.
가장 단순한 사용 사례에서는 최근 데이터에는 빠르고 직접적인 접근을 제공하면서, 사용자가 오래된
데이터는 아카이브로 내려받도록 할 수 있습니다. audit_events 사용 사례에서 검토한 선택지입니다.
국가와 산업에 따라 감사 이벤트의 보존 기간은 매우 길 수 있지만, GitLab 인터페이스를 통해 실제로
조회되는 것은 최근 몇 개월치 데이터뿐입니다.
추가 사용 사례로는 해당 유형의 데이터를 처리하기에 더 적합한 데이터 웨어하우스나 다른 유형의 데이터 저장소로 데이터를 내보내는 경우가 있습니다. 테이블에 저장하는 JSON 로그가 그 예입니다. 이러한 데이터를 BigQuery 나 Redshift 같은 칼럼형 저장소에 적재하면 데이터를 분석하거나 쿼리하기에 더 낫습니다.
데이터를 데이터베이스 외부로 옮기는 전략으로는 다음을 고려할 수 있습니다.
- 이 유형의 데이터를 로그로 스트리밍한 다음 보조 스토리지로 옮기거나, 다른 유형의 데이터 저장소에 직접(CSV/JSON 데이터로) 적재합니다.
- 데이터를 CSV로 내보내 오브젝트 스토리지에 업로드하고, 데이터베이스에서 해당 데이터를 드롭한 다음, 그 CSV를 다른 데이터 저장소에 적재하는 ETL 프로세스를 만듭니다.
- 데이터 저장소가 제공하는 API를 사용해 백그라운드에서 데이터를 적재합니다.
큰 데이터셋에는 이 방법이 적합하지 않을 수 있습니다. 파일을 사용한 대량 업로드가 가능한 한 API 호출보다 성능이 좋습니다.
사용 사례#
웹 훅 로그#
관련 에픽: Partitioning: web_hook_logs table
web_hook_logs의 중요한 특성은 다음과 같습니다.
-
데이터셋 크기: 매우 큰 테이블입니다. 파티셔닝을 결정한 시점(
2021-03-01)에 약 5억 2,700만 레코드에 전체 크기는 약 1TB였습니다.- 테이블:
web_hook_logs - 행 수: 약 5억 2,700만
- 전체 크기: 1.02 TiB (10.46%)
- 테이블 크기: 713.02 GiB (13.37%)
- 인덱스 크기: 42.26 GiB (1.10%)
- TOAST 크기: 279.01 GiB (38.56%)
- 테이블:
-
접근 방식: 항상 최대 과거 7일치 로그만 요청합니다.
-
불변성: 값이 바뀌지 않는 속성인
created_at으로 파티셔닝할 수 있습니다. -
보존: 90일 보존 정책이 설정되어 있습니다.
또한 당시에는 백그라운드 워커(PruneWebHookLogsWorker)로 데이터를 정리하려 했으나,
이 워커는 삽입 속도를 따라가지 못했습니다.
그 결과 2021년 3월 시점에도 2020년 7월 이후 삭제되지 않은 레코드가 남아 있었고, 테이블 크기는 어느 정도 안정적으로 유지되기는커녕 하루에 200만 레코드 이상씩 늘어나고 있었습니다.
또한 삽입 속도는 2021년 3월까지 월 170GB를 넘어섰고 계속 증가하고 있었기 때문에, 오래된 데이터를 정리할 유일한 방법은 파티셔닝이었습니다.
90일 보존 정책과 맞아떨어졌기 때문에 테이블을 월 단위로 파티셔닝하는 방식을 선택했습니다.
필요한 절차는 다음과 같습니다.
-
파티셔닝 키를 결정합니다.
이 경우
created_at칼럼을 사용하는 것이 자연스럽습니다. 보존 정책이 있고 충돌하는 접근 패턴이 없을 때 자연스러운 파티셔닝 키입니다. -
파티셔닝 키를 결정한 뒤에는 파티션을 생성하고 백필(기존 테이블에서 데이터 복사)할 수 있습니다. 기존 테이블을 그대로 파티셔닝할 수는 없습니다. 새로 파티셔닝된 테이블을 만들어야 합니다.
따라서 파티셔닝된 테이블과 관련 파티션을 모두 만들고 전체 데이터 복사를 시작하며, 새 데이터나 기존 데이터의 업데이트·삭제가 새 파티셔닝 테이블에 반영되도록 동기화 트리거도 추가해야 합니다.
테이블 파티셔닝을 시작하는 방법에 대한 상세 내용이 담긴 MR
이 과정을 완료하는 데 15일 7.6시간이 걸렸습니다.
-
최초 파티셔닝을 시작하고 한 마일스톤 뒤에, 백필에 사용한 백그라운드 마이그레이션을 정리하고 남은 job을 모두 실행 완료하며, 실패한 job은 재시도합니다.
-
파티셔닝된 테이블에 남은 외래 키와 보조 인덱스를 추가합니다. 이로써 다음 마일스톤에서 교체하기 전에 스키마가 기존 비파티셔닝 테이블과 같아집니다.
처음부터 추가하지 않는 이유는 삽입마다 오버헤드가 생겨 테이블의 초기 백필이 느려지기 때문입니다(이 경우 5억 레코드가 넘으므로 그 영향이 상당히 커질 수 있습니다). 그래서 가볍고 기본적인 형태의 테이블을 만들어 데이터를 모두 복사한 뒤, 남은 인덱스와 외래 키를 추가합니다.
-
기본 테이블을 파티셔닝된 복사본과 교체합니다. 이 시점부터 파티셔닝된 테이블이 애플리케이션에서 실제로 사용되기 시작합니다.
원본 테이블을 드롭하는 작업은 되돌릴 수 없으므로, 과정에 문제가 없었는지 확인하기 위해 기존 비파티셔닝 테이블을 남겨 둡니다. 또한 동기화 트리거의 방향을 반대로 바꾸어, 파티셔닝된 테이블에서 발생하는 작업이 비파티셔닝 테이블에도 계속 반영되도록 합니다. 이렇게 하면 필요할 때 테이블을 다시 되돌릴 수 있습니다.
-
마지막 단계로, 교체 후 한 마일스톤 뒤에 비파티셔닝 테이블을 드롭합니다.
-
비파티셔닝 테이블을 드롭한 뒤에는 과거 파티션을 드롭하는 방식으로 정리 전략을 구현하는 워커를 추가할 수 있습니다.
이 경우 워커는 (보존 정책이 90일이므로) 항상 4개의 파티션만 활성 상태로 유지하고, 4개월보다 오래된 파티션은 드롭합니다. 현재 월이 아직 진행 중인 동안에는 90일 전으로 거슬러 올라가면 네 번째로 오래된 파티션에 닿으므로, 4개월치 파티션을 유지해야 합니다.
감사 이벤트#
관련 에픽: Partitioning: Design and implement partitioning strategy for audit events
audit_events 테이블은 앞의 하위 절에서 다룬 web_hook_logs 테이블과 특성이 많이 겹치므로,
여기서는 차이점에 집중합니다.
파티셔닝으로 대부분의 성능 문제를 해결할 수 있다는 데 의견이 모였습니다.
다른 대부분의 큰 테이블과 달리 이 테이블에는 크게 충돌하는 접근 패턴이 없어서, 월 단위 파티셔닝에 맞도록 접근 패턴을 바꿀 수 있었습니다. 예를 들어 다른 테이블은 그렇지 않습니다. 파티셔닝 방식(예를 들어 네임스페이스 기준)이 정당화될 수 있더라도, 충돌하는 접근 패턴이 많습니다.
또한 audit_events는 읽기(쿼리)가 거의 없는 쓰기 위주 테이블이고, 스키마가 매우 단순하며,
데이터베이스의 나머지 부분과 연결되어 있지 않고(들어오거나 나가는 FK 제약이 없음) 인덱스도
두 개만 정의되어 있습니다.
외래 키 제약이 없다는 점은 당시 중요했습니다. PostgreSQL 11을 사용하던 시점에도 파티셔닝을 할
수 있다는 뜻이었기 때문입니다. 위의 web_hook_logs 사용 사례에서 볼 수 있듯이, PostgreSQL 12
가 필수 기본값이 된 지금은 더 이상 고려할 사항이 아닙니다.
audit_events 파티셔닝에 필요한 마이그레이션과 단계는 앞의 하위 절에서
web_hook_logs에 대해 설명한 것과 비슷합니다. 현재 audit_events에는 보존 전략이
정의되어 있지 않아 정리 전략도 구현되어 있지 않지만, 향후 아카이빙 방안을 구현할 수
있습니다.
audit_events 사례에서 흥미로운 점은
파티셔닝된 테이블을 최적으로 쿼리하도록 유도하는
UI/UX 변경을 구현하기 위해 거쳐야 했던 단계에 대한 논의입니다.
모든 접근 패턴을 특정 time-decay 관련 접근 방식에 맞추기 위해 애플리케이션 수준에서 필요한
변경의 출발점으로 삼을 수 있습니다.
CI 테이블#
CI 테이블 사용 사례의 요구 사항과 분석은 아직 진행 중입니다. 분석이 진전되면 자세한 내용을 추가할 예정입니다.