InfoGrab DocsInfoGrab Docs

데이터 레이아웃 및 액세스 패턴 모범 사례

요약

특정한 데이터 접근 패턴, 특히 데이터 업데이트 패턴은 데이터베이스에 가해지는 부하를 악화시킬 수 있습니다. 이 문서에서는 피해야 할 패턴과 그 대안을 정리합니다. 여러 트랜잭션이 동시에 업데이트하는 단일 데이터베이스 행은 피합니다.

특정한 데이터 접근 패턴, 특히 데이터 업데이트 패턴은 데이터베이스에 가해지는 부하를 악화시킬 수 있습니다. 가능하면 이러한 패턴을 피합니다.

이 문서에서는 피해야 할 패턴과 그 대안을 정리합니다.

고빈도 업데이트, 특히 동일한 행에 대한 업데이트#

여러 트랜잭션이 동시에 업데이트하는 단일 데이터베이스 행은 피합니다.

  • 여러 프로세스가 동일한 행을 동시에 업데이트하려고 하면, 각 트랜잭션이 쓰기를 위해 행을 잠그면서 대기열이 생깁니다. 이로 인해 트랜잭션 시간이 크게 늘어나면 Rails 커넥션 풀이 포화되어 애플리케이션 전체가 중단될 수 있습니다.
  • 행을 업데이트할 때마다 PostgreSQL은 새 행 버전을 삽입하고 이전 버전을 삭제합니다. 트래픽이 많은 상황에서는 이 방식이 vacuum과 WAL(write-ahead log) 부하를 유발하여 데이터베이스 성능을 떨어뜨립니다.

이 패턴은 요청마다 집계를 계산하는 비용이 너무 커서 누적 합계를 데이터베이스에 유지할 때 자주 나타납니다. 이러한 집계가 필요하다면, 단일 행에 누적 합계를 두고 최근에 추가된 데이터, 예를 들어 개별 증분값으로 구성된 작은 작업 집합을 함께 두는 방식을 고려합니다.

  • 새 데이터가 들어오면 작업 집합에 추가합니다. 이러한 삽입은 잠금 경합을 일으키지 않습니다.
  • 집계를 계산할 때는 누적 합계와 작업 집합의 실시간 집계를 결합하여 최신 결과를 제공합니다.
  • 작업 집합을 누적 합계에 반영하고 트랜잭션 안에서 작업 집합을 비우는 주기적 job을 추가하여, 읽는 쪽이 처리해야 할 작업량을 제한합니다.

넓은 테이블#

PostgreSQL은 행을 8 KB 페이지 단위로 구성하고 한 번에 한 페이지씩 처리합니다. 테이블의 행 폭을 최소화하면 다음이 개선됩니다.

  • 순차 스캔과 비트맵 인덱스 스캔 성능. 페이지마다 더 많은 행이 들어가면 스캔해야 할 페이지가 줄어들기 때문입니다.
  • Vacuum 성능. vacuum 이 각 페이지에서 더 많은 행을 처리할 수 있기 때문입니다.
  • 업데이트 성능. (HOT 이 아닌) 업데이트 중에는 행 업데이트마다 모든 인덱스를 갱신해야 하기 때문입니다.

넓은 테이블 문제를 줄이는 작업은 데이터베이스 팀의 100 GB 테이블 이니셔티브의 일부입니다. 테이블이 넓을수록 100 GB 안에 담을 수 있는 행이 줄어들기 때문입니다.

테이블에 칼럼을 추가할 때는 새 칼럼의 데이터를 테이블의 다른 칼럼과 일대일 관계로 두면서 그 자체만 조회할 의도가 있는지 검토합니다. 그렇다면 새 칼럼은 새 테이블로 분리하기에 좋은 후보입니다.

이미 이런 방식으로 분리한 테이블이 여럿 있습니다. 예를 들면 다음과 같습니다.

  • search_data는 issues에서 분리했습니다.
  • project_pages_metadata는 projects에서 분리했습니다.
  • merge_request_diff_details는 merge_request_diffs에서 분리했습니다.

데이터 모델 트레이드오프#

users, namespaces, projects 같은 일부 테이블은 매우 넓어질 수 있습니다. 이러한 테이블은 대개 애플리케이션의 중심이며 아주 자주 사용됩니다.

이것이 문제가 되는 이유는 다음과 같습니다.

  • 이러한 칼럼 중 상당수가 인덱스에 포함되어 인덱스 쓰기 증폭을 유발합니다. 테이블의 인덱스 수가 16 개를 넘으면 쿼리 계획 수립에 영향을 주고 경량 잠금(LWLock) 경합으로 이어질 수 있습니다.
  • PostgreSQL의 업데이트는 삭제와 삽입의 조합으로 구현됩니다. 즉, 거의 사용되지 않는 칼럼이라도 업데이트마다 계속 복사됩니다. 이는 생성되는 WAL(write ahead log) 양에 영향을 줍니다.
  • 자주 업데이트되는 칼럼이 있으면 업데이트마다 테이블의 모든 칼럼이 복사됩니다. 이 또한 생성되는 WAL을 늘리고 auto-vacuum의 작업량을 키웁니다.
  • PostgreSQL은 데이터를 페이지 안의 행, 즉 튜플로 저장합니다. 행이 넓으면 페이지당 튜플 수가 줄어들어 읽기 성능에 영향을 줍니다.

이 문제의 해결책 하나는 가장 중요한 칼럼만 기본 테이블에 남기고 나머지는 기본 테이블과 일대일 관계를 갖는 다른 테이블로 추출하는 것입니다. last_activity_at처럼 매우 자주 업데이트되는 칼럼이나, 활성화 토큰처럼 거의 업데이트되거나 사용되지 않는 칼럼이 좋은 후보입니다.

이러한 추출에 따르는 트레이드오프는 인덱스 전용 스캔을 더 이상 사용할 수 없다는 점입니다. 대신 애플리케이션이 새 테이블을 조인하거나 추가 쿼리를 실행해야 합니다. 이때의 성능 영향은 수직 테이블 분리의 이점과 견주어 판단해야 합니다.

이 주제를 다룬 좋은 에피소드가 PostgresFM 팟캐스트에 있습니다. PostgresAI의 @NikolayS와 PgMustard의 @michristofides가 이 주제를 더 깊이 논의합니다: https://postgres.fm/episodes/data-model-trade-offs.

예시#

작성 시점 기준으로 75 개 칼럼을 가진 users 테이블을 살펴봅니다. 위 기준에 들어맞아 추출하기 좋은 후보에 해당하는 칼럼 그룹을 몇 가지 확인할 수 있습니다.

  • encrypted_otp_secret, otp_secret_expires_at 등 OTP 관련 칼럼. 개수가 적고 한 번 채워지면 자주 업데이트되지 않습니다(업데이트가 아예 없을 수도 있습니다).
  • 이메일 확인과 관련된 confirmation_token, confirmation_sent_at, confirmed_at 칼럼. 한 번 채워지면 이후 업데이트될 가능성이 거의 없습니다.
  • password_expires_at, last_credential_check_at, admin_email_unsubscribed_at 같은 타임스탬프. 이런 칼럼은 매우 자주 업데이트되거나 전혀 업데이트되지 않습니다. 별도 테이블에 두는 편이 낫습니다.
  • unlock_token, incoming_email_token, feed_token 같은 각종 토큰(및 관련 칼럼).

그중 users.incoming_email_token에 집중해 봅니다. GitLab.com의 모든 사용자가 이 값을 가지고 있으며 이 토큰은 거의 업데이트되지 않습니다.

이 칼럼을 users에서 새 테이블로 추출하려면 다음 과정을 거쳐야 합니다.

  1. 릴리스 M 예시
    • 테이블을 생성합니다(릴리스 M)
    • 새 테이블에서 읽도록 애플리케이션을 업데이트하고, 아직 데이터가 없으면 원래 칼럼으로 폴백하도록 합니다.
    • 새 테이블에 대한 백필을 시작합니다
  2. 릴리스 N 예시
    • 백필을 수행하는 백그라운드 마이그레이션을 마무리합니다. 이 작업은 필수 중간 정차 버전 다음 릴리스에서 수행해야 합니다.
  3. 릴리스 N + 1 예시
    • 새 테이블에서만 읽고 쓰도록 애플리케이션을 업데이트합니다.
    • 원래 칼럼을 무시합니다. 이렇게 가이드에 설명된 대로 데이터베이스 칼럼을 안전하게 제거하는 절차가 시작됩니다.
  4. 릴리스 N + 2 예시
    • 원래 칼럼을 삭제합니다.
  5. 릴리스 N + 3 예시
    • 원래 칼럼에 대한 무시 규칙을 제거합니다.

이 과정은 길지만, 애플리케이션을 중단시키지 않고 추출을 수행하려면 필요합니다. 완료되고 나면 원래 칼럼과 관련 인덱스가 users 테이블에서 사라지므로 성능이 개선됩니다.

데이터 레이아웃 및 액세스 패턴 모범 사례

GitLab v19.4
원문 보기

요약

특정한 데이터 접근 패턴, 특히 데이터 업데이트 패턴은 데이터베이스에 가해지는 부하를 악화시킬 수 있습니다. 이 문서에서는 피해야 할 패턴과 그 대안을 정리합니다. 여러 트랜잭션이 동시에 업데이트하는 단일 데이터베이스 행은 피합니다.

특정한 데이터 접근 패턴, 특히 데이터 업데이트 패턴은 데이터베이스에 가해지는 부하를 악화시킬 수 있습니다. 가능하면 이러한 패턴을 피합니다.

이 문서에서는 피해야 할 패턴과 그 대안을 정리합니다.

고빈도 업데이트, 특히 동일한 행에 대한 업데이트#

여러 트랜잭션이 동시에 업데이트하는 단일 데이터베이스 행은 피합니다.

  • 여러 프로세스가 동일한 행을 동시에 업데이트하려고 하면, 각 트랜잭션이 쓰기를 위해 행을 잠그면서 대기열이 생깁니다. 이로 인해 트랜잭션 시간이 크게 늘어나면 Rails 커넥션 풀이 포화되어 애플리케이션 전체가 중단될 수 있습니다.
  • 행을 업데이트할 때마다 PostgreSQL은 새 행 버전을 삽입하고 이전 버전을 삭제합니다. 트래픽이 많은 상황에서는 이 방식이 vacuum과 WAL(write-ahead log) 부하를 유발하여 데이터베이스 성능을 떨어뜨립니다.

이 패턴은 요청마다 집계를 계산하는 비용이 너무 커서 누적 합계를 데이터베이스에 유지할 때 자주 나타납니다. 이러한 집계가 필요하다면, 단일 행에 누적 합계를 두고 최근에 추가된 데이터, 예를 들어 개별 증분값으로 구성된 작은 작업 집합을 함께 두는 방식을 고려합니다.

  • 새 데이터가 들어오면 작업 집합에 추가합니다. 이러한 삽입은 잠금 경합을 일으키지 않습니다.
  • 집계를 계산할 때는 누적 합계와 작업 집합의 실시간 집계를 결합하여 최신 결과를 제공합니다.
  • 작업 집합을 누적 합계에 반영하고 트랜잭션 안에서 작업 집합을 비우는 주기적 job을 추가하여, 읽는 쪽이 처리해야 할 작업량을 제한합니다.

넓은 테이블#

PostgreSQL은 행을 8 KB 페이지 단위로 구성하고 한 번에 한 페이지씩 처리합니다. 테이블의 행 폭을 최소화하면 다음이 개선됩니다.

  • 순차 스캔과 비트맵 인덱스 스캔 성능. 페이지마다 더 많은 행이 들어가면 스캔해야 할 페이지가 줄어들기 때문입니다.
  • Vacuum 성능. vacuum 이 각 페이지에서 더 많은 행을 처리할 수 있기 때문입니다.
  • 업데이트 성능. (HOT 이 아닌) 업데이트 중에는 행 업데이트마다 모든 인덱스를 갱신해야 하기 때문입니다.

넓은 테이블 문제를 줄이는 작업은 데이터베이스 팀의 100 GB 테이블 이니셔티브의 일부입니다. 테이블이 넓을수록 100 GB 안에 담을 수 있는 행이 줄어들기 때문입니다.

테이블에 칼럼을 추가할 때는 새 칼럼의 데이터를 테이블의 다른 칼럼과 일대일 관계로 두면서 그 자체만 조회할 의도가 있는지 검토합니다. 그렇다면 새 칼럼은 새 테이블로 분리하기에 좋은 후보입니다.

이미 이런 방식으로 분리한 테이블이 여럿 있습니다. 예를 들면 다음과 같습니다.

  • search_data는 issues에서 분리했습니다.
  • project_pages_metadata는 projects에서 분리했습니다.
  • merge_request_diff_details는 merge_request_diffs에서 분리했습니다.

데이터 모델 트레이드오프#

users, namespaces, projects 같은 일부 테이블은 매우 넓어질 수 있습니다. 이러한 테이블은 대개 애플리케이션의 중심이며 아주 자주 사용됩니다.

이것이 문제가 되는 이유는 다음과 같습니다.

  • 이러한 칼럼 중 상당수가 인덱스에 포함되어 인덱스 쓰기 증폭을 유발합니다. 테이블의 인덱스 수가 16 개를 넘으면 쿼리 계획 수립에 영향을 주고 경량 잠금(LWLock) 경합으로 이어질 수 있습니다.
  • PostgreSQL의 업데이트는 삭제와 삽입의 조합으로 구현됩니다. 즉, 거의 사용되지 않는 칼럼이라도 업데이트마다 계속 복사됩니다. 이는 생성되는 WAL(write ahead log) 양에 영향을 줍니다.
  • 자주 업데이트되는 칼럼이 있으면 업데이트마다 테이블의 모든 칼럼이 복사됩니다. 이 또한 생성되는 WAL을 늘리고 auto-vacuum의 작업량을 키웁니다.
  • PostgreSQL은 데이터를 페이지 안의 행, 즉 튜플로 저장합니다. 행이 넓으면 페이지당 튜플 수가 줄어들어 읽기 성능에 영향을 줍니다.

이 문제의 해결책 하나는 가장 중요한 칼럼만 기본 테이블에 남기고 나머지는 기본 테이블과 일대일 관계를 갖는 다른 테이블로 추출하는 것입니다. last_activity_at처럼 매우 자주 업데이트되는 칼럼이나, 활성화 토큰처럼 거의 업데이트되거나 사용되지 않는 칼럼이 좋은 후보입니다.

이러한 추출에 따르는 트레이드오프는 인덱스 전용 스캔을 더 이상 사용할 수 없다는 점입니다. 대신 애플리케이션이 새 테이블을 조인하거나 추가 쿼리를 실행해야 합니다. 이때의 성능 영향은 수직 테이블 분리의 이점과 견주어 판단해야 합니다.

이 주제를 다룬 좋은 에피소드가 PostgresFM 팟캐스트에 있습니다. PostgresAI의 @NikolayS와 PgMustard의 @michristofides가 이 주제를 더 깊이 논의합니다: https://postgres.fm/episodes/data-model-trade-offs.

예시#

작성 시점 기준으로 75 개 칼럼을 가진 users 테이블을 살펴봅니다. 위 기준에 들어맞아 추출하기 좋은 후보에 해당하는 칼럼 그룹을 몇 가지 확인할 수 있습니다.

  • encrypted_otp_secret, otp_secret_expires_at 등 OTP 관련 칼럼. 개수가 적고 한 번 채워지면 자주 업데이트되지 않습니다(업데이트가 아예 없을 수도 있습니다).
  • 이메일 확인과 관련된 confirmation_token, confirmation_sent_at, confirmed_at 칼럼. 한 번 채워지면 이후 업데이트될 가능성이 거의 없습니다.
  • password_expires_at, last_credential_check_at, admin_email_unsubscribed_at 같은 타임스탬프. 이런 칼럼은 매우 자주 업데이트되거나 전혀 업데이트되지 않습니다. 별도 테이블에 두는 편이 낫습니다.
  • unlock_token, incoming_email_token, feed_token 같은 각종 토큰(및 관련 칼럼).

그중 users.incoming_email_token에 집중해 봅니다. GitLab.com의 모든 사용자가 이 값을 가지고 있으며 이 토큰은 거의 업데이트되지 않습니다.

이 칼럼을 users에서 새 테이블로 추출하려면 다음 과정을 거쳐야 합니다.

  1. 릴리스 M 예시
    • 테이블을 생성합니다(릴리스 M)
    • 새 테이블에서 읽도록 애플리케이션을 업데이트하고, 아직 데이터가 없으면 원래 칼럼으로 폴백하도록 합니다.
    • 새 테이블에 대한 백필을 시작합니다
  2. 릴리스 N 예시
    • 백필을 수행하는 백그라운드 마이그레이션을 마무리합니다. 이 작업은 필수 중간 정차 버전 다음 릴리스에서 수행해야 합니다.
  3. 릴리스 N + 1 예시
    • 새 테이블에서만 읽고 쓰도록 애플리케이션을 업데이트합니다.
    • 원래 칼럼을 무시합니다. 이렇게 가이드에 설명된 대로 데이터베이스 칼럼을 안전하게 제거하는 절차가 시작됩니다.
  4. 릴리스 N + 2 예시
    • 원래 칼럼을 삭제합니다.
  5. 릴리스 N + 3 예시
    • 원래 칼럼에 대한 무시 규칙을 제거합니다.

이 과정은 길지만, 애플리케이션을 중단시키지 않고 추출을 수행하려면 필요합니다. 완료되고 나면 원래 칼럼과 관련 인덱스가 users 테이블에서 사라지므로 성능이 개선됩니다.