InfoGrab DocsInfoGrab Docs

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

요약

새로 도입되는 모든 머지 리퀘스트는 기본적으로 성능을 고려해야 합니다. 머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 미치지 않도록 모든 머지 리퀘스트는 이 문서에 제시된 가이드라인을 준수해야(SHOULD) 합니다.

새로 도입되는 모든 머지 리퀘스트는 기본적으로 성능을 고려해야 합니다.

머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 미치지 않도록 모든 머지 리퀘스트는 이 문서에 제시된 가이드라인을 준수해야(SHOULD) 합니다. 백엔드 메인테이너와 성능 전문가가 특별히 논의하고 합의한 경우를 제외하고는 이 규칙에 예외는 없습니다.

또한 다음 가이드를 읽어보시길 강력히 권장합니다:

정의#

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

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

이러한 트레이드오프는 각각 별도의 이슈에 문서화하고, 적절히 라벨을 붙이고, 원본 이슈 및 에픽에 링크하는 것이 이상적입니다.

영향 분석#

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

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

사용된 쿼리가 중요한 서비스를 다운시켜 엔지니어들이 밤에 깨어나야 할 상황을 유발할 가능성이 있나요? 악의적인 사용자가 코드를 악용하여 GitLab 인스턴스를 다운시킬 수 있나요? 변경 사항으로 인해 특정 페이지의 로딩이 느려지나요? 데이터베이스에 충분한 부하나 데이터가 있을 경우 실행 시간이 기하급수적으로 증가하나요?

이 모든 것은 머지 리퀘스트를 제출하기 전에 스스로에게 물어봐야 할 질문들입니다. 영향을 평가하기 어려운 경우에는 성능 전문가에게 코드 리뷰를 요청해야 합니다. 자세한 내용은 아래 "리뷰" 섹션을 참조하세요.

성능 리뷰#

요약: 영향이 확실하지 않은 경우 성능 전문가에게 코드 리뷰를 요청하세요.

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

틀 밖에서 생각하기#

모든 사람은 새로운 기능을 사용하는 방식에 대한 자신만의 인식을 가지고 있습니다. 대신 사용자들이 기능을 어떻게 사용할지 항상 고려하세요. 보통 사용자들은 브루트 포싱이나 우리가 가진 엣지 조건을 남용하는 것처럼 매우 비전통적인 방식으로 기능을 테스트합니다.

데이터 집합#

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

처리되는 데이터 집합을 강하게 강조하는 다음 예시를 생각해보면, 문제는 간단합니다: 어떤 Git 리포지터리에서 파일 목록을 필터링하려 합니다. 기능은 리포지터리의 모든 파일 목록을 요청하고 해당 파일 집합을 검색합니다. 작성자로서 이 문제의 맥락에서 다음 사항을 고려해야 합니다:

  • 어떤 리포지터리를 지원할 계획인가요?

  • Linux 커널처럼 큰 리포지터리는 얼마나 걸리나요?

  • 이렇게 큰 데이터 집합을 처리하지 않도록 다르게 할 수 있는 방법이 있나요?

  • 연산 복잡도를 제한하기 위한 안전 장치를 구축해야 하나요? 보통 모든 사용자 대신 단일 사용자의 서비스를 저하시키는 것이 더 낫습니다.

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

쿼리 계획은 추가 인덱스가 필요한지, 또는 순차 스캔 사용과 같은 비용이 많이 드는 필터링이 있는지 알려줄 수 있습니다.

각 쿼리 계획은 충분한 크기의 데이터 집합에 대해 실행되어야 합니다. 예를 들어, 특정 조건으로 이슈를 검색하는 경우, 소수(수백 개)와 다수(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에 대한 압력이 증가함에 따라 복제본도 함께 성장할 수 있습니다.

기본적으로 쿼리는 읽기 전용 복제본을 사용하지만, 프라이머리 고착(primary sticking)으로 인해 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는 이 예시와 같이 많은 단순한 경우에서 최적화 펜스(optimization fence)로 효과적으로 사용되었습니다. 지원되는 PostgreSQL 버전에서 최적화 펜스 동작은 MATERIALIZED 키워드로 활성화해야 합니다. 기본적으로 CTE는 인라인 처리된 후 기본적으로 최적화됩니다.

CTE 구문을 구성할 때는 Gitlab::SQL::CTE 클래스를 사용하세요. 기본적으로 이 Gitlab::SQL::CTE 클래스는 MATERIALIZED 키워드를 추가하여 구체화(materialization)를 강제합니다.

GitLab 14.0으로 업그레이드하려면 PostgreSQL 12 이상이 필요합니다.

캐시된 쿼리#

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

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

캐시된 쿼리가 나쁜 이유탐지 방법을 참고하세요.

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

머지 리퀘스트에서 수정하거나 추가한 코드가 실행하는 쿼리의 총 수(캐시된 쿼리 포함)는 절대적으로 필요하지 않은 한 증가해서는 안 됩니다. 실행된 쿼리의 수(캐시된 쿼리 포함)는 컬렉션 크기에 의존해서는 안 됩니다. QueryRecorderskip_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, Object Storage)에 대한 단일 프로세스 반복은 연결 오버헤드를 줄이기 위해 배치 방식으로 실행되어야 합니다.

배치 방식으로 다양한 테이블에서 행을 가져오는 것은 이거 로딩(Eager Loading) 섹션을 참고하세요.

예시: Object Storage에서 여러 파일 삭제#

GCS와 같은 오브젝트 스토리지에서 여러 파일을 삭제할 때, 단일 REST API 호출을 여러 번 실행하는 것은 꽤 비용이 많이 드는 프로세스입니다. 이상적으로는 배치 방식으로 수행해야 합니다. 예를 들어, S3는 배치 삭제 API를 제공하므로 이러한 접근 방식을 고려하는 것이 좋습니다.

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

타임아웃#

요약: 시스템이 외부 서비스(예: 쿠버네티스)에 HTTP 호출을 할 때 합리적인 타임아웃을 설정해야 하며, Puma 스레드가 아닌 Sidekiq에서 실행되어야 합니다.

GitLab은 종종 쿠버네티스 클러스터와 같은 외부 서비스와 통신해야 합니다. 이 경우, 외부 서비스가 요청한 프로세스를 언제 완료할지 추정하기 어렵습니다. 예를 들어, 어떤 이유로 비활성 상태인 사용자 소유의 클러스터라면 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

또한 이거 로딩 시 회귀를 방지하기 위해 QueryRecorder 테스트를 사용하는 것을 고려하세요.

메모리 사용량#

요약: 머지 리퀘스트는 절대적으로 필요하지 않은 한 메모리 사용량을 늘려서는 안 됩니다.

머지 리퀘스트는 코드에서 요구하는 절대 최소한을 초과하여 GitLab의 메모리 사용량을 늘려서는 안 됩니다. 즉, 큰 문서(예: HTML 문서)를 파싱해야 하는 경우, 전체 입력을 메모리에 로드하는 대신 가능한 경우 스트림으로 파싱하는 것이 좋습니다. 이것이 불가능한 경우에는 머지 리퀘스트에서 명시적으로 언급해야 합니다.

UI 요소의 지연 렌더링#

요약: 실제로 필요할 때만 UI 요소를 렌더링하세요.

특정 UI 요소가 항상 필요하지 않을 수 있습니다. 예를 들어, diff 줄 위로 마우스를 가져갈 때 새 댓글을 작성하는 데 사용할 수 있는 작은 아이콘이 표시됩니다. 이러한 종류의 요소는 항상 렌더링하는 대신 실제로 필요할 때만 렌더링해야 합니다. 이렇게 하면 사용되지 않을 때 Haml/HTML을 생성하는 데 시간을 낭비하지 않습니다.

캐싱 사용#

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

트랜잭션 중에 여러 곳에서 특정 데이터를 재사용해야 할 때가 있습니다. 이 경우 데이터를 가져오기 위한 복잡한 연산의 필요성을 없애기 위해 메모리에 캐시해야 합니다. 트랜잭션 기간이 아닌 특정 시간 동안 데이터를 캐시해야 하는 경우에는 Redis를 사용해야 합니다.

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

트랜잭션별 데이터 캐싱은 RequestStore를 사용하여 수행할 수 있습니다(RequestStore.active?를 확인해야 하는 것을 기억하지 않아도 되도록 Gitlab::SafeRequestStore를 사용하세요). Redis에 데이터를 캐시하는 것은 Rails의 캐싱 시스템을 사용하여 수행할 수 있습니다.

페이지네이션#

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

주요 페이지네이션 스타일은 다음과 같습니다:

  • 오프셋 기반 페이지네이션: 사용자가 1과 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호와 총 페이지 수를 볼 수 있습니다. 이 스타일은 GitLab의 모든 컴포넌트에서 잘 지원됩니다.

  • 카운트 없는 오프셋 기반 페이지네이션: 사용자가 1과 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호만 볼 수 있고, 총 페이지 수는 볼 수 없습니다.

  • 키셋 기반 페이지네이션을 사용한 다음 페이지: 사용자는 다음 페이지로만 이동할 수 있으며, 사용 가능한 페이지가 몇 개인지 알 수 없습니다.

  • 무한 스크롤 페이지네이션: 사용자가 페이지를 스크롤하면 다음 항목이 비동기적으로 로드됩니다. 이전 방식과 동일한 이점을 가지므로 이상적입니다.

궁극적으로 확장 가능한 페이지네이션 솔루션은 키셋 기반 페이지네이션을 사용하는 것입니다. 그러나 현재 GitLab에서는 이를 지원하지 않습니다. API: 키셋 페이지네이션에서 진행 상황을 확인할 수 있습니다.

페이지네이션 전략을 선택할 때 다음을 고려하세요:

  • 필터링을 통과하는 객체의 수를 계산하는 것은 매우 비효율적이며, 이 작업은 일반적으로 몇 초가 걸릴 수 있고 타임아웃이 발생할 수 있습니다.

  • 1000과 같이 높은 서수의 페이지에 대한 항목을 가져오는 것은 매우 비효율적입니다. 데이터베이스가 이전 모든 항목을 정렬하고 반복해야 하며, 이 작업은 일반적으로 데이터베이스에 상당한 부하를 줄 수 있습니다.

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

배지 카운터#

카운터는 항상 잘라내야(truncated) 합니다. 즉, 특정 임계값을 초과하는 정확한 숫자를 표시하지 않으려는 것입니다. 정확한 항목 수를 계산하려는 경우, 일치하는 항목의 정확한 수를 알기 위해 각 항목을 효과적으로 필터링해야 하기 때문입니다.

~UX 관점에서 40000개 이상의 파이프라인이 있다는 것을 알면서 페이지 로드가 2초 더 길어지는 것보다, 1000개 이상의 파이프라인이 있다는 것을 보는 것이 종종 수용 가능합니다.

이 패턴의 예시는 파이프라인 및 job 목록입니다. 숫자를 1000+로 잘라내지만, 가장 흥미로운 정보인 실행 중인 파이프라인의 정확한 수는 표시합니다.

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

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

기능 플래그 사용#

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

기능 플래그는 팀이 더 행복해지도록 합니다. 왜냐하면 사용자들이 문제를 알아차리기 전에 시스템을 모니터링하고 빠르게 대응할 수 있기 때문입니다.

성능 결함은 초기 변경 사항을 머지한 직후에 즉시 해결해야 합니다.

기능 플래그를 언제, 어떻게 사용해야 하는지에 대한 자세한 내용은 GitLab 개발의 기능 플래그를 읽어보세요.

스토리지#

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

  • 로컬 임시 스토리지 (매우 단기 스토리지) 이 유형의 스토리지는 /tmp 폴더와 같이 시스템에서 제공하는 스토리지입니다. 모든 임시 작업에 이상적으로 사용해야 하는 스토리지 유형입니다. 각 노드에 자체 임시 스토리지가 있다는 사실은 확장을 훨씬 쉽게 만듭니다. 이 스토리지는 또한 매우 자주 SSD 기반이므로 상당히 빠릅니다. 로컬 스토리지는 TMPDIR 변수를 사용하여 애플리케이션에 대해 설정할 수 있습니다.

  • 공유 임시 스토리지 (단기 스토리지) 이 유형의 스토리지는 일반적으로 공통 NFS 서버로 실행되는 네트워크 기반 임시 스토리지입니다. 2020년 2월 기준으로 우리는 대부분의 구현에서 이 유형의 스토리지를 계속 사용하고 있습니다. 이를 통해 위의 제한이 훨씬 더 커질 수 있지만, 그렇다고 더 많이 사용할 수 있다는 의미는 아닙니다. 공유 임시 스토리지는 모든 노드가 공유합니다. 따라서 상당한 양의 공간을 사용하거나 많은 작업을 수행하는 job은 전체 애플리케이션에서 다른 모든 job과 요청 실행에 대한 경합을 생성하며, 이는 전체 GitLab의 안정성에 영향을 미칠 수 있습니다. 이 점을 염두에 두세요.

  • 공유 영구 스토리지 (장기 스토리지) 이 유형의 스토리지는 공유 네트워크 기반 스토리지(예: NFS)를 사용합니다. 이 솔루션은 주로 몇 개의 노드로 구성된 소규모 설치를 실행하는 고객이 사용합니다. 공유 스토리지의 파일은 쉽게 접근할 수 있지만, 데이터를 업로드하거나 다운로드하는 job은 다른 모든 job에 심각한 경합을 일으킬 수 있습니다. 이것은 또한 Omnibus에서 기본적으로 사용되는 접근 방식입니다.

  • 오브젝트 기반 영구 스토리지 (장기 스토리지) 이 유형의 스토리지는 AWS S3와 같은 외부 서비스를 사용합니다. 오브젝트 스토리지는 무한히 확장 가능하고 중복 가능한 것으로 취급할 수 있습니다. 이 스토리지에 접근하려면 일반적으로 파일을 조작하기 위해 다운로드해야 합니다. 오브젝트 스토리지는 정의상 무제한의 동시 파일 업로드 및 다운로드를 처리할 수 있다고 가정할 수 있으므로 궁극적인 솔루션으로 간주할 수 있습니다. 이는 또한 애플리케이션이 컨테이너화된 배포(쿠버네티스)에서 쉽게 실행될 수 있도록 하는 데 필요한 궁극적인 솔루션입니다.

임시 스토리지#

프로덕션 노드의 스토리지는 실제로 매우 제한적입니다. 애플리케이션은 매우 제한된 임시 스토리지에서도 실행될 수 있도록 구축되어야 합니다. 코드가 실행되는 시스템에는 총 1G-10G의 임시 스토리지가 있을 것으로 예상할 수 있습니다. 그러나 이 스토리지는 실행 중인 모든 job이 공유합니다. job에서 해당 공간의 100MB 이상을 사용해야 하는 경우 취한 접근 방식을 재고해야 합니다.

어떤 필요가 있든 파일을 처리해야 하는 경우 명확히 문서화해야 합니다. 100MB 이상이 필요한 경우, 더 나은 솔루션을 발견할 수 있도록 메인테이너에게 도움을 요청하는 것을 고려하세요.

로컬 임시 스토리지#

로컬 스토리지 사용은 특히 애플리케이션을 쿠버네티스 클러스터에 배포하는 작업에서 사용하고 싶은 솔루션입니다. 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 컨선)을 재사용하려고 해야 합니다.

공유 임시 스토리지의 사용은 그 외 다른 모든 용도에서는 더 이상 사용되지 않습니다(deprecated).

영구 스토리지#

오브젝트 스토리지#

영구 파일을 보유하는 모든 기능은 오브젝트 스토리지에 데이터 저장을 지원해야 합니다. 노드 간 공유 볼륨 형태의 영구 스토리지는 모든 노드에서 데이터 접근에 대한 경합을 생성하기 때문에 확장이 불가능합니다.

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

데이터 접근#

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

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

Puma를 통한 업로드/다운로드는 또한 특히 느린 클라이언트에게 문제가 되는 작업 타임아웃 문제가 있습니다. 클라이언트가 업로드/다운로드하는 데 오랜 시간이 걸리면 요청 처리 타임아웃(일반적으로 30초-60초)으로 인해 처리 슬롯이 종료될 수 있습니다.

위의 이유로 모든 파일 업로드 및 다운로드에 대해 Workhorse 직접 업로드 구현이 필요합니다.

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

GitLab v19.1
원문 보기
요약

새로 도입되는 모든 머지 리퀘스트는 기본적으로 성능을 고려해야 합니다. 머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 미치지 않도록 모든 머지 리퀘스트는 이 문서에 제시된 가이드라인을 준수해야(SHOULD) 합니다.

새로 도입되는 모든 머지 리퀘스트는 기본적으로 성능을 고려해야 합니다.

머지 리퀘스트가 GitLab의 성능에 부정적인 영향을 미치지 않도록 모든 머지 리퀘스트는 이 문서에 제시된 가이드라인을 준수해야(SHOULD) 합니다. 백엔드 메인테이너와 성능 전문가가 특별히 논의하고 합의한 경우를 제외하고는 이 규칙에 예외는 없습니다.

또한 다음 가이드를 읽어보시길 강력히 권장합니다:

정의#

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

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

이러한 트레이드오프는 각각 별도의 이슈에 문서화하고, 적절히 라벨을 붙이고, 원본 이슈 및 에픽에 링크하는 것이 이상적입니다.

영향 분석#

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

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

사용된 쿼리가 중요한 서비스를 다운시켜 엔지니어들이 밤에 깨어나야 할 상황을 유발할 가능성이 있나요? 악의적인 사용자가 코드를 악용하여 GitLab 인스턴스를 다운시킬 수 있나요? 변경 사항으로 인해 특정 페이지의 로딩이 느려지나요? 데이터베이스에 충분한 부하나 데이터가 있을 경우 실행 시간이 기하급수적으로 증가하나요?

이 모든 것은 머지 리퀘스트를 제출하기 전에 스스로에게 물어봐야 할 질문들입니다. 영향을 평가하기 어려운 경우에는 성능 전문가에게 코드 리뷰를 요청해야 합니다. 자세한 내용은 아래 "리뷰" 섹션을 참조하세요.

성능 리뷰#

요약: 영향이 확실하지 않은 경우 성능 전문가에게 코드 리뷰를 요청하세요.

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

틀 밖에서 생각하기#

모든 사람은 새로운 기능을 사용하는 방식에 대한 자신만의 인식을 가지고 있습니다. 대신 사용자들이 기능을 어떻게 사용할지 항상 고려하세요. 보통 사용자들은 브루트 포싱이나 우리가 가진 엣지 조건을 남용하는 것처럼 매우 비전통적인 방식으로 기능을 테스트합니다.

데이터 집합#

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

처리되는 데이터 집합을 강하게 강조하는 다음 예시를 생각해보면, 문제는 간단합니다: 어떤 Git 리포지터리에서 파일 목록을 필터링하려 합니다. 기능은 리포지터리의 모든 파일 목록을 요청하고 해당 파일 집합을 검색합니다. 작성자로서 이 문제의 맥락에서 다음 사항을 고려해야 합니다:

  • 어떤 리포지터리를 지원할 계획인가요?

  • Linux 커널처럼 큰 리포지터리는 얼마나 걸리나요?

  • 이렇게 큰 데이터 집합을 처리하지 않도록 다르게 할 수 있는 방법이 있나요?

  • 연산 복잡도를 제한하기 위한 안전 장치를 구축해야 하나요? 보통 모든 사용자 대신 단일 사용자의 서비스를 저하시키는 것이 더 낫습니다.

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

쿼리 계획은 추가 인덱스가 필요한지, 또는 순차 스캔 사용과 같은 비용이 많이 드는 필터링이 있는지 알려줄 수 있습니다.

각 쿼리 계획은 충분한 크기의 데이터 집합에 대해 실행되어야 합니다. 예를 들어, 특정 조건으로 이슈를 검색하는 경우, 소수(수백 개)와 다수(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에 대한 압력이 증가함에 따라 복제본도 함께 성장할 수 있습니다.

기본적으로 쿼리는 읽기 전용 복제본을 사용하지만, 프라이머리 고착(primary sticking)으로 인해 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는 이 예시와 같이 많은 단순한 경우에서 최적화 펜스(optimization fence)로 효과적으로 사용되었습니다. 지원되는 PostgreSQL 버전에서 최적화 펜스 동작은 MATERIALIZED 키워드로 활성화해야 합니다. 기본적으로 CTE는 인라인 처리된 후 기본적으로 최적화됩니다.

CTE 구문을 구성할 때는 Gitlab::SQL::CTE 클래스를 사용하세요. 기본적으로 이 Gitlab::SQL::CTE 클래스는 MATERIALIZED 키워드를 추가하여 구체화(materialization)를 강제합니다.

GitLab 14.0으로 업그레이드하려면 PostgreSQL 12 이상이 필요합니다.

캐시된 쿼리#

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

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

캐시된 쿼리가 나쁜 이유탐지 방법을 참고하세요.

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

머지 리퀘스트에서 수정하거나 추가한 코드가 실행하는 쿼리의 총 수(캐시된 쿼리 포함)는 절대적으로 필요하지 않은 한 증가해서는 안 됩니다. 실행된 쿼리의 수(캐시된 쿼리 포함)는 컬렉션 크기에 의존해서는 안 됩니다. QueryRecorderskip_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, Object Storage)에 대한 단일 프로세스 반복은 연결 오버헤드를 줄이기 위해 배치 방식으로 실행되어야 합니다.

배치 방식으로 다양한 테이블에서 행을 가져오는 것은 이거 로딩(Eager Loading) 섹션을 참고하세요.

예시: Object Storage에서 여러 파일 삭제#

GCS와 같은 오브젝트 스토리지에서 여러 파일을 삭제할 때, 단일 REST API 호출을 여러 번 실행하는 것은 꽤 비용이 많이 드는 프로세스입니다. 이상적으로는 배치 방식으로 수행해야 합니다. 예를 들어, S3는 배치 삭제 API를 제공하므로 이러한 접근 방식을 고려하는 것이 좋습니다.

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

타임아웃#

요약: 시스템이 외부 서비스(예: 쿠버네티스)에 HTTP 호출을 할 때 합리적인 타임아웃을 설정해야 하며, Puma 스레드가 아닌 Sidekiq에서 실행되어야 합니다.

GitLab은 종종 쿠버네티스 클러스터와 같은 외부 서비스와 통신해야 합니다. 이 경우, 외부 서비스가 요청한 프로세스를 언제 완료할지 추정하기 어렵습니다. 예를 들어, 어떤 이유로 비활성 상태인 사용자 소유의 클러스터라면 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

또한 이거 로딩 시 회귀를 방지하기 위해 QueryRecorder 테스트를 사용하는 것을 고려하세요.

메모리 사용량#

요약: 머지 리퀘스트는 절대적으로 필요하지 않은 한 메모리 사용량을 늘려서는 안 됩니다.

머지 리퀘스트는 코드에서 요구하는 절대 최소한을 초과하여 GitLab의 메모리 사용량을 늘려서는 안 됩니다. 즉, 큰 문서(예: HTML 문서)를 파싱해야 하는 경우, 전체 입력을 메모리에 로드하는 대신 가능한 경우 스트림으로 파싱하는 것이 좋습니다. 이것이 불가능한 경우에는 머지 리퀘스트에서 명시적으로 언급해야 합니다.

UI 요소의 지연 렌더링#

요약: 실제로 필요할 때만 UI 요소를 렌더링하세요.

특정 UI 요소가 항상 필요하지 않을 수 있습니다. 예를 들어, diff 줄 위로 마우스를 가져갈 때 새 댓글을 작성하는 데 사용할 수 있는 작은 아이콘이 표시됩니다. 이러한 종류의 요소는 항상 렌더링하는 대신 실제로 필요할 때만 렌더링해야 합니다. 이렇게 하면 사용되지 않을 때 Haml/HTML을 생성하는 데 시간을 낭비하지 않습니다.

캐싱 사용#

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

트랜잭션 중에 여러 곳에서 특정 데이터를 재사용해야 할 때가 있습니다. 이 경우 데이터를 가져오기 위한 복잡한 연산의 필요성을 없애기 위해 메모리에 캐시해야 합니다. 트랜잭션 기간이 아닌 특정 시간 동안 데이터를 캐시해야 하는 경우에는 Redis를 사용해야 합니다.

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

트랜잭션별 데이터 캐싱은 RequestStore를 사용하여 수행할 수 있습니다(RequestStore.active?를 확인해야 하는 것을 기억하지 않아도 되도록 Gitlab::SafeRequestStore를 사용하세요). Redis에 데이터를 캐시하는 것은 Rails의 캐싱 시스템을 사용하여 수행할 수 있습니다.

페이지네이션#

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

주요 페이지네이션 스타일은 다음과 같습니다:

  • 오프셋 기반 페이지네이션: 사용자가 1과 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호와 총 페이지 수를 볼 수 있습니다. 이 스타일은 GitLab의 모든 컴포넌트에서 잘 지원됩니다.

  • 카운트 없는 오프셋 기반 페이지네이션: 사용자가 1과 같은 특정 페이지로 이동합니다. 사용자는 다음 페이지 번호만 볼 수 있고, 총 페이지 수는 볼 수 없습니다.

  • 키셋 기반 페이지네이션을 사용한 다음 페이지: 사용자는 다음 페이지로만 이동할 수 있으며, 사용 가능한 페이지가 몇 개인지 알 수 없습니다.

  • 무한 스크롤 페이지네이션: 사용자가 페이지를 스크롤하면 다음 항목이 비동기적으로 로드됩니다. 이전 방식과 동일한 이점을 가지므로 이상적입니다.

궁극적으로 확장 가능한 페이지네이션 솔루션은 키셋 기반 페이지네이션을 사용하는 것입니다. 그러나 현재 GitLab에서는 이를 지원하지 않습니다. API: 키셋 페이지네이션에서 진행 상황을 확인할 수 있습니다.

페이지네이션 전략을 선택할 때 다음을 고려하세요:

  • 필터링을 통과하는 객체의 수를 계산하는 것은 매우 비효율적이며, 이 작업은 일반적으로 몇 초가 걸릴 수 있고 타임아웃이 발생할 수 있습니다.

  • 1000과 같이 높은 서수의 페이지에 대한 항목을 가져오는 것은 매우 비효율적입니다. 데이터베이스가 이전 모든 항목을 정렬하고 반복해야 하며, 이 작업은 일반적으로 데이터베이스에 상당한 부하를 줄 수 있습니다.

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

배지 카운터#

카운터는 항상 잘라내야(truncated) 합니다. 즉, 특정 임계값을 초과하는 정확한 숫자를 표시하지 않으려는 것입니다. 정확한 항목 수를 계산하려는 경우, 일치하는 항목의 정확한 수를 알기 위해 각 항목을 효과적으로 필터링해야 하기 때문입니다.

~UX 관점에서 40000개 이상의 파이프라인이 있다는 것을 알면서 페이지 로드가 2초 더 길어지는 것보다, 1000개 이상의 파이프라인이 있다는 것을 보는 것이 종종 수용 가능합니다.

이 패턴의 예시는 파이프라인 및 job 목록입니다. 숫자를 1000+로 잘라내지만, 가장 흥미로운 정보인 실행 중인 파이프라인의 정확한 수는 표시합니다.

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

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

기능 플래그 사용#

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

기능 플래그는 팀이 더 행복해지도록 합니다. 왜냐하면 사용자들이 문제를 알아차리기 전에 시스템을 모니터링하고 빠르게 대응할 수 있기 때문입니다.

성능 결함은 초기 변경 사항을 머지한 직후에 즉시 해결해야 합니다.

기능 플래그를 언제, 어떻게 사용해야 하는지에 대한 자세한 내용은 GitLab 개발의 기능 플래그를 읽어보세요.

스토리지#

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

  • 로컬 임시 스토리지 (매우 단기 스토리지) 이 유형의 스토리지는 /tmp 폴더와 같이 시스템에서 제공하는 스토리지입니다. 모든 임시 작업에 이상적으로 사용해야 하는 스토리지 유형입니다. 각 노드에 자체 임시 스토리지가 있다는 사실은 확장을 훨씬 쉽게 만듭니다. 이 스토리지는 또한 매우 자주 SSD 기반이므로 상당히 빠릅니다. 로컬 스토리지는 TMPDIR 변수를 사용하여 애플리케이션에 대해 설정할 수 있습니다.

  • 공유 임시 스토리지 (단기 스토리지) 이 유형의 스토리지는 일반적으로 공통 NFS 서버로 실행되는 네트워크 기반 임시 스토리지입니다. 2020년 2월 기준으로 우리는 대부분의 구현에서 이 유형의 스토리지를 계속 사용하고 있습니다. 이를 통해 위의 제한이 훨씬 더 커질 수 있지만, 그렇다고 더 많이 사용할 수 있다는 의미는 아닙니다. 공유 임시 스토리지는 모든 노드가 공유합니다. 따라서 상당한 양의 공간을 사용하거나 많은 작업을 수행하는 job은 전체 애플리케이션에서 다른 모든 job과 요청 실행에 대한 경합을 생성하며, 이는 전체 GitLab의 안정성에 영향을 미칠 수 있습니다. 이 점을 염두에 두세요.

  • 공유 영구 스토리지 (장기 스토리지) 이 유형의 스토리지는 공유 네트워크 기반 스토리지(예: NFS)를 사용합니다. 이 솔루션은 주로 몇 개의 노드로 구성된 소규모 설치를 실행하는 고객이 사용합니다. 공유 스토리지의 파일은 쉽게 접근할 수 있지만, 데이터를 업로드하거나 다운로드하는 job은 다른 모든 job에 심각한 경합을 일으킬 수 있습니다. 이것은 또한 Omnibus에서 기본적으로 사용되는 접근 방식입니다.

  • 오브젝트 기반 영구 스토리지 (장기 스토리지) 이 유형의 스토리지는 AWS S3와 같은 외부 서비스를 사용합니다. 오브젝트 스토리지는 무한히 확장 가능하고 중복 가능한 것으로 취급할 수 있습니다. 이 스토리지에 접근하려면 일반적으로 파일을 조작하기 위해 다운로드해야 합니다. 오브젝트 스토리지는 정의상 무제한의 동시 파일 업로드 및 다운로드를 처리할 수 있다고 가정할 수 있으므로 궁극적인 솔루션으로 간주할 수 있습니다. 이는 또한 애플리케이션이 컨테이너화된 배포(쿠버네티스)에서 쉽게 실행될 수 있도록 하는 데 필요한 궁극적인 솔루션입니다.

임시 스토리지#

프로덕션 노드의 스토리지는 실제로 매우 제한적입니다. 애플리케이션은 매우 제한된 임시 스토리지에서도 실행될 수 있도록 구축되어야 합니다. 코드가 실행되는 시스템에는 총 1G-10G의 임시 스토리지가 있을 것으로 예상할 수 있습니다. 그러나 이 스토리지는 실행 중인 모든 job이 공유합니다. job에서 해당 공간의 100MB 이상을 사용해야 하는 경우 취한 접근 방식을 재고해야 합니다.

어떤 필요가 있든 파일을 처리해야 하는 경우 명확히 문서화해야 합니다. 100MB 이상이 필요한 경우, 더 나은 솔루션을 발견할 수 있도록 메인테이너에게 도움을 요청하는 것을 고려하세요.

로컬 임시 스토리지#

로컬 스토리지 사용은 특히 애플리케이션을 쿠버네티스 클러스터에 배포하는 작업에서 사용하고 싶은 솔루션입니다. 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 컨선)을 재사용하려고 해야 합니다.

공유 임시 스토리지의 사용은 그 외 다른 모든 용도에서는 더 이상 사용되지 않습니다(deprecated).

영구 스토리지#

오브젝트 스토리지#

영구 파일을 보유하는 모든 기능은 오브젝트 스토리지에 데이터 저장을 지원해야 합니다. 노드 간 공유 볼륨 형태의 영구 스토리지는 모든 노드에서 데이터 접근에 대한 경합을 생성하기 때문에 확장이 불가능합니다.

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

데이터 접근#

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

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

Puma를 통한 업로드/다운로드는 또한 특히 느린 클라이언트에게 문제가 되는 작업 타임아웃 문제가 있습니다. 클라이언트가 업로드/다운로드하는 데 오랜 시간이 걸리면 요청 처리 타임아웃(일반적으로 30초-60초)으로 인해 처리 슬롯이 종료될 수 있습니다.

위의 이유로 모든 파일 업로드 및 다운로드에 대해 Workhorse 직접 업로드 구현이 필요합니다.