InfoGrab DocsInfoGrab Docs

머지 리퀘스트 성능 가이드라인

요약

새로 도입하는 모든 머지 리퀘스트는 기본적으로 성능을 갖춰야 합니다. 머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 주지 않도록 모든 머지 리퀘스트는 이 문서에 정리된 가이드라인을 준수해야 합니다. 다음 가이드도 함께 읽기를 강력히 권장합니다:

새로 도입하는 모든 머지 리퀘스트는 기본적으로 성능을 갖춰야 합니다.

머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 주지 않도록 모든 머지 리퀘스트는 이 문서에 정리된 가이드라인을 준수해야 합니다. 백엔드 메인테이너와 성능 전문가가 명시적으로 논의하고 합의한 경우가 아니라면 이 규칙에는 예외가 없습니다.

다음 가이드도 함께 읽기를 강력히 권장합니다:

정의#

RFC 2119에 따른 SHOULD의 의미는 다음과 같습니다:

이 단어, 또는 형용사 "RECOMMENDED" 는 특정 상황에서 특정 항목을 무시할 타당한 이유가 존재할 수 있다는 뜻이지만, 다른 방향을 선택하기 전에 그 전체적인 함의를 이해하고 신중하게 검토해야 합니다.

이러한 트레이드오프는 각각 별도의 이슈에 문서화하고 그에 맞게 레이블을 붙이고 원래 이슈와 에픽에 링크하는 것이 이상적입니다.

영향 분석#

요약: 머지 리퀘스트가 성능과 GitLab 설치 환경을 유지·관리하는 사람들에게 미칠 수 있는 영향을 고려합니다.

제출하는 변경 사항은 애플리케이션 자체뿐 아니라 애플리케이션을 유지·관리하고 계속 운영하는 사람들(예: 프로덕션 엔지니어)에게도 영향을 줄 수 있습니다. 따라서 머지 리퀘스트가 애플리케이션뿐 아니라 애플리케이션을 계속 운영하는 사람들에게 미치는 영향까지 신중하게 고려해야 합니다.

사용하는 쿼리가 중요한 서비스를 중단시켜 엔지니어가 밤중에 깨어나는 상황을 만들 가능성이 있는지 확인합니다. 악의적인 사용자가 코드를 악용해 GitLab 인스턴스를 중단시킬 수 있는지, 변경 사항으로 특정 페이지의 로딩이 느려지지는 않는지, 데이터베이스에 부하나 데이터가 충분히 쌓였을 때 실행 시간이 기하급수적으로 늘어나지는 않는지도 함께 확인합니다.

이는 모두 머지 리퀘스트를 제출하기 전에 스스로 확인해야 할 사항입니다. 영향을 평가하기 어려운 경우도 있으며, 그럴 때는 성능 전문가에게 코드 리뷰를 요청해야 합니다. 자세한 내용은 아래 "리뷰" 섹션을 참고합니다.

성능 리뷰#

요약: 영향이 확실하지 않으면 성능 전문가에게 코드 리뷰를 요청합니다.

머지 리퀘스트의 영향을 평가하기 어려운 경우가 있습니다. 이 경우에는 머지 리퀘스트 리뷰어 중 한 명에게 변경 사항 리뷰를 요청해야 합니다. (리뷰어 목록이 있습니다.) 리뷰어는 다시 성능 전문가에게 변경 사항 리뷰를 요청할 수 있습니다.

틀 밖에서 생각하기#

새 기능을 어떻게 사용할지는 사람마다 인식이 다릅니다. 사용자가 기능을 어떻게 쓸지를 항상 함께 고려합니다. 보통 사용자는 매우 이례적인 방식으로 기능을 시험하는데, 무차별 대입을 하거나 제품에 있는 엣지 조건을 악용하는 식입니다.

데이터 집합#

머지 리퀘스트가 처리하는 데이터 집합은 명확히 파악하고 문서화해야 합니다. 기능은 처리할 예상 데이터 집합과 그로 인해 발생할 수 있는 문제를 명확히 문서화해야 합니다.

처리하는 데이터 집합을 강하게 강조하는 다음 예시를 살펴봅니다. 문제는 간단합니다. 어떤 Git 리포지터리에서 파일 목록을 필터링하려고 합니다. 기능은 리포지터리의 모든 파일 목록을 요청하고 그 파일 집합을 대상으로 검색을 수행합니다. 작성자는 그 문제의 컨텍스트에서 다음을 고려해야 합니다:

  1. 지원할 계획인 리포지터리는 무엇인지 확인합니다.
  2. Linux 커널처럼 큰 리포지터리는 얼마나 걸리는지 확인합니다.
  3. 그렇게 큰 데이터 집합을 처리하지 않도록 다르게 할 수 있는 방법이 있는지 확인합니다.
  4. 연산 복잡도를 억제할 안전장치를 만들어야 하는지 확인합니다. 보통 모든 사용자의 서비스를 저하시키는 것보다 단일 사용자의 서비스를 저하시키는 편이 낫습니다.

쿼리 계획 및 데이터베이스 구조#

쿼리 계획은 추가 인덱스가 필요한지, 또는 순차 스캔 사용처럼 비용이 큰 필터링이 있는지 알려줍니다.

각 쿼리 계획은 충분한 크기의 데이터 집합을 대상으로 실행해야 합니다. 예를 들어 특정 조건으로 이슈를 조회한다면 이슈가 적은 경우(수백 개)와 많은 경우(100_000개) 모두에 대해 쿼리를 검증하는 것을 고려해야 합니다. 결과가 몇 개일 때와 수천 개일 때 쿼리가 어떻게 동작하는지 확인합니다.

GitLab을 매우 큰 프로젝트에서 매우 이례적인 방식으로 사용하는 사용자가 있기 때문에 이 작업이 필요합니다. 그렇게 큰 데이터 집합이 사용될 가능성이 낮아 보여도, 고객 중 한 명이 해당 기능에서 문제를 겪을 가능성은 여전히 있습니다.

규모가 커졌을 때 어떻게 동작하는지 미리 이해하는 것이, 설령 그 동작을 그대로 받아들이더라도 바람직한 결과입니다. 더 높은 사용 패턴에 맞춰 기능을 최적화하는 데 무엇이 필요한지 늘 계획이나 이해를 갖고 있어야 합니다.

모든 데이터베이스 구조는 최적화해야 하고, 확장을 쉽게 하기 위해 때로는 과도할 정도로 상세히 기술해야 합니다. 어느 시점부터 가장 어려운 부분은 데이터 마이그레이션입니다. 수백만 행을 마이그레이션하는 일은 항상 번거롭고 애플리케이션에 부정적인 영향을 줄 수 있습니다.

쿼리 계획 리뷰에 관해 도움을 받는 방법을 더 잘 이해하려면 데이터베이스 리뷰를 위한 머지 리퀘스트 준비 방법에 관한 이 섹션을 참고합니다.

쿼리 수#

요약: 머지 리퀘스트는 꼭 필요한 경우가 아니라면 실행되는 SQL 쿼리의 총 수를 늘려서는 안 됩니다.

머지 리퀘스트가 수정하거나 추가한 코드가 실행하는 쿼리의 총 수는 꼭 필요한 경우가 아니라면 늘어나서는 안 됩니다. 기능을 만들 때 추가 쿼리가 필요할 수도 있지만, 그 수를 최소한으로 유지하도록 노력해야 합니다.

예를 들어 여러 데이터베이스 행을 같은 값으로 업데이트하는 기능을 도입한다고 가정합니다. 다음 의사 코드로 작성하는 방법이 매우 매력적으로 보일 수 있고(그리고 쉽기도 합니다):

objects_to_update.each do |object|
  object.some_field = some_value
  object.save
end

이는 업데이트할 객체마다 쿼리를 하나씩 실행한다는 뜻입니다. 이 코드는 업데이트할 행이 충분히 많거나 이 코드의 인스턴스가 여러 개 병렬로 실행되면 데이터베이스에 쉽게 과부하를 줄 수 있습니다. 이 문제는 "N+1 쿼리 문제"로 알려져 있습니다. QueryRecorder로 테스트를 작성해 이를 탐지하고 회귀를 방지할 수 있습니다.

이 경우의 해결 방법은 상당히 간단합니다:

objects_to_update.update_all(some_field: some_value)

이 코드는 ActiveRecord의 update_all 메서드를 사용해 단일 쿼리로 모든 행을 업데이트합니다. 그러면 이 코드가 데이터베이스에 과부하를 주기가 훨씬 어려워집니다.

가능한 경우 읽기 복제본 사용#

데이터베이스(DB) 클러스터에는 읽기 복제본이 여러 개, 프라이머리가 하나 있습니다. DB를 확장하는 전형적인 방식은 읽기 전용 작업을 복제본이 수행하도록 하는 것입니다. 이 부하를 분산하기 위해 로드 밸런싱을 사용합니다. 이렇게 하면 DB에 걸리는 압력이 커질 때 복제본도 함께 늘릴 수 있습니다.

기본적으로 쿼리는 읽기 전용 복제본을 사용하지만, 프라이머리 고착 때문에 GitLab은 일정 시간 동안 프라이머리를 사용하고, 세컨더리가 따라잡거나 30초가 지나면 세컨더리로 돌아갑니다. 이렇게 하면 프라이머리에 불필요한 부하가 상당히 많이 걸릴 수 있습니다. 프라이머리로 전환되는 것을 막기 위해 머지 리퀘스트 56849에서 without_sticky_writes 블록을 도입했습니다. 보통 이 메서드는 같은 세션의 이후 쿼리에 영향을 주지 않는 사소하거나 중요하지 않은 쓰기 이후에 프라이머리 고착을 막는 데 적용할 수 있습니다.

사용 타임스탬프 업데이트가 세션을 프라이머리에 고착시킬 수 있는 시점과 without_sticky_writes로 이를 방지하는 방법은 머지 리퀘스트 57328을 참고합니다

without_sticky_writes 유틸리티에 대응하는 것으로, 머지 리퀘스트 59167에서 use_replicas_for_read_queries를 도입했습니다. 이 메서드는 블록 안의 모든 읽기 전용 쿼리가 현재 프라이머리 고착 여부와 관계없이 읽기 복제본을 사용하도록 강제합니다. 이 유틸리티는 쿼리가 복제 지연을 감수할 수 있는 경우를 위한 것입니다.

내부적으로 데이터베이스 로드 밸런서는 주요 구문(select, update, delete 등)을 기준으로 쿼리를 분류합니다. 판단이 모호하면 쿼리를 프라이머리 데이터베이스로 리다이렉트합니다. 그래서 로드 밸런서가 불필요하게 프라이머리로 쿼리를 보내는 흔한 경우가 몇 가지 있습니다:

  • 커스텀 쿼리(exec_query, execute_statement, execute 등을 통한 쿼리)
  • 읽기 전용 트랜잭션
  • 진행 중인 연결 구성 설정
  • Sidekiq 백그라운드 job

위 쿼리가 실행된 뒤 GitLab은 프라이머리에 고착합니다.

커스텀 읽기 전용 SQL 쿼리를 작성할 때는 execute 대신 select_all을 사용해 가능한 경우 읽기 전용 복제본을 사용하도록 합니다. select_all을 사용하면 쿼리 캐시가 비워지는 것도 방지됩니다.

트랜잭션과 그 밖의 모호한 쿼리가 복제본을 우선 사용하도록 하기 위해 머지 리퀘스트 59086에서 fallback_to_replicas_for_ambiguous_queries를 도입했습니다. 이 MR은 비용이 크고 시간이 오래 걸리는 쿼리를 복제본으로 리다이렉트한 방법의 예시이기도 합니다.

CTE 현명하게 사용하기#

CTE 사용 시 고려할 사항은 관계 객체에 대한 복잡한 쿼리를 참고합니다. 일부 상황에서는 CTE 사용이 (위의 N+1 문제와 비슷하게) 문제가 될 수 있다는 점을 확인했습니다. 특히 AuthorizedProjectsWorker의 CTE처럼 계층적 재귀 CTE 쿼리는 최적화하기가 매우 어렵고 확장되지 않습니다. 계층 구조를 요구하는 새 기능을 구현할 때는 이를 피해야 합니다.

CTE는 훨씬 단순한 여러 경우에 최적화 펜스로 효과적으로 사용되어 왔습니다. 이 예시가 그렇습니다. 지원되는 PostgreSQL 버전에서는 최적화 펜스 동작을 MATERIALIZED 키워드로 활성화해야 합니다. 기본적으로 CTE는 인라인 처리된 뒤 기본적으로 최적화됩니다.

CTE 구문을 만들 때는 Gitlab::SQL::CTE 클래스를 사용합니다. 기본적으로 이 Gitlab::SQL::CTE 클래스는 MATERIALIZED 키워드를 추가해 구체화를 강제합니다.

캐시된 쿼리#

요약: 머지 리퀘스트는 중복된 캐시 쿼리를 실행해서는 안 됩니다.

Rails는 요청이 진행되는 동안 데이터베이스 쿼리 결과를 캐시하는 데 사용하는 SQL 쿼리 캐시를 제공합니다.

캐시된 쿼리를 나쁘게 보는 이유와 탐지 방법을 참고합니다.

머지 리퀘스트가 도입하는 코드는 중복된 캐시 쿼리를 여러 번 실행해서는 안 됩니다.

머지 리퀘스트가 수정하거나 추가한 코드가 실행하는 쿼리의 총 수(캐시된 쿼리 포함)는 꼭 필요한 경우가 아니라면 늘어나서는 안 됩니다. 실행되는 쿼리 수(캐시된 쿼리 포함)는 컬렉션 크기에 의존해서는 안 됩니다. QueryRecorder에 skip_cached 변수를 전달해 이를 탐지하고 회귀를 방지하는 테스트를 작성할 수 있습니다.

예를 들어 CI 파이프라인이 있다고 가정합니다. 모든 파이프라인 빌드는 같은 파이프라인에 속하므로 같은 프로젝트(pipeline.project)에도 속합니다:

pipeline_project = pipeline.project
# Project Load (0.6ms)  SELECT "projects".* FROM "projects" WHERE "projects"."id" = $1 LIMIT $2
build = pipeline.builds.first

build.project == pipeline_project
# CACHE Project Load (0.0ms)  SELECT "projects".* FROM "projects" WHERE "projects"."id" = $1 LIMIT $2
# => true

build.project를 호출하면 데이터베이스에 접근하지 않습니다. 캐시된 결과를 사용하지만 같은 파이프라인 프로젝트 객체를 다시 인스턴스화합니다. 연관된 객체가 메모리 내 같은 객체를 가리키지 않기 때문입니다.

각 빌드를 직렬화하려고 하면 다음과 같습니다:

pipeline.builds.each do |build|
  build.to_json(only: [:name], include: [project: { only: [:name]}])
end

같은 메모리 내 객체를 사용하는 대신 빌드마다 프로젝트 객체를 다시 인스턴스화합니다.

이 경우의 해결 방법은 상당히 간단합니다:

ActiveRecord::Associations::Preloader.new(records: pipeline, associations: [builds: :project]).call

pipeline.builds.each do |build|
  build.to_json(only: [:name], include: [project: { only: [:name]}])
end

ActiveRecord::Associations::Preloader는 같은 프로젝트에 대해 메모리 내 같은 객체를 사용합니다. 이렇게 하면 캐시된 SQL 쿼리를 피하고 빌드마다 프로젝트 객체를 다시 인스턴스화하는 것도 피할 수 있습니다.

루프에서 쿼리 실행#

요약: SQL 쿼리는 꼭 필요한 경우가 아니라면 루프에서 실행해서는 안 됩니다.

루프에서 SQL 쿼리를 실행하면 루프의 반복 횟수에 따라 많은 쿼리가 실행될 수 있습니다. 데이터가 적은 개발 환경에서는 문제없이 동작할 수 있지만, 프로덕션 환경에서는 금세 통제를 벗어날 수 있습니다.

이 방식이 필요한 경우도 있습니다. 그런 경우에는 머지 리퀘스트 설명에 명확히 언급해야 합니다.

배치 처리#

요약: 외부 서비스(예: PostgreSQL, Redis, 오브젝트 스토리지)에 대한 단일 프로세스 반복은 연결 오버헤드를 줄이기 위해 배치 방식으로 실행해야 합니다.

여러 테이블에서 배치 방식으로 행을 가져오는 방법은 이거 로딩 섹션을 참고합니다.

예시: 오브젝트 스토리지에서 여러 파일 삭제#

GCS처럼 오브젝트 스토리지에서 여러 파일을 삭제할 때 단일 REST API 호출을 여러 번 실행하는 것은 비용이 상당히 큰 작업입니다. 이상적으로는 배치 방식으로 처리해야 하며, 예를 들어 S3는 배치 삭제 API를 제공하므로 이러한 접근 방식을 고려하는 것이 좋습니다.

FastDestroyAll 모듈이 이 상황에 도움이 될 수 있습니다. 이 모듈은 여러 데이터베이스 행과 그에 연관된 데이터를 배치 방식으로 제거하는 작은 프레임워크입니다.

타임아웃#

요약: 시스템이 외부 서비스(예: Kubernetes)에 HTTP 호출을 할 때는 합리적인 타임아웃을 설정해야 하며, 그 호출은 Puma 스레드가 아니라 Sidekiq에서 실행해야 합니다.

GitLab은 Kubernetes 클러스터 같은 외부 서비스와 통신해야 하는 경우가 많습니다. 이때 외부 서비스가 요청한 작업을 언제 끝낼지 추정하기 어렵습니다. 예를 들어 어떤 이유로 비활성 상태인 사용자 소유 클러스터라면 GitLab 이 응답을 무한정 기다릴 수 있습니다(예시). 이는 Puma 타임아웃으로 이어질 수 있으므로 무슨 일이 있어도 피해야 합니다.

합리적인 타임아웃을 설정하고 예외를 정상적으로 처리하며 오류를 UI에 표시하거나 내부적으로 로깅해야 합니다.

ReactiveCaching을 사용하는 것이 외부 데이터를 가져오는 가장 좋은 해결책 중 하나입니다.

데이터베이스 트랜잭션 최소화#

요약: 데이터베이스 트랜잭션 중에는 Gitaly 같은 외부 서비스에 접근하지 않아야 합니다. 열린 트랜잭션은 사실상 PostgreSQL 백엔드 연결의 해제를 막기 때문에 심각한 경합 문제로 이어집니다.

트랜잭션을 최대한 짧게 유지하려면 AfterCommitQueue 모듈이나 after_commit AR 훅 사용을 고려합니다.

트랜잭션 중 Gitaly 인스턴스에 보낸 요청 하나가 ~"priority::1" 이슈를 유발한 예시가 있습니다.

이거 로딩(Eager Loading)#

요약: 행을 두 개 이상 조회할 때는 항상 연관 관계를 이거 로딩합니다.

연관 관계를 사용해야 하는 데이터베이스 레코드를 여러 개 조회할 때는 이러한 연관 관계를 반드시 이거 로딩해야 합니다. 예를 들어 블로그 게시물 목록을 조회하면서 작성자를 표시하려면 작성자 연관 관계를 반드시 이거 로딩해야 합니다.

다시 말해 다음 대신:

Post.all.each do |post|
  puts post.author.name
end

다음을 사용해야 합니다:

Post.all.includes(:author).each do |post|
  puts post.author.name
end

또한 이거 로딩 시 회귀를 방지하기 위해 QueryRecoder 테스트 사용을 고려합니다.

메모리 사용량#

요약: 머지 리퀘스트는 꼭 필요한 경우가 아니라면 메모리 사용량을 늘려서는 안 됩니다.

머지 리퀘스트는 코드에 필요한 절대 최소한을 넘어서 GitLab의 메모리 사용량을 늘려서는 안 됩니다. 즉 큰 문서(예: HTML 문서)를 파싱해야 한다면 전체 입력을 메모리에 올리는 대신 가능한 한 스트림으로 파싱하는 것이 가장 좋습니다. 그렇게 할 수 없는 경우도 있으며, 그럴 때는 머지 리퀘스트에 이를 명시적으로 밝혀야 합니다.

UI 요소의 지연 렌더링#

요약: UI 요소는 실제로 필요할 때만 렌더링합니다.

특정 UI 요소는 항상 필요한 것이 아닙니다. 예를 들어 diff 줄 위에 마우스를 올리면 새 댓글을 작성하는 데 사용할 수 있는 작은 아이콘이 표시됩니다. 이런 요소는 항상 렌더링하는 대신 실제로 필요할 때만 렌더링해야 합니다. 그러면 사용되지 않는 Haml/HTML을 생성하는 데 시간을 쓰지 않게 됩니다.

캐싱 사용#

요약: 트랜잭션 중에 여러 번 필요하거나 일정 기간 동안 유지해야 하는 데이터는 메모리나 Redis에 캐시합니다.

트랜잭션 중에 특정 데이터를 여러 곳에서 재사용해야 할 때가 있습니다. 이런 경우 데이터를 가져오기 위해 복잡한 연산을 실행할 필요가 없도록 데이터를 메모리에 캐시해야 합니다. 트랜잭션이 진행되는 동안이 아니라 일정 기간 동안 데이터를 캐시해야 한다면 Redis를 사용해야 합니다.

예를 들어 사용자 이름 멘션이 포함된 텍스트 스니펫을 여러 개 처리한다고 가정합니다(예: Hello @alice와 How are you doing @alice?). 모든 사용자 이름에 대해 사용자 객체를 캐시하면 @alice가 언급될 때마다 같은 쿼리를 실행할 필요가 없어집니다.

트랜잭션별 데이터 캐싱은 RequestStore로 할 수 있습니다(RequestStore.active? 확인을 잊지 않도록 Gitlab::SafeRequestStore를 사용합니다). Redis에 데이터를 캐시하는 것은 Rails의 캐싱 시스템으로 할 수 있습니다.

페이지네이션#

항목 목록을 표로 렌더링하는 각 기능에는 페이지네이션을 포함해야 합니다.

주요 페이지네이션 방식은 다음과 같습니다:

  1. 오프셋 기반 페이지네이션: 사용자가 1 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호와 전체 페이지 수를 봅니다. 이 방식은 GitLab의 모든 컴포넌트에서 잘 지원됩니다.
  2. 전체 개수가 없는 오프셋 기반 페이지네이션: 사용자가 1 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호만 보고 전체 페이지 수는 보지 못합니다.
  3. 키셋 기반 페이지네이션을 사용한 다음 페이지: 사용 가능한 페이지가 몇 개인지 알 수 없으므로 사용자는 다음 페이지로만 이동할 수 있습니다.
  4. 무한 스크롤 페이지네이션: 사용자가 페이지를 스크롤하면 다음 항목이 비동기로 로드됩니다. 앞의 방식과 이점이 완전히 같으므로 이상적입니다.

페이지네이션에서 궁극적으로 확장 가능한 해결책은 키셋 기반 페이지네이션입니다. 다만 현재 GitLab에는 이에 대한 지원이 없습니다. 진행 상황은 API: 키셋 페이지네이션에서 확인할 수 있습니다.

페이지네이션 전략을 선택할 때는 다음을 고려합니다:

  1. 필터링을 통과하는 객체의 수를 계산하는 작업은 매우 비효율적입니다. 이 작업은 보통 몇 초가 걸릴 수 있고 타임아웃이 발생할 수 있습니다.
  2. 1000처럼 높은 서수의 페이지에 해당하는 항목을 가져오는 작업도 매우 비효율적입니다. 데이터베이스가 이전 항목 전체를 정렬하고 순회해야 하며, 이 작업은 보통 데이터베이스에 상당한 부하를 줄 수 있습니다.

페이지네이션과 관련된 유용한 팁은 페이지네이션 가이드라인에서 확인할 수 있습니다.

배지 카운터#

카운터는 항상 잘라내야 합니다. 즉 일정 임계값을 넘는 정확한 숫자는 표시하지 않습니다. 정확한 항목 수를 계산하려면 일치하는 항목의 정확한 수를 알기 위해 사실상 항목을 하나씩 모두 필터링해야 하기 때문입니다.

UX 관점에서는 페이지 로드가 2초 더 걸리는 대가를 치르고 파이프라인이 40000개 이상 있다는 것을 아는 것보다, 파이프라인이 1000개 이상 있다는 것을 보는 편이 흔히 수용 가능합니다.

이 패턴의 예시는 파이프라인과 job 목록입니다. 숫자는 1000+로 잘라내지만 가장 관심이 큰 정보인 실행 중인 파이프라인 수는 정확하게 표시합니다.

이 목적으로 사용할 수 있는 헬퍼 메서드가 있습니다. NumbersHelper.limited_counter_with_delimiter는 계산할 행의 상한을 받습니다.

배지 카운터를 비동기로 로드하는 것이 바람직한 경우도 있습니다. 이렇게 하면 초기 페이지 로드 속도를 높이고 전반적으로 더 나은 사용자 경험을 제공할 수 있습니다.

기능 플래그 사용#

성능에 중요한 요소가 있거나 알려진 성능 결함이 있는 기능에는 그 기능을 비활성화할 수 있는 기능 플래그가 함께 있어야 합니다.

기능 플래그는 팀을 더 행복하게 만듭니다. 사용자가 문제를 알아차리기 전에 시스템을 모니터링하고 빠르게 대응할 수 있기 때문입니다.

성능 결함은 초기 변경 사항을 머지한 직후 곧바로 해결해야 합니다.

기능 플래그를 언제 어떻게 사용해야 하는지는 GitLab 개발의 기능 플래그에서 자세히 확인합니다.

스토리지#

다음과 같은 유형의 스토리지를 고려할 수 있습니다:

  • 로컬 임시 스토리지(매우 단기 스토리지): 이 유형의 스토리지는 /tmp 폴더처럼 시스템이 제공하는 스토리지입니다. 모든 임시 작업에 이상적으로 사용해야 하는 스토리지 유형입니다. 각 노드가 자체 임시 스토리지를 갖기 때문에 확장이 훨씬 쉬워집니다. 이 스토리지는 또한 SSD 기반인 경우가 매우 많아 상당히 빠릅니다. 로컬 스토리지는 TMPDIR 변수를 사용해 애플리케이션에 맞게 구성할 수 있습니다.
  • 공유 임시 스토리지(단기 스토리지): 이 유형의 스토리지는 네트워크 기반 임시 스토리지로, 보통 공통 NFS 서버로 운영합니다. 2020년 2월 기준으로 대부분의 구현에서 여전히 이 유형의 스토리지를 사용합니다. 이를 통해 위의 한도가 훨씬 커질 수는 있지만, 실제로 더 많이 쓸 수 있다는 뜻은 아닙니다. 공유 임시 스토리지는 모든 노드가 공유합니다. 따라서 그 공간을 상당히 많이 사용하거나 연산을 많이 수행하는 job은 애플리케이션 전체에서 다른 모든 job과 요청의 실행에 경합을 만듭니다. 이는 GitLab 전체의 안정성에 영향을 줄 수 있습니다. 이 점을 존중해야 합니다.
  • 공유 영구 스토리지(장기 스토리지): 이 유형의 스토리지는 공유 네트워크 기반 스토리지(예: NFS)를 사용합니다. 이 방식은 주로 노드 몇 개로 구성된 소규모 설치를 운영하는 고객이 사용합니다. 공유 스토리지의 파일은 접근하기 쉽지만, 데이터를 업로드하거나 다운로드하는 job은 다른 모든 job에 심각한 경합을 만들 수 있습니다. 이는 Omnibus가 기본적으로 사용하는 방식이기도 합니다.
  • 오브젝트 기반 영구 스토리지(장기 스토리지): 이 유형의 스토리지는 AWS S3 같은 외부 서비스를 사용합니다. 오브젝트 스토리지는 무한히 확장 가능하고 중복성이 있다고 볼 수 있습니다. 이 스토리지에 접근하려면 보통 파일을 조작하기 위해 내려받아야 합니다. 오브젝트 스토리지는 정의상 파일의 동시 업로드와 다운로드를 무제한으로 처리할 수 있다고 가정할 수 있으므로 궁극적인 해결책으로 볼 수 있습니다. 또한 애플리케이션이 컨테이너 기반 배포(Kubernetes)에서 쉽게 실행되도록 보장하는 데 필요한 궁극적인 해결책입니다.

임시 스토리지#

프로덕션 노드의 스토리지는 실제로 매우 빈약합니다. 애플리케이션은 매우 제한된 임시 스토리지에서도 실행될 수 있도록 만들어야 합니다. 코드가 실행되는 시스템에는 임시 스토리지가 총 1G-10G 있다고 예상할 수 있습니다. 다만 이 스토리지는 실행되는 모든 job 이 실제로 공유합니다. job 이 그 공간에서 100MB 넘게 사용해야 한다면 택한 접근 방식을 재고해야 합니다.

필요가 무엇이든, 파일을 처리해야 한다면 그 사실을 명확히 문서화해야 합니다. 100MB 넘게 필요하다면 더 나은 해결책을 함께 찾아볼 수 있도록 메인테이너에게 도움을 요청하는 것을 고려합니다.

로컬 임시 스토리지#

로컬 스토리지 사용은 바람직한 해결책이며, 특히 애플리케이션을 Kubernetes 클러스터에 배포하는 작업을 하고 있기 때문에 더욱 그렇습니다. Dir.mktmpdir는 예를 들어 아카이브를 추출·생성하거나 기존 데이터를 광범위하게 조작하는 등의 경우에 사용합니다.

Dir.mktmpdir('designs') do |path|
  # do manipulation on path
  # the path will be removed once
  # we go out of the block
end

공유 임시 스토리지#

오브젝트 스토리지가 아니라 디스크 기반 스토리지에 파일을 영속화하려는 경우에는 공유 임시 스토리지를 사용해야 합니다. Workhorse 직접 업로드는 파일을 받을 때 공유 스토리지에 파일을 쓸 수 있고, 이후 GitLab Rails가 이동 작업을 수행할 수 있습니다. 같은 대상 내에서의 이동 작업은 즉시 끝납니다. 시스템은 copy 작업을 수행하는 대신 파일을 새 위치에 다시 연결합니다.

이는 애플리케이션에 복잡성을 추가하므로, 다시 구현하는 대신 잘 확립된 패턴(예: ObjectStorage 컨선)을 재사용하려고 해야 합니다.

그 밖의 모든 용도에서 공유 임시 스토리지 사용은 더 이상 권장되지 않습니다.

영구 스토리지#

오브젝트 스토리지#

영구 파일을 보유하는 모든 기능은 오브젝트 스토리지에 데이터를 저장하는 것을 지원해야 합니다. 노드 간 공유 볼륨 형태의 영구 스토리지는 모든 노드에서 데이터 접근 경합을 만들기 때문에 확장할 수 없습니다.

GitLab은 공유 스토리지 및 오브젝트 스토리지 기반 영구 스토리지를 원활하게 지원하는 ObjectStorage 컨선을 제공합니다.

데이터 접근#

데이터 업로드를 받거나 다운로드를 허용하는 각 기능은 Workhorse 직접 업로드를 사용해야 합니다. 즉 업로드는 Workhorse가 오브젝트 스토리지에 직접 저장해야 하고, 모든 다운로드는 Workhorse가 제공해야 합니다.

Puma를 통한 업로드/다운로드는 업로드가 진행되는 동안 처리 슬롯(스레드) 전체를 차단하기 때문에 비용이 큰 작업입니다.

Puma를 통한 업로드/다운로드에는 작업이 타임아웃될 수 있다는 문제도 있는데, 특히 느린 클라이언트에서 문제가 됩니다. 클라이언트가 업로드/다운로드에 오랜 시간을 쓰면 요청 처리 타임아웃(보통 30초~60초)으로 인해 처리 슬롯이 종료될 수 있습니다.

위와 같은 이유로 모든 파일 업로드와 다운로드에 대해 Workhorse 직접 업로드를 구현해야 합니다.

머지 리퀘스트 성능 가이드라인

GitLab v19.4
원문 보기

요약

새로 도입하는 모든 머지 리퀘스트는 기본적으로 성능을 갖춰야 합니다. 머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 주지 않도록 모든 머지 리퀘스트는 이 문서에 정리된 가이드라인을 준수해야 합니다. 다음 가이드도 함께 읽기를 강력히 권장합니다:

새로 도입하는 모든 머지 리퀘스트는 기본적으로 성능을 갖춰야 합니다.

머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 주지 않도록 모든 머지 리퀘스트는 이 문서에 정리된 가이드라인을 준수해야 합니다. 백엔드 메인테이너와 성능 전문가가 명시적으로 논의하고 합의한 경우가 아니라면 이 규칙에는 예외가 없습니다.

다음 가이드도 함께 읽기를 강력히 권장합니다:

정의#

RFC 2119에 따른 SHOULD의 의미는 다음과 같습니다:

이 단어, 또는 형용사 "RECOMMENDED" 는 특정 상황에서 특정 항목을 무시할 타당한 이유가 존재할 수 있다는 뜻이지만, 다른 방향을 선택하기 전에 그 전체적인 함의를 이해하고 신중하게 검토해야 합니다.

이러한 트레이드오프는 각각 별도의 이슈에 문서화하고 그에 맞게 레이블을 붙이고 원래 이슈와 에픽에 링크하는 것이 이상적입니다.

영향 분석#

요약: 머지 리퀘스트가 성능과 GitLab 설치 환경을 유지·관리하는 사람들에게 미칠 수 있는 영향을 고려합니다.

제출하는 변경 사항은 애플리케이션 자체뿐 아니라 애플리케이션을 유지·관리하고 계속 운영하는 사람들(예: 프로덕션 엔지니어)에게도 영향을 줄 수 있습니다. 따라서 머지 리퀘스트가 애플리케이션뿐 아니라 애플리케이션을 계속 운영하는 사람들에게 미치는 영향까지 신중하게 고려해야 합니다.

사용하는 쿼리가 중요한 서비스를 중단시켜 엔지니어가 밤중에 깨어나는 상황을 만들 가능성이 있는지 확인합니다. 악의적인 사용자가 코드를 악용해 GitLab 인스턴스를 중단시킬 수 있는지, 변경 사항으로 특정 페이지의 로딩이 느려지지는 않는지, 데이터베이스에 부하나 데이터가 충분히 쌓였을 때 실행 시간이 기하급수적으로 늘어나지는 않는지도 함께 확인합니다.

이는 모두 머지 리퀘스트를 제출하기 전에 스스로 확인해야 할 사항입니다. 영향을 평가하기 어려운 경우도 있으며, 그럴 때는 성능 전문가에게 코드 리뷰를 요청해야 합니다. 자세한 내용은 아래 "리뷰" 섹션을 참고합니다.

성능 리뷰#

요약: 영향이 확실하지 않으면 성능 전문가에게 코드 리뷰를 요청합니다.

머지 리퀘스트의 영향을 평가하기 어려운 경우가 있습니다. 이 경우에는 머지 리퀘스트 리뷰어 중 한 명에게 변경 사항 리뷰를 요청해야 합니다. (리뷰어 목록이 있습니다.) 리뷰어는 다시 성능 전문가에게 변경 사항 리뷰를 요청할 수 있습니다.

틀 밖에서 생각하기#

새 기능을 어떻게 사용할지는 사람마다 인식이 다릅니다. 사용자가 기능을 어떻게 쓸지를 항상 함께 고려합니다. 보통 사용자는 매우 이례적인 방식으로 기능을 시험하는데, 무차별 대입을 하거나 제품에 있는 엣지 조건을 악용하는 식입니다.

데이터 집합#

머지 리퀘스트가 처리하는 데이터 집합은 명확히 파악하고 문서화해야 합니다. 기능은 처리할 예상 데이터 집합과 그로 인해 발생할 수 있는 문제를 명확히 문서화해야 합니다.

처리하는 데이터 집합을 강하게 강조하는 다음 예시를 살펴봅니다. 문제는 간단합니다. 어떤 Git 리포지터리에서 파일 목록을 필터링하려고 합니다. 기능은 리포지터리의 모든 파일 목록을 요청하고 그 파일 집합을 대상으로 검색을 수행합니다. 작성자는 그 문제의 컨텍스트에서 다음을 고려해야 합니다:

  1. 지원할 계획인 리포지터리는 무엇인지 확인합니다.
  2. Linux 커널처럼 큰 리포지터리는 얼마나 걸리는지 확인합니다.
  3. 그렇게 큰 데이터 집합을 처리하지 않도록 다르게 할 수 있는 방법이 있는지 확인합니다.
  4. 연산 복잡도를 억제할 안전장치를 만들어야 하는지 확인합니다. 보통 모든 사용자의 서비스를 저하시키는 것보다 단일 사용자의 서비스를 저하시키는 편이 낫습니다.

쿼리 계획 및 데이터베이스 구조#

쿼리 계획은 추가 인덱스가 필요한지, 또는 순차 스캔 사용처럼 비용이 큰 필터링이 있는지 알려줍니다.

각 쿼리 계획은 충분한 크기의 데이터 집합을 대상으로 실행해야 합니다. 예를 들어 특정 조건으로 이슈를 조회한다면 이슈가 적은 경우(수백 개)와 많은 경우(100_000개) 모두에 대해 쿼리를 검증하는 것을 고려해야 합니다. 결과가 몇 개일 때와 수천 개일 때 쿼리가 어떻게 동작하는지 확인합니다.

GitLab을 매우 큰 프로젝트에서 매우 이례적인 방식으로 사용하는 사용자가 있기 때문에 이 작업이 필요합니다. 그렇게 큰 데이터 집합이 사용될 가능성이 낮아 보여도, 고객 중 한 명이 해당 기능에서 문제를 겪을 가능성은 여전히 있습니다.

규모가 커졌을 때 어떻게 동작하는지 미리 이해하는 것이, 설령 그 동작을 그대로 받아들이더라도 바람직한 결과입니다. 더 높은 사용 패턴에 맞춰 기능을 최적화하는 데 무엇이 필요한지 늘 계획이나 이해를 갖고 있어야 합니다.

모든 데이터베이스 구조는 최적화해야 하고, 확장을 쉽게 하기 위해 때로는 과도할 정도로 상세히 기술해야 합니다. 어느 시점부터 가장 어려운 부분은 데이터 마이그레이션입니다. 수백만 행을 마이그레이션하는 일은 항상 번거롭고 애플리케이션에 부정적인 영향을 줄 수 있습니다.

쿼리 계획 리뷰에 관해 도움을 받는 방법을 더 잘 이해하려면 데이터베이스 리뷰를 위한 머지 리퀘스트 준비 방법에 관한 이 섹션을 참고합니다.

쿼리 수#

요약: 머지 리퀘스트는 꼭 필요한 경우가 아니라면 실행되는 SQL 쿼리의 총 수를 늘려서는 안 됩니다.

머지 리퀘스트가 수정하거나 추가한 코드가 실행하는 쿼리의 총 수는 꼭 필요한 경우가 아니라면 늘어나서는 안 됩니다. 기능을 만들 때 추가 쿼리가 필요할 수도 있지만, 그 수를 최소한으로 유지하도록 노력해야 합니다.

예를 들어 여러 데이터베이스 행을 같은 값으로 업데이트하는 기능을 도입한다고 가정합니다. 다음 의사 코드로 작성하는 방법이 매우 매력적으로 보일 수 있고(그리고 쉽기도 합니다):

objects_to_update.each do |object|
  object.some_field = some_value
  object.save
end

이는 업데이트할 객체마다 쿼리를 하나씩 실행한다는 뜻입니다. 이 코드는 업데이트할 행이 충분히 많거나 이 코드의 인스턴스가 여러 개 병렬로 실행되면 데이터베이스에 쉽게 과부하를 줄 수 있습니다. 이 문제는 "N+1 쿼리 문제"로 알려져 있습니다. QueryRecorder로 테스트를 작성해 이를 탐지하고 회귀를 방지할 수 있습니다.

이 경우의 해결 방법은 상당히 간단합니다:

objects_to_update.update_all(some_field: some_value)

이 코드는 ActiveRecord의 update_all 메서드를 사용해 단일 쿼리로 모든 행을 업데이트합니다. 그러면 이 코드가 데이터베이스에 과부하를 주기가 훨씬 어려워집니다.

가능한 경우 읽기 복제본 사용#

데이터베이스(DB) 클러스터에는 읽기 복제본이 여러 개, 프라이머리가 하나 있습니다. DB를 확장하는 전형적인 방식은 읽기 전용 작업을 복제본이 수행하도록 하는 것입니다. 이 부하를 분산하기 위해 로드 밸런싱을 사용합니다. 이렇게 하면 DB에 걸리는 압력이 커질 때 복제본도 함께 늘릴 수 있습니다.

기본적으로 쿼리는 읽기 전용 복제본을 사용하지만, 프라이머리 고착 때문에 GitLab은 일정 시간 동안 프라이머리를 사용하고, 세컨더리가 따라잡거나 30초가 지나면 세컨더리로 돌아갑니다. 이렇게 하면 프라이머리에 불필요한 부하가 상당히 많이 걸릴 수 있습니다. 프라이머리로 전환되는 것을 막기 위해 머지 리퀘스트 56849에서 without_sticky_writes 블록을 도입했습니다. 보통 이 메서드는 같은 세션의 이후 쿼리에 영향을 주지 않는 사소하거나 중요하지 않은 쓰기 이후에 프라이머리 고착을 막는 데 적용할 수 있습니다.

사용 타임스탬프 업데이트가 세션을 프라이머리에 고착시킬 수 있는 시점과 without_sticky_writes로 이를 방지하는 방법은 머지 리퀘스트 57328을 참고합니다

without_sticky_writes 유틸리티에 대응하는 것으로, 머지 리퀘스트 59167에서 use_replicas_for_read_queries를 도입했습니다. 이 메서드는 블록 안의 모든 읽기 전용 쿼리가 현재 프라이머리 고착 여부와 관계없이 읽기 복제본을 사용하도록 강제합니다. 이 유틸리티는 쿼리가 복제 지연을 감수할 수 있는 경우를 위한 것입니다.

내부적으로 데이터베이스 로드 밸런서는 주요 구문(select, update, delete 등)을 기준으로 쿼리를 분류합니다. 판단이 모호하면 쿼리를 프라이머리 데이터베이스로 리다이렉트합니다. 그래서 로드 밸런서가 불필요하게 프라이머리로 쿼리를 보내는 흔한 경우가 몇 가지 있습니다:

  • 커스텀 쿼리(exec_query, execute_statement, execute 등을 통한 쿼리)
  • 읽기 전용 트랜잭션
  • 진행 중인 연결 구성 설정
  • Sidekiq 백그라운드 job

위 쿼리가 실행된 뒤 GitLab은 프라이머리에 고착합니다.

커스텀 읽기 전용 SQL 쿼리를 작성할 때는 execute 대신 select_all을 사용해 가능한 경우 읽기 전용 복제본을 사용하도록 합니다. select_all을 사용하면 쿼리 캐시가 비워지는 것도 방지됩니다.

트랜잭션과 그 밖의 모호한 쿼리가 복제본을 우선 사용하도록 하기 위해 머지 리퀘스트 59086에서 fallback_to_replicas_for_ambiguous_queries를 도입했습니다. 이 MR은 비용이 크고 시간이 오래 걸리는 쿼리를 복제본으로 리다이렉트한 방법의 예시이기도 합니다.

CTE 현명하게 사용하기#

CTE 사용 시 고려할 사항은 관계 객체에 대한 복잡한 쿼리를 참고합니다. 일부 상황에서는 CTE 사용이 (위의 N+1 문제와 비슷하게) 문제가 될 수 있다는 점을 확인했습니다. 특히 AuthorizedProjectsWorker의 CTE처럼 계층적 재귀 CTE 쿼리는 최적화하기가 매우 어렵고 확장되지 않습니다. 계층 구조를 요구하는 새 기능을 구현할 때는 이를 피해야 합니다.

CTE는 훨씬 단순한 여러 경우에 최적화 펜스로 효과적으로 사용되어 왔습니다. 이 예시가 그렇습니다. 지원되는 PostgreSQL 버전에서는 최적화 펜스 동작을 MATERIALIZED 키워드로 활성화해야 합니다. 기본적으로 CTE는 인라인 처리된 뒤 기본적으로 최적화됩니다.

CTE 구문을 만들 때는 Gitlab::SQL::CTE 클래스를 사용합니다. 기본적으로 이 Gitlab::SQL::CTE 클래스는 MATERIALIZED 키워드를 추가해 구체화를 강제합니다.

캐시된 쿼리#

요약: 머지 리퀘스트는 중복된 캐시 쿼리를 실행해서는 안 됩니다.

Rails는 요청이 진행되는 동안 데이터베이스 쿼리 결과를 캐시하는 데 사용하는 SQL 쿼리 캐시를 제공합니다.

캐시된 쿼리를 나쁘게 보는 이유와 탐지 방법을 참고합니다.

머지 리퀘스트가 도입하는 코드는 중복된 캐시 쿼리를 여러 번 실행해서는 안 됩니다.

머지 리퀘스트가 수정하거나 추가한 코드가 실행하는 쿼리의 총 수(캐시된 쿼리 포함)는 꼭 필요한 경우가 아니라면 늘어나서는 안 됩니다. 실행되는 쿼리 수(캐시된 쿼리 포함)는 컬렉션 크기에 의존해서는 안 됩니다. QueryRecorder에 skip_cached 변수를 전달해 이를 탐지하고 회귀를 방지하는 테스트를 작성할 수 있습니다.

예를 들어 CI 파이프라인이 있다고 가정합니다. 모든 파이프라인 빌드는 같은 파이프라인에 속하므로 같은 프로젝트(pipeline.project)에도 속합니다:

pipeline_project = pipeline.project
# Project Load (0.6ms)  SELECT "projects".* FROM "projects" WHERE "projects"."id" = $1 LIMIT $2
build = pipeline.builds.first

build.project == pipeline_project
# CACHE Project Load (0.0ms)  SELECT "projects".* FROM "projects" WHERE "projects"."id" = $1 LIMIT $2
# => true

build.project를 호출하면 데이터베이스에 접근하지 않습니다. 캐시된 결과를 사용하지만 같은 파이프라인 프로젝트 객체를 다시 인스턴스화합니다. 연관된 객체가 메모리 내 같은 객체를 가리키지 않기 때문입니다.

각 빌드를 직렬화하려고 하면 다음과 같습니다:

pipeline.builds.each do |build|
  build.to_json(only: [:name], include: [project: { only: [:name]}])
end

같은 메모리 내 객체를 사용하는 대신 빌드마다 프로젝트 객체를 다시 인스턴스화합니다.

이 경우의 해결 방법은 상당히 간단합니다:

ActiveRecord::Associations::Preloader.new(records: pipeline, associations: [builds: :project]).call

pipeline.builds.each do |build|
  build.to_json(only: [:name], include: [project: { only: [:name]}])
end

ActiveRecord::Associations::Preloader는 같은 프로젝트에 대해 메모리 내 같은 객체를 사용합니다. 이렇게 하면 캐시된 SQL 쿼리를 피하고 빌드마다 프로젝트 객체를 다시 인스턴스화하는 것도 피할 수 있습니다.

루프에서 쿼리 실행#

요약: SQL 쿼리는 꼭 필요한 경우가 아니라면 루프에서 실행해서는 안 됩니다.

루프에서 SQL 쿼리를 실행하면 루프의 반복 횟수에 따라 많은 쿼리가 실행될 수 있습니다. 데이터가 적은 개발 환경에서는 문제없이 동작할 수 있지만, 프로덕션 환경에서는 금세 통제를 벗어날 수 있습니다.

이 방식이 필요한 경우도 있습니다. 그런 경우에는 머지 리퀘스트 설명에 명확히 언급해야 합니다.

배치 처리#

요약: 외부 서비스(예: PostgreSQL, Redis, 오브젝트 스토리지)에 대한 단일 프로세스 반복은 연결 오버헤드를 줄이기 위해 배치 방식으로 실행해야 합니다.

여러 테이블에서 배치 방식으로 행을 가져오는 방법은 이거 로딩 섹션을 참고합니다.

예시: 오브젝트 스토리지에서 여러 파일 삭제#

GCS처럼 오브젝트 스토리지에서 여러 파일을 삭제할 때 단일 REST API 호출을 여러 번 실행하는 것은 비용이 상당히 큰 작업입니다. 이상적으로는 배치 방식으로 처리해야 하며, 예를 들어 S3는 배치 삭제 API를 제공하므로 이러한 접근 방식을 고려하는 것이 좋습니다.

FastDestroyAll 모듈이 이 상황에 도움이 될 수 있습니다. 이 모듈은 여러 데이터베이스 행과 그에 연관된 데이터를 배치 방식으로 제거하는 작은 프레임워크입니다.

타임아웃#

요약: 시스템이 외부 서비스(예: Kubernetes)에 HTTP 호출을 할 때는 합리적인 타임아웃을 설정해야 하며, 그 호출은 Puma 스레드가 아니라 Sidekiq에서 실행해야 합니다.

GitLab은 Kubernetes 클러스터 같은 외부 서비스와 통신해야 하는 경우가 많습니다. 이때 외부 서비스가 요청한 작업을 언제 끝낼지 추정하기 어렵습니다. 예를 들어 어떤 이유로 비활성 상태인 사용자 소유 클러스터라면 GitLab 이 응답을 무한정 기다릴 수 있습니다(예시). 이는 Puma 타임아웃으로 이어질 수 있으므로 무슨 일이 있어도 피해야 합니다.

합리적인 타임아웃을 설정하고 예외를 정상적으로 처리하며 오류를 UI에 표시하거나 내부적으로 로깅해야 합니다.

ReactiveCaching을 사용하는 것이 외부 데이터를 가져오는 가장 좋은 해결책 중 하나입니다.

데이터베이스 트랜잭션 최소화#

요약: 데이터베이스 트랜잭션 중에는 Gitaly 같은 외부 서비스에 접근하지 않아야 합니다. 열린 트랜잭션은 사실상 PostgreSQL 백엔드 연결의 해제를 막기 때문에 심각한 경합 문제로 이어집니다.

트랜잭션을 최대한 짧게 유지하려면 AfterCommitQueue 모듈이나 after_commit AR 훅 사용을 고려합니다.

트랜잭션 중 Gitaly 인스턴스에 보낸 요청 하나가 ~"priority::1" 이슈를 유발한 예시가 있습니다.

이거 로딩(Eager Loading)#

요약: 행을 두 개 이상 조회할 때는 항상 연관 관계를 이거 로딩합니다.

연관 관계를 사용해야 하는 데이터베이스 레코드를 여러 개 조회할 때는 이러한 연관 관계를 반드시 이거 로딩해야 합니다. 예를 들어 블로그 게시물 목록을 조회하면서 작성자를 표시하려면 작성자 연관 관계를 반드시 이거 로딩해야 합니다.

다시 말해 다음 대신:

Post.all.each do |post|
  puts post.author.name
end

다음을 사용해야 합니다:

Post.all.includes(:author).each do |post|
  puts post.author.name
end

또한 이거 로딩 시 회귀를 방지하기 위해 QueryRecoder 테스트 사용을 고려합니다.

메모리 사용량#

요약: 머지 리퀘스트는 꼭 필요한 경우가 아니라면 메모리 사용량을 늘려서는 안 됩니다.

머지 리퀘스트는 코드에 필요한 절대 최소한을 넘어서 GitLab의 메모리 사용량을 늘려서는 안 됩니다. 즉 큰 문서(예: HTML 문서)를 파싱해야 한다면 전체 입력을 메모리에 올리는 대신 가능한 한 스트림으로 파싱하는 것이 가장 좋습니다. 그렇게 할 수 없는 경우도 있으며, 그럴 때는 머지 리퀘스트에 이를 명시적으로 밝혀야 합니다.

UI 요소의 지연 렌더링#

요약: UI 요소는 실제로 필요할 때만 렌더링합니다.

특정 UI 요소는 항상 필요한 것이 아닙니다. 예를 들어 diff 줄 위에 마우스를 올리면 새 댓글을 작성하는 데 사용할 수 있는 작은 아이콘이 표시됩니다. 이런 요소는 항상 렌더링하는 대신 실제로 필요할 때만 렌더링해야 합니다. 그러면 사용되지 않는 Haml/HTML을 생성하는 데 시간을 쓰지 않게 됩니다.

캐싱 사용#

요약: 트랜잭션 중에 여러 번 필요하거나 일정 기간 동안 유지해야 하는 데이터는 메모리나 Redis에 캐시합니다.

트랜잭션 중에 특정 데이터를 여러 곳에서 재사용해야 할 때가 있습니다. 이런 경우 데이터를 가져오기 위해 복잡한 연산을 실행할 필요가 없도록 데이터를 메모리에 캐시해야 합니다. 트랜잭션이 진행되는 동안이 아니라 일정 기간 동안 데이터를 캐시해야 한다면 Redis를 사용해야 합니다.

예를 들어 사용자 이름 멘션이 포함된 텍스트 스니펫을 여러 개 처리한다고 가정합니다(예: Hello @alice와 How are you doing @alice?). 모든 사용자 이름에 대해 사용자 객체를 캐시하면 @alice가 언급될 때마다 같은 쿼리를 실행할 필요가 없어집니다.

트랜잭션별 데이터 캐싱은 RequestStore로 할 수 있습니다(RequestStore.active? 확인을 잊지 않도록 Gitlab::SafeRequestStore를 사용합니다). Redis에 데이터를 캐시하는 것은 Rails의 캐싱 시스템으로 할 수 있습니다.

페이지네이션#

항목 목록을 표로 렌더링하는 각 기능에는 페이지네이션을 포함해야 합니다.

주요 페이지네이션 방식은 다음과 같습니다:

  1. 오프셋 기반 페이지네이션: 사용자가 1 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호와 전체 페이지 수를 봅니다. 이 방식은 GitLab의 모든 컴포넌트에서 잘 지원됩니다.
  2. 전체 개수가 없는 오프셋 기반 페이지네이션: 사용자가 1 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호만 보고 전체 페이지 수는 보지 못합니다.
  3. 키셋 기반 페이지네이션을 사용한 다음 페이지: 사용 가능한 페이지가 몇 개인지 알 수 없으므로 사용자는 다음 페이지로만 이동할 수 있습니다.
  4. 무한 스크롤 페이지네이션: 사용자가 페이지를 스크롤하면 다음 항목이 비동기로 로드됩니다. 앞의 방식과 이점이 완전히 같으므로 이상적입니다.

페이지네이션에서 궁극적으로 확장 가능한 해결책은 키셋 기반 페이지네이션입니다. 다만 현재 GitLab에는 이에 대한 지원이 없습니다. 진행 상황은 API: 키셋 페이지네이션에서 확인할 수 있습니다.

페이지네이션 전략을 선택할 때는 다음을 고려합니다:

  1. 필터링을 통과하는 객체의 수를 계산하는 작업은 매우 비효율적입니다. 이 작업은 보통 몇 초가 걸릴 수 있고 타임아웃이 발생할 수 있습니다.
  2. 1000처럼 높은 서수의 페이지에 해당하는 항목을 가져오는 작업도 매우 비효율적입니다. 데이터베이스가 이전 항목 전체를 정렬하고 순회해야 하며, 이 작업은 보통 데이터베이스에 상당한 부하를 줄 수 있습니다.

페이지네이션과 관련된 유용한 팁은 페이지네이션 가이드라인에서 확인할 수 있습니다.

배지 카운터#

카운터는 항상 잘라내야 합니다. 즉 일정 임계값을 넘는 정확한 숫자는 표시하지 않습니다. 정확한 항목 수를 계산하려면 일치하는 항목의 정확한 수를 알기 위해 사실상 항목을 하나씩 모두 필터링해야 하기 때문입니다.

UX 관점에서는 페이지 로드가 2초 더 걸리는 대가를 치르고 파이프라인이 40000개 이상 있다는 것을 아는 것보다, 파이프라인이 1000개 이상 있다는 것을 보는 편이 흔히 수용 가능합니다.

이 패턴의 예시는 파이프라인과 job 목록입니다. 숫자는 1000+로 잘라내지만 가장 관심이 큰 정보인 실행 중인 파이프라인 수는 정확하게 표시합니다.

이 목적으로 사용할 수 있는 헬퍼 메서드가 있습니다. NumbersHelper.limited_counter_with_delimiter는 계산할 행의 상한을 받습니다.

배지 카운터를 비동기로 로드하는 것이 바람직한 경우도 있습니다. 이렇게 하면 초기 페이지 로드 속도를 높이고 전반적으로 더 나은 사용자 경험을 제공할 수 있습니다.

기능 플래그 사용#

성능에 중요한 요소가 있거나 알려진 성능 결함이 있는 기능에는 그 기능을 비활성화할 수 있는 기능 플래그가 함께 있어야 합니다.

기능 플래그는 팀을 더 행복하게 만듭니다. 사용자가 문제를 알아차리기 전에 시스템을 모니터링하고 빠르게 대응할 수 있기 때문입니다.

성능 결함은 초기 변경 사항을 머지한 직후 곧바로 해결해야 합니다.

기능 플래그를 언제 어떻게 사용해야 하는지는 GitLab 개발의 기능 플래그에서 자세히 확인합니다.

스토리지#

다음과 같은 유형의 스토리지를 고려할 수 있습니다:

  • 로컬 임시 스토리지(매우 단기 스토리지): 이 유형의 스토리지는 /tmp 폴더처럼 시스템이 제공하는 스토리지입니다. 모든 임시 작업에 이상적으로 사용해야 하는 스토리지 유형입니다. 각 노드가 자체 임시 스토리지를 갖기 때문에 확장이 훨씬 쉬워집니다. 이 스토리지는 또한 SSD 기반인 경우가 매우 많아 상당히 빠릅니다. 로컬 스토리지는 TMPDIR 변수를 사용해 애플리케이션에 맞게 구성할 수 있습니다.
  • 공유 임시 스토리지(단기 스토리지): 이 유형의 스토리지는 네트워크 기반 임시 스토리지로, 보통 공통 NFS 서버로 운영합니다. 2020년 2월 기준으로 대부분의 구현에서 여전히 이 유형의 스토리지를 사용합니다. 이를 통해 위의 한도가 훨씬 커질 수는 있지만, 실제로 더 많이 쓸 수 있다는 뜻은 아닙니다. 공유 임시 스토리지는 모든 노드가 공유합니다. 따라서 그 공간을 상당히 많이 사용하거나 연산을 많이 수행하는 job은 애플리케이션 전체에서 다른 모든 job과 요청의 실행에 경합을 만듭니다. 이는 GitLab 전체의 안정성에 영향을 줄 수 있습니다. 이 점을 존중해야 합니다.
  • 공유 영구 스토리지(장기 스토리지): 이 유형의 스토리지는 공유 네트워크 기반 스토리지(예: NFS)를 사용합니다. 이 방식은 주로 노드 몇 개로 구성된 소규모 설치를 운영하는 고객이 사용합니다. 공유 스토리지의 파일은 접근하기 쉽지만, 데이터를 업로드하거나 다운로드하는 job은 다른 모든 job에 심각한 경합을 만들 수 있습니다. 이는 Omnibus가 기본적으로 사용하는 방식이기도 합니다.
  • 오브젝트 기반 영구 스토리지(장기 스토리지): 이 유형의 스토리지는 AWS S3 같은 외부 서비스를 사용합니다. 오브젝트 스토리지는 무한히 확장 가능하고 중복성이 있다고 볼 수 있습니다. 이 스토리지에 접근하려면 보통 파일을 조작하기 위해 내려받아야 합니다. 오브젝트 스토리지는 정의상 파일의 동시 업로드와 다운로드를 무제한으로 처리할 수 있다고 가정할 수 있으므로 궁극적인 해결책으로 볼 수 있습니다. 또한 애플리케이션이 컨테이너 기반 배포(Kubernetes)에서 쉽게 실행되도록 보장하는 데 필요한 궁극적인 해결책입니다.

임시 스토리지#

프로덕션 노드의 스토리지는 실제로 매우 빈약합니다. 애플리케이션은 매우 제한된 임시 스토리지에서도 실행될 수 있도록 만들어야 합니다. 코드가 실행되는 시스템에는 임시 스토리지가 총 1G-10G 있다고 예상할 수 있습니다. 다만 이 스토리지는 실행되는 모든 job 이 실제로 공유합니다. job 이 그 공간에서 100MB 넘게 사용해야 한다면 택한 접근 방식을 재고해야 합니다.

필요가 무엇이든, 파일을 처리해야 한다면 그 사실을 명확히 문서화해야 합니다. 100MB 넘게 필요하다면 더 나은 해결책을 함께 찾아볼 수 있도록 메인테이너에게 도움을 요청하는 것을 고려합니다.

로컬 임시 스토리지#

로컬 스토리지 사용은 바람직한 해결책이며, 특히 애플리케이션을 Kubernetes 클러스터에 배포하는 작업을 하고 있기 때문에 더욱 그렇습니다. Dir.mktmpdir는 예를 들어 아카이브를 추출·생성하거나 기존 데이터를 광범위하게 조작하는 등의 경우에 사용합니다.

Dir.mktmpdir('designs') do |path|
  # do manipulation on path
  # the path will be removed once
  # we go out of the block
end

공유 임시 스토리지#

오브젝트 스토리지가 아니라 디스크 기반 스토리지에 파일을 영속화하려는 경우에는 공유 임시 스토리지를 사용해야 합니다. Workhorse 직접 업로드는 파일을 받을 때 공유 스토리지에 파일을 쓸 수 있고, 이후 GitLab Rails가 이동 작업을 수행할 수 있습니다. 같은 대상 내에서의 이동 작업은 즉시 끝납니다. 시스템은 copy 작업을 수행하는 대신 파일을 새 위치에 다시 연결합니다.

이는 애플리케이션에 복잡성을 추가하므로, 다시 구현하는 대신 잘 확립된 패턴(예: ObjectStorage 컨선)을 재사용하려고 해야 합니다.

그 밖의 모든 용도에서 공유 임시 스토리지 사용은 더 이상 권장되지 않습니다.

영구 스토리지#

오브젝트 스토리지#

영구 파일을 보유하는 모든 기능은 오브젝트 스토리지에 데이터를 저장하는 것을 지원해야 합니다. 노드 간 공유 볼륨 형태의 영구 스토리지는 모든 노드에서 데이터 접근 경합을 만들기 때문에 확장할 수 없습니다.

GitLab은 공유 스토리지 및 오브젝트 스토리지 기반 영구 스토리지를 원활하게 지원하는 ObjectStorage 컨선을 제공합니다.

데이터 접근#

데이터 업로드를 받거나 다운로드를 허용하는 각 기능은 Workhorse 직접 업로드를 사용해야 합니다. 즉 업로드는 Workhorse가 오브젝트 스토리지에 직접 저장해야 하고, 모든 다운로드는 Workhorse가 제공해야 합니다.

Puma를 통한 업로드/다운로드는 업로드가 진행되는 동안 처리 슬롯(스레드) 전체를 차단하기 때문에 비용이 큰 작업입니다.

Puma를 통한 업로드/다운로드에는 작업이 타임아웃될 수 있다는 문제도 있는데, 특히 느린 클라이언트에서 문제가 됩니다. 클라이언트가 업로드/다운로드에 오랜 시간을 쓰면 요청 처리 타임아웃(보통 30초~60초)으로 인해 처리 슬롯이 종료될 수 있습니다.

위와 같은 이유로 모든 파일 업로드와 다운로드에 대해 Workhorse 직접 업로드를 구현해야 합니다.