InfoGrab DocsInfoGrab Docs

소프트웨어 설계 가이드

요약

코드는 제품과 사용자 문서에서 사용하는 것과 동일한 유비쿼터스 언어를 사용해야 합니다. 아래 예시에서 CRUD 용어는 모호함을 유발합니다. 유비쿼터스 언어를 사용하면 코드가 명확해지고, 프레임워크 용어를 해석하려는 독자에게 인지적 부담을 주지 않습니다.

CRUD 용어 대신 유비쿼터스 언어 사용#

코드는 제품과 사용자 문서에서 사용하는 것과 동일한 유비쿼터스 언어를 사용해야 합니다. 유비쿼터스 언어를 올바르게 사용하지 않으면, 지속적인 번역이나 여러 용어의 사용으로 인해 기여자와 고객 모두에게 큰 혼란을 초래할 수 있습니다. 이는 또한 커뮤니케이션 전략에도 위배됩니다.

아래 예시에서 CRUD 용어는 모호함을 유발합니다. 이름만 보면 epic_issues 연관 레코드를 생성하는 것처럼 보이지만, 실제로는 기존 이슈를 에픽에 추가하는 작업입니다. Rails 관례에서 비롯된 epic_issues라는 이름은 서비스 객체와 같은 상위 추상화 계층으로 누수됩니다. 코드가 유비쿼터스 언어가 아닌 프레임워크 전용 용어로 표현되고 있습니다.

# Bad
EpicIssues::CreateService

유비쿼터스 언어를 사용하면 코드가 명확해지고, 프레임워크 용어를 해석하려는 독자에게 인지적 부담을 주지 않습니다.

# Good
Epic::AddExistingIssueService

프로젝트 생성처럼 모호하지 않고 단순한 개념을 표현하거나, 기존 유비쿼터스 언어와 일치하는 경우에는 CRUD를 사용할 수 있습니다.

# OK: Matches the product language.
Projects::CreateService

새 클래스와 데이터베이스 테이블은 유비쿼터스 언어를 사용해야 합니다. 이 경우 모델 이름과 테이블 이름은 Rails 관례를 따릅니다.

유비쿼터스 언어를 따르지 않는 기존 클래스는 가능하다면 이름을 변경해야 합니다. 데이터베이스 테이블과 같은 일부 저수준 추상화는 이름을 변경할 필요가 없습니다. 예를 들어, 모델 이름이 테이블 이름과 다를 경우 self.table_name=을 사용하세요.

이름 변경이 어려운 경우에만 예외를 허용할 수 있습니다. 예를 들어, 해당 이름이 STI에 사용되거나, 사용자에게 노출되거나, 변경 시 호환성이 깨지는 경우입니다.

경계 컨텍스트#

경계 컨텍스트의 목표, 동기 및 방향에 대한 자세한 내용은 경계 컨텍스트 워킹 그룹GitLab 모듈형 모놀리스 설계 문서를 참고하세요.

네임스페이스를 사용하여 경계 컨텍스트 정의하기#

건강한 애플리케이션은 작동하는 경계 컨텍스트를 나타내는 매크로 및 하위 컴포넌트로 분리됩니다. GitLab 코드에는 매우 많은 기능과 컴포넌트가 있어, 관련된 컨텍스트를 파악하기 어렵습니다. 이러한 컴포넌트는 비즈니스 도메인 또는 인프라 코드와 관련될 수 있습니다.

모든 클래스는 해당 클래스가 동작하는 컨텍스트를 나타내는 모듈/네임스페이스 내에 정의되어야 합니다. 이러한 컨텍스트를 정의하기 위해 허용된 네임스페이스 목록을 관리하고 있습니다.

클래스를 도메인별로 네임스페이스로 묶으면:

  • 도메인이 의미를 명확히 해주므로 유사한 용어 간의 모호함이 해소됩니다. 예를 들어, MergeRequests::DiffNotes::Diff처럼 구분됩니다.

  • 최상위 네임스페이스는 도메인 전문가로 식별된 하나 이상의 그룹과 연계될 수 있습니다.

  • 컴포넌트 간의 상호작용과 결합도를 더 잘 파악할 수 있습니다. 예를 들어, MergeRequests:: 도메인 내의 여러 클래스가 Import::보다 Ci:: 도메인과 더 많이 상호작용한다는 것을 알 수 있습니다.

# bad
class JobArtifact ... end

# good
module Ci
  class JobArtifact ... end
end

경계 컨텍스트 정의 방법#

허용된 경계 컨텍스트는 config/bounded_contexts.yml에 정의되며, 도메인 계층과 인프라 계층에 대한 네임스페이스를 포함합니다.

도메인 계층은 다음을 의미합니다:

  • 애플리케이션 어댑터(컨트롤러, API 엔드포인트 및 뷰)를 제외한 app의 코드.

  • 도메인 로직과 직접적으로 관련된 lib의 코드.

여기에는 ActiveRecord 모델, 서비스 객체, 워커, 도메인별 Plain Old Ruby Objects가 포함됩니다.

현재는 모듈화 작업의 범위를 줄이고, 특정 엔드포인트가 항상 단일 도메인(예: 설정, 머지 리퀘스트 뷰, 프로젝트 뷰)에 매핑되지 않기 때문에 애플리케이션 어댑터를 모듈화에서 제외합니다.

인프라 계층lib의 코드 중 일반적인 목적으로 사용되며, GitLab 비즈니스 개념을 포함하지 않고, Ruby gem으로 추출할 수 있는 코드를 의미합니다.

최상위 네임스페이스(경계 컨텍스트) 이름을 정할 때는 관련된 기능 카테고리를 사용하는 것이 좋습니다. 예를 들어, Continuous Integration 기능 카테고리는 Ci:: 네임스페이스에 매핑됩니다.

Projects와 Groups는 일반적으로 테넌트를 식별하는 컨테이너 개념입니다. 리포지터리나 러너와 같은 기능이 프로젝트나 그룹 수준에 존재하더라도, 이러한 기능을 Projects:: 또는 Groups:: 아래에 중첩하지 말고 해당 기능의 경계 컨텍스트 아래에 두어야 합니다.

Projects::Groups:: 네임스페이스는 그것과 엄격히 관련된 개념에만 사용해야 합니다. 예를 들어 Project::CreateService 또는 Groups::TransferService가 이에 해당합니다.

컨트롤러의 경우 app/controllers/projectsapp/controllers/groups를 예외로 허용합니다. 경계 컨텍스트가 애플리케이션 계층에는 적용되지 않기 때문이기도 합니다. 이 관례는 특정 웹 엔드포인트의 범위를 나타내기 위해 사용합니다.

기능 카테고리는 나중에 다른 그룹으로 재할당될 수 있으므로 스테이지 또는 그룹 이름은 사용하지 마세요.

# bad
module Create
  class Commit ... end
end

# good
module Repositories
  class Commit ... end
end

반면, 기능 카테고리가 때로는 너무 세분화될 수 있습니다. 기능들은 Product 및 마케팅 관점에서는 다르게 취급되지만, 내부적으로는 많은 도메인 모델과 동작을 공유할 수 있습니다. 이 경우 너무 많은 경계 컨텍스트를 두면 각 컨텍스트가 얕아지고 다른 컨텍스트와 더 결합될 수 있습니다.

경계 컨텍스트(또는 최상위 네임스페이스)는 전체 앱에서 매크로 컴포넌트로 볼 수 있습니다. 좋은 경계 컨텍스트는 깊어야 하므로, 도메인의 복잡한 부분을 더 세분화하기 위해 중첩된 네임스페이스를 사용하는 것을 고려하세요. 예를 들어 Ci::Config::.

예를 들어, ContainerScanning::, ContainerHostSecurity::, ContainerNetworkSecurity::와 같이 분리되고 세분화된 경계 컨텍스트를 두는 대신 다음과 같이 할 수 있습니다:

module Security::Container
  module Scanning ... end

  module NetworkSecurity ... end

  module HostSecurity ... end
end

특정 네임스페이스에 정의된 클래스들이 다른 네임스페이스의 클래스들과 공통점이 많다면, 두 네임스페이스가 동일한 경계 컨텍스트에 속할 가능성이 높습니다.

GitLab/BoundedContexts RuboCop 위반 해결 방법#

Gitlab/BoundedContexts RuboCop cop은 모든 Ruby 클래스 또는 모듈이 config/bounded_contexts.yml에 존재하는 최상위 Ruby 네임스페이스 안에 중첩되어 있는지 확인합니다.

위반 사항은 상수를 기존 경계 컨텍스트 네임스페이스 안에 중첩하여 해결해야 합니다.

  • 기능 카테고리 매칭 등을 통해 기능과 더 밀접하게 관련된 네임스페이스를 config/bounded_contexts.yml에서 검색합니다.

  • 필요한 경우 하위 네임스페이스를 사용하여 해당 네임스페이스 안에 상수를 더 깊이 중첩합니다. 예: Repositories::Mirrors::SyncService.

  • 기존의 관련 코드를 동일한 네임스페이스로 이동하기 위한 후속 이슈를 생성합니다.

예외적인 경우에는 목록에 새 경계 컨텍스트를 추가해야 할 수도 있습니다. 다음과 같은 경우에 가능합니다:

  • 기존 경계 컨텍스트와 일치하지 않는 새 제품 카테고리를 도입하는 경우.

  • 기존 경계 컨텍스트가 너무 커져서 두 컨텍스트를 분리하기 위해 하나를 추출하는 경우.

GitLab/BoundedContexts 및 config/bounded_contexts.yml FAQ#

cop을 비활성화해야 하는 상황이 있나요?

cop을 비활성화해서는 안 되지만, 위반하는 클래스나 모듈이 함께 이동해야 하는 클래스 묶음의 일부인 경우 일시적으로 비활성화할 수 있습니다. 이 경우 cop을 비활성화하고 모든 클래스를 한꺼번에 이동하기 위한 후속 이슈를 생성하세요.

기존 코드를 모두 규정에 맞게 리팩토링하는 데 제안된 일정이 있나요?

별도로 정해진 일정은 없지만, 코드를 빨리 통합할수록 일관성이 높아집니다.

경계 컨텍스트가 기존 Sidekiq 워커에도 적용되나요?

기존 워커는 이미 RuboCop TODO 파일에 등록되어 있어 위반이 발생하지 않습니다. 하지만 가능하면 경계 컨텍스트로 이동해야 합니다. Sidekiq 워커 이름 변경 가이드를 따르세요.

기능 카테고리 이름을 변경하고 있으며 config/bounded_contexts.yml이 이를 참조하고 있습니다. 업데이트해도 안전한가요?

네, 이 파일은 경계 컨텍스트에 매핑된 기능 카테고리가 config/feature_categories.yml에 정의되어 있기만을 요구하며, 이 값들에 특별히 의존하는 것은 없습니다. 이 매핑은 주로 기여자들이 코드베이스에서 기능이 어디에 있는지 이해하기 위한 것입니다.

도메인 코드와 범용 코드 구분하기#

위의 가이드라인은 주로 도메인 코드를 다룹니다. 도메인 코드는 주어진 경계 컨텍스트(응집력 있는 기능 및 기능 집합)를 나타내는 네임스페이스 아래에 Ruby 클래스를 배치해야 합니다.

도메인 코드는 GitLab 제품에 고유하며, 비즈니스 로직, 정책 및 데이터를 설명합니다. 이 코드는 GitLab 리포지터리에 있어야 합니다. 도메인 코드는 주로 app/lib/에 분산되어 있습니다.

애플리케이션 코드베이스에는 더 인프라 수준의 작업을 수행하는 범용 코드도 있습니다. 예를 들어 로거, 계측 도구, Redis와 같은 데이터스토어 클라이언트, 데이터베이스 유틸리티 등이 있습니다.

범용 코드는 애플리케이션 실행에 필수적이지만, GitLab 제품에 고유한 비즈니스 로직을 설명하지 않습니다. 비즈니스 로직에 영향을 주지 않고 기성 솔루션으로 재작성하거나 교체할 수 있습니다. 따라서 범용 코드는 도메인 코드와 분리되어야 합니다.

현재 많은 범용 코드가 lib/에 있지만 도메인 코드와 혼재하고 있습니다. Gems 개발 가이드라인에 설명된 것처럼 gem을 gems/ 디렉터리로 추출해야 합니다.

전지전능 클래스 제어하기#

전지전능 클래스(god object라고도 함)에 새로운 데이터와 동작을 추가하지 않도록 주의해야 합니다. Project, User, MergeRequest, Ci::Pipeline 및 1000줄 이상의 클래스를 전지전능한 것으로 간주합니다.

이러한 클래스는 책임이 과도하게 집중되어 있습니다. 새로운 데이터와 동작은 대부분 별도의 전용 클래스로 추가할 수 있습니다.

가이드라인:

  • 주로 객체 ID(예: Project#id)에 대한 참조만 필요한 경우, 외래 키를 사용하는 새 모델이나 특별한 동작을 추가하기 위해 객체를 얇게 감싸는 래퍼를 추가할 수 있습니다.

  • 전지전능 클래스에 메서드를 추가하다 보면 몇 가지 다른 메서드(private 또는 public)도 추가하게 된다면, 이는 해당 메서드들이 전용 클래스 안에 캡슐화되어야 한다는 신호입니다.

  • 데이터와 연관의 출발점이기 때문에 Project에 메서드를 추가하고 싶은 유혹이 있습니다. 데이터가 있는 곳이 아니라 해당 동작이 속하는 경계 컨텍스트에서 동작을 정의하세요. 이는 경계 컨텍스트에서 더 관련성이 높은 전지전능 객체의 단면을 만드는 데 도움이 되며, 더 많은 결합도와 복잡성을 유발하는 범용적이고 과부하된 객체를 피할 수 있습니다.

예시: 범용 모델을 감싸는 얇은 도메인 객체 정의하기#

abuse_trust_scores와의 연관이 있다는 이유만으로 User에 여러 메서드를 추가하는 대신, 의존성을 역전해보세요.

##
# BAD: Behavior added to User object.
class User
  def spam_score
    abuse_trust_scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    # Warning sign: we use a constant that belongs to a specific bounded context!
    spam_score > AntiAbuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
  end

  def telesign_score
    abuse_trust_scores.telesign.recent_first.first&.score || 0.0
  end

  def arkose_global_score
    abuse_trust_scores.arkose_global_score.recent_first.first&.score || 0.0
  end

  def arkose_custom_score
    abuse_trust_scores.arkose_custom_score.recent_first.first&.score || 0.0
  end
end

# Usage:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_score
##
# GOOD: Define a thin class that represents a user trust score
class AntiAbuse::UserTrustScore
  def initialize(user)
    @user = user
  end

  def spam
    scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    spam > AntiAbuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
  end

  def telesign
    scores.telesign.recent_first.first&.score || 0.0
  end

  def arkose_global
    scores.arkose_global_score.recent_first.first&.score || 0.0
  end

  def arkose_custom
    scores.arkose_custom_score.recent_first.first&.score || 0.0
  end

  private

  def scores
    AntiAbuse::TrustScore.for_user(@user)
  end
end

# Usage:
user = User.find(1)
user_score = AntiAbuse::UserTrustScore.new(user)
user_score.spam
user_score.spammer?
user_score.telesign
user_score.arkose_global

실제 예시는 이 머지 리퀘스트를 참조하세요.

예시: 의존성 역전을 사용하여 도메인 개념 추출하기#

##
# BAD: methods related to integrations defined in Project.
class Project
  has_many :integrations

  def find_or_initialize_integrations
    # ...
  end

  def find_or_initialize_integration(name)
    # ...
  end

  def disabled_integrations
    # ...
  end

  def ci_integrations
    # ...
  end

  # many more methods...
end
##
# GOOD: All logic related to Integrations is enclosed inside the `Integrations::`
# bounded context.
module Integrations
  class ProjectIntegrations
    def initialize(project)
      @project = project
    end

    def all_integrations
      @project.integrations # can still leverage caching of AR associations
    end

    def find_or_initialize(name)
      # ...
    end

    def all_disabled
      all_integrations.disabled
    end

    def all_ci
      all_integrations.ci_integration
    end
  end
end

유사한 리팩토링의 실제 예시는 이 머지 리퀘스트를 참조하세요.

엔티티가 아닌 유스케이스 중심으로 소프트웨어 설계하기#

Rails는 Active Record의 강력한 기능을 통해 개발자들이 엔티티 중심의 소프트웨어를 설계하도록 유도합니다. 컨트롤러와 API 엔드포인트는 엔티티와 서비스 객체 모두에 대한 CRUD 작업을 나타내는 경향이 있습니다. 새로운 데이터베이스 칼럼은 서로 다른 유스케이스를 참조함에도 불구하고 기존 엔티티 테이블에 추가되는 경향이 있습니다.

이 안티패턴은 종종 다음 중 하나 이상으로 나타납니다:

  • 서로 다른 유스케이스를 위해 검사하는 서로 다른 사전 조건.

  • 동일한 추상화(서비스 객체, 컨트롤러, 시리얼라이저)에서 검사하는 서로 다른 권한.

  • 다양한 암묵적 유스케이스를 위해 동일한 추상화에서 실행되는 서로 다른 부수 효과. 예를 들어, "필드 X가 변경되면 Y를 수행한다".

안티패턴 예시#

Groups::UpdateService는 엔티티 중심으로, 근본적으로 다른 유스케이스에 재사용됩니다:

  • 그룹 설명 업데이트 - 그룹 관리자 접근 권한이 필요합니다.

  • shared_runners_minutes_limit와 같은 컴퓨트 할당량에 대한 네임스페이스 수준 제한 설정 - 인스턴스 관리자 접근 권한이 필요합니다.

이 2가지 서로 다른 유스케이스는 서로 다른 파라미터 세트를 지원합니다. 인스턴스 관리자가 shared_runners_minutes_limit와 그룹 설명을 동시에 업데이트하는 것은 가능하지도 않고 예상되지도 않습니다. 마찬가지로, 사용자가 브랜치 보호 규칙과 인스턴스 러너 설정을 동시에 변경하는 것도 예상되지 않습니다. 이것들은 서로 다른 도메인에서 나온 서로 다른 유스케이스를 나타냅니다.

해결 방법#

엔티티가 아닌 유스케이스를 중심으로 설계하세요. 페르소나, 유스케이스 및 의도가 다르다면 별도의 추상화를 생성하세요:

  • 유스케이스의 특정 도메인에 중첩된 다른 엔드포인트(컨트롤러, GraphQL 또는 REST).

  • 특정 권한과 응집력 있는 파라미터 세트를 포함하는 다른 서비스 객체. 예를 들어, 그룹 관리자가 일반적인 그룹 설정을 업데이트하기 위한 Groups::UpdateService. Ci::Minutes::UpdateLimitService는 인스턴스 관리자를 위한 것으로, 완전히 다른 권한, 기대값, 파라미터 및 부수 효과를 가집니다.

궁극적으로 이는 전지전능 클래스 제어하기의 원칙을 활용해야 합니다. 관련 없는 유스케이스 로직을 단일하고 응집력이 낮은 클래스에 결합하는 것을 피함으로써 느슨한 결합과 높은 응집력을 달성하고자 합니다. 그 결과 권한이 전체 작업에 일관되게 적용되므로 더 안전한 시스템이 됩니다. 마찬가지로 별도의 모델이나 테이블에 정의된 경우 관리자 수준의 데이터를 의도치 않게 노출하지 않습니다. 동일한 유스케이스에 일관되게 속하는 데이터를 읽거나 쓰기 전에 단일 권한 검사를 가질 수 있습니다.

Cells 호환성#

GitLab은 서로 다른 조직이 별도의 GitLab 물리적 인스턴스에서 제공되는 Cells 아키텍처로 이동하고 있습니다. 모든 새 코드는 Cells 호환성을 고려하여 설계되어야 합니다.

전체 개발 원칙은 Cells 개발 원칙을 참조하세요.

소프트웨어 설계 가이드

GitLab v19.1
원문 보기
요약

코드는 제품과 사용자 문서에서 사용하는 것과 동일한 유비쿼터스 언어를 사용해야 합니다. 아래 예시에서 CRUD 용어는 모호함을 유발합니다. 유비쿼터스 언어를 사용하면 코드가 명확해지고, 프레임워크 용어를 해석하려는 독자에게 인지적 부담을 주지 않습니다.

CRUD 용어 대신 유비쿼터스 언어 사용#

코드는 제품과 사용자 문서에서 사용하는 것과 동일한 유비쿼터스 언어를 사용해야 합니다. 유비쿼터스 언어를 올바르게 사용하지 않으면, 지속적인 번역이나 여러 용어의 사용으로 인해 기여자와 고객 모두에게 큰 혼란을 초래할 수 있습니다. 이는 또한 커뮤니케이션 전략에도 위배됩니다.

아래 예시에서 CRUD 용어는 모호함을 유발합니다. 이름만 보면 epic_issues 연관 레코드를 생성하는 것처럼 보이지만, 실제로는 기존 이슈를 에픽에 추가하는 작업입니다. Rails 관례에서 비롯된 epic_issues라는 이름은 서비스 객체와 같은 상위 추상화 계층으로 누수됩니다. 코드가 유비쿼터스 언어가 아닌 프레임워크 전용 용어로 표현되고 있습니다.

# Bad
EpicIssues::CreateService

유비쿼터스 언어를 사용하면 코드가 명확해지고, 프레임워크 용어를 해석하려는 독자에게 인지적 부담을 주지 않습니다.

# Good
Epic::AddExistingIssueService

프로젝트 생성처럼 모호하지 않고 단순한 개념을 표현하거나, 기존 유비쿼터스 언어와 일치하는 경우에는 CRUD를 사용할 수 있습니다.

# OK: Matches the product language.
Projects::CreateService

새 클래스와 데이터베이스 테이블은 유비쿼터스 언어를 사용해야 합니다. 이 경우 모델 이름과 테이블 이름은 Rails 관례를 따릅니다.

유비쿼터스 언어를 따르지 않는 기존 클래스는 가능하다면 이름을 변경해야 합니다. 데이터베이스 테이블과 같은 일부 저수준 추상화는 이름을 변경할 필요가 없습니다. 예를 들어, 모델 이름이 테이블 이름과 다를 경우 self.table_name=을 사용하세요.

이름 변경이 어려운 경우에만 예외를 허용할 수 있습니다. 예를 들어, 해당 이름이 STI에 사용되거나, 사용자에게 노출되거나, 변경 시 호환성이 깨지는 경우입니다.

경계 컨텍스트#

경계 컨텍스트의 목표, 동기 및 방향에 대한 자세한 내용은 경계 컨텍스트 워킹 그룹GitLab 모듈형 모놀리스 설계 문서를 참고하세요.

네임스페이스를 사용하여 경계 컨텍스트 정의하기#

건강한 애플리케이션은 작동하는 경계 컨텍스트를 나타내는 매크로 및 하위 컴포넌트로 분리됩니다. GitLab 코드에는 매우 많은 기능과 컴포넌트가 있어, 관련된 컨텍스트를 파악하기 어렵습니다. 이러한 컴포넌트는 비즈니스 도메인 또는 인프라 코드와 관련될 수 있습니다.

모든 클래스는 해당 클래스가 동작하는 컨텍스트를 나타내는 모듈/네임스페이스 내에 정의되어야 합니다. 이러한 컨텍스트를 정의하기 위해 허용된 네임스페이스 목록을 관리하고 있습니다.

클래스를 도메인별로 네임스페이스로 묶으면:

  • 도메인이 의미를 명확히 해주므로 유사한 용어 간의 모호함이 해소됩니다. 예를 들어, MergeRequests::DiffNotes::Diff처럼 구분됩니다.

  • 최상위 네임스페이스는 도메인 전문가로 식별된 하나 이상의 그룹과 연계될 수 있습니다.

  • 컴포넌트 간의 상호작용과 결합도를 더 잘 파악할 수 있습니다. 예를 들어, MergeRequests:: 도메인 내의 여러 클래스가 Import::보다 Ci:: 도메인과 더 많이 상호작용한다는 것을 알 수 있습니다.

# bad
class JobArtifact ... end

# good
module Ci
  class JobArtifact ... end
end

경계 컨텍스트 정의 방법#

허용된 경계 컨텍스트는 config/bounded_contexts.yml에 정의되며, 도메인 계층과 인프라 계층에 대한 네임스페이스를 포함합니다.

도메인 계층은 다음을 의미합니다:

  • 애플리케이션 어댑터(컨트롤러, API 엔드포인트 및 뷰)를 제외한 app의 코드.

  • 도메인 로직과 직접적으로 관련된 lib의 코드.

여기에는 ActiveRecord 모델, 서비스 객체, 워커, 도메인별 Plain Old Ruby Objects가 포함됩니다.

현재는 모듈화 작업의 범위를 줄이고, 특정 엔드포인트가 항상 단일 도메인(예: 설정, 머지 리퀘스트 뷰, 프로젝트 뷰)에 매핑되지 않기 때문에 애플리케이션 어댑터를 모듈화에서 제외합니다.

인프라 계층lib의 코드 중 일반적인 목적으로 사용되며, GitLab 비즈니스 개념을 포함하지 않고, Ruby gem으로 추출할 수 있는 코드를 의미합니다.

최상위 네임스페이스(경계 컨텍스트) 이름을 정할 때는 관련된 기능 카테고리를 사용하는 것이 좋습니다. 예를 들어, Continuous Integration 기능 카테고리는 Ci:: 네임스페이스에 매핑됩니다.

Projects와 Groups는 일반적으로 테넌트를 식별하는 컨테이너 개념입니다. 리포지터리나 러너와 같은 기능이 프로젝트나 그룹 수준에 존재하더라도, 이러한 기능을 Projects:: 또는 Groups:: 아래에 중첩하지 말고 해당 기능의 경계 컨텍스트 아래에 두어야 합니다.

Projects::Groups:: 네임스페이스는 그것과 엄격히 관련된 개념에만 사용해야 합니다. 예를 들어 Project::CreateService 또는 Groups::TransferService가 이에 해당합니다.

컨트롤러의 경우 app/controllers/projectsapp/controllers/groups를 예외로 허용합니다. 경계 컨텍스트가 애플리케이션 계층에는 적용되지 않기 때문이기도 합니다. 이 관례는 특정 웹 엔드포인트의 범위를 나타내기 위해 사용합니다.

기능 카테고리는 나중에 다른 그룹으로 재할당될 수 있으므로 스테이지 또는 그룹 이름은 사용하지 마세요.

# bad
module Create
  class Commit ... end
end

# good
module Repositories
  class Commit ... end
end

반면, 기능 카테고리가 때로는 너무 세분화될 수 있습니다. 기능들은 Product 및 마케팅 관점에서는 다르게 취급되지만, 내부적으로는 많은 도메인 모델과 동작을 공유할 수 있습니다. 이 경우 너무 많은 경계 컨텍스트를 두면 각 컨텍스트가 얕아지고 다른 컨텍스트와 더 결합될 수 있습니다.

경계 컨텍스트(또는 최상위 네임스페이스)는 전체 앱에서 매크로 컴포넌트로 볼 수 있습니다. 좋은 경계 컨텍스트는 깊어야 하므로, 도메인의 복잡한 부분을 더 세분화하기 위해 중첩된 네임스페이스를 사용하는 것을 고려하세요. 예를 들어 Ci::Config::.

예를 들어, ContainerScanning::, ContainerHostSecurity::, ContainerNetworkSecurity::와 같이 분리되고 세분화된 경계 컨텍스트를 두는 대신 다음과 같이 할 수 있습니다:

module Security::Container
  module Scanning ... end

  module NetworkSecurity ... end

  module HostSecurity ... end
end

특정 네임스페이스에 정의된 클래스들이 다른 네임스페이스의 클래스들과 공통점이 많다면, 두 네임스페이스가 동일한 경계 컨텍스트에 속할 가능성이 높습니다.

GitLab/BoundedContexts RuboCop 위반 해결 방법#

Gitlab/BoundedContexts RuboCop cop은 모든 Ruby 클래스 또는 모듈이 config/bounded_contexts.yml에 존재하는 최상위 Ruby 네임스페이스 안에 중첩되어 있는지 확인합니다.

위반 사항은 상수를 기존 경계 컨텍스트 네임스페이스 안에 중첩하여 해결해야 합니다.

  • 기능 카테고리 매칭 등을 통해 기능과 더 밀접하게 관련된 네임스페이스를 config/bounded_contexts.yml에서 검색합니다.

  • 필요한 경우 하위 네임스페이스를 사용하여 해당 네임스페이스 안에 상수를 더 깊이 중첩합니다. 예: Repositories::Mirrors::SyncService.

  • 기존의 관련 코드를 동일한 네임스페이스로 이동하기 위한 후속 이슈를 생성합니다.

예외적인 경우에는 목록에 새 경계 컨텍스트를 추가해야 할 수도 있습니다. 다음과 같은 경우에 가능합니다:

  • 기존 경계 컨텍스트와 일치하지 않는 새 제품 카테고리를 도입하는 경우.

  • 기존 경계 컨텍스트가 너무 커져서 두 컨텍스트를 분리하기 위해 하나를 추출하는 경우.

GitLab/BoundedContexts 및 config/bounded_contexts.yml FAQ#

cop을 비활성화해야 하는 상황이 있나요?

cop을 비활성화해서는 안 되지만, 위반하는 클래스나 모듈이 함께 이동해야 하는 클래스 묶음의 일부인 경우 일시적으로 비활성화할 수 있습니다. 이 경우 cop을 비활성화하고 모든 클래스를 한꺼번에 이동하기 위한 후속 이슈를 생성하세요.

기존 코드를 모두 규정에 맞게 리팩토링하는 데 제안된 일정이 있나요?

별도로 정해진 일정은 없지만, 코드를 빨리 통합할수록 일관성이 높아집니다.

경계 컨텍스트가 기존 Sidekiq 워커에도 적용되나요?

기존 워커는 이미 RuboCop TODO 파일에 등록되어 있어 위반이 발생하지 않습니다. 하지만 가능하면 경계 컨텍스트로 이동해야 합니다. Sidekiq 워커 이름 변경 가이드를 따르세요.

기능 카테고리 이름을 변경하고 있으며 config/bounded_contexts.yml이 이를 참조하고 있습니다. 업데이트해도 안전한가요?

네, 이 파일은 경계 컨텍스트에 매핑된 기능 카테고리가 config/feature_categories.yml에 정의되어 있기만을 요구하며, 이 값들에 특별히 의존하는 것은 없습니다. 이 매핑은 주로 기여자들이 코드베이스에서 기능이 어디에 있는지 이해하기 위한 것입니다.

도메인 코드와 범용 코드 구분하기#

위의 가이드라인은 주로 도메인 코드를 다룹니다. 도메인 코드는 주어진 경계 컨텍스트(응집력 있는 기능 및 기능 집합)를 나타내는 네임스페이스 아래에 Ruby 클래스를 배치해야 합니다.

도메인 코드는 GitLab 제품에 고유하며, 비즈니스 로직, 정책 및 데이터를 설명합니다. 이 코드는 GitLab 리포지터리에 있어야 합니다. 도메인 코드는 주로 app/lib/에 분산되어 있습니다.

애플리케이션 코드베이스에는 더 인프라 수준의 작업을 수행하는 범용 코드도 있습니다. 예를 들어 로거, 계측 도구, Redis와 같은 데이터스토어 클라이언트, 데이터베이스 유틸리티 등이 있습니다.

범용 코드는 애플리케이션 실행에 필수적이지만, GitLab 제품에 고유한 비즈니스 로직을 설명하지 않습니다. 비즈니스 로직에 영향을 주지 않고 기성 솔루션으로 재작성하거나 교체할 수 있습니다. 따라서 범용 코드는 도메인 코드와 분리되어야 합니다.

현재 많은 범용 코드가 lib/에 있지만 도메인 코드와 혼재하고 있습니다. Gems 개발 가이드라인에 설명된 것처럼 gem을 gems/ 디렉터리로 추출해야 합니다.

전지전능 클래스 제어하기#

전지전능 클래스(god object라고도 함)에 새로운 데이터와 동작을 추가하지 않도록 주의해야 합니다. Project, User, MergeRequest, Ci::Pipeline 및 1000줄 이상의 클래스를 전지전능한 것으로 간주합니다.

이러한 클래스는 책임이 과도하게 집중되어 있습니다. 새로운 데이터와 동작은 대부분 별도의 전용 클래스로 추가할 수 있습니다.

가이드라인:

  • 주로 객체 ID(예: Project#id)에 대한 참조만 필요한 경우, 외래 키를 사용하는 새 모델이나 특별한 동작을 추가하기 위해 객체를 얇게 감싸는 래퍼를 추가할 수 있습니다.

  • 전지전능 클래스에 메서드를 추가하다 보면 몇 가지 다른 메서드(private 또는 public)도 추가하게 된다면, 이는 해당 메서드들이 전용 클래스 안에 캡슐화되어야 한다는 신호입니다.

  • 데이터와 연관의 출발점이기 때문에 Project에 메서드를 추가하고 싶은 유혹이 있습니다. 데이터가 있는 곳이 아니라 해당 동작이 속하는 경계 컨텍스트에서 동작을 정의하세요. 이는 경계 컨텍스트에서 더 관련성이 높은 전지전능 객체의 단면을 만드는 데 도움이 되며, 더 많은 결합도와 복잡성을 유발하는 범용적이고 과부하된 객체를 피할 수 있습니다.

예시: 범용 모델을 감싸는 얇은 도메인 객체 정의하기#

abuse_trust_scores와의 연관이 있다는 이유만으로 User에 여러 메서드를 추가하는 대신, 의존성을 역전해보세요.

##
# BAD: Behavior added to User object.
class User
  def spam_score
    abuse_trust_scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    # Warning sign: we use a constant that belongs to a specific bounded context!
    spam_score > AntiAbuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
  end

  def telesign_score
    abuse_trust_scores.telesign.recent_first.first&.score || 0.0
  end

  def arkose_global_score
    abuse_trust_scores.arkose_global_score.recent_first.first&.score || 0.0
  end

  def arkose_custom_score
    abuse_trust_scores.arkose_custom_score.recent_first.first&.score || 0.0
  end
end

# Usage:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_score
##
# GOOD: Define a thin class that represents a user trust score
class AntiAbuse::UserTrustScore
  def initialize(user)
    @user = user
  end

  def spam
    scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    spam > AntiAbuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
  end

  def telesign
    scores.telesign.recent_first.first&.score || 0.0
  end

  def arkose_global
    scores.arkose_global_score.recent_first.first&.score || 0.0
  end

  def arkose_custom
    scores.arkose_custom_score.recent_first.first&.score || 0.0
  end

  private

  def scores
    AntiAbuse::TrustScore.for_user(@user)
  end
end

# Usage:
user = User.find(1)
user_score = AntiAbuse::UserTrustScore.new(user)
user_score.spam
user_score.spammer?
user_score.telesign
user_score.arkose_global

실제 예시는 이 머지 리퀘스트를 참조하세요.

예시: 의존성 역전을 사용하여 도메인 개념 추출하기#

##
# BAD: methods related to integrations defined in Project.
class Project
  has_many :integrations

  def find_or_initialize_integrations
    # ...
  end

  def find_or_initialize_integration(name)
    # ...
  end

  def disabled_integrations
    # ...
  end

  def ci_integrations
    # ...
  end

  # many more methods...
end
##
# GOOD: All logic related to Integrations is enclosed inside the `Integrations::`
# bounded context.
module Integrations
  class ProjectIntegrations
    def initialize(project)
      @project = project
    end

    def all_integrations
      @project.integrations # can still leverage caching of AR associations
    end

    def find_or_initialize(name)
      # ...
    end

    def all_disabled
      all_integrations.disabled
    end

    def all_ci
      all_integrations.ci_integration
    end
  end
end

유사한 리팩토링의 실제 예시는 이 머지 리퀘스트를 참조하세요.

엔티티가 아닌 유스케이스 중심으로 소프트웨어 설계하기#

Rails는 Active Record의 강력한 기능을 통해 개발자들이 엔티티 중심의 소프트웨어를 설계하도록 유도합니다. 컨트롤러와 API 엔드포인트는 엔티티와 서비스 객체 모두에 대한 CRUD 작업을 나타내는 경향이 있습니다. 새로운 데이터베이스 칼럼은 서로 다른 유스케이스를 참조함에도 불구하고 기존 엔티티 테이블에 추가되는 경향이 있습니다.

이 안티패턴은 종종 다음 중 하나 이상으로 나타납니다:

  • 서로 다른 유스케이스를 위해 검사하는 서로 다른 사전 조건.

  • 동일한 추상화(서비스 객체, 컨트롤러, 시리얼라이저)에서 검사하는 서로 다른 권한.

  • 다양한 암묵적 유스케이스를 위해 동일한 추상화에서 실행되는 서로 다른 부수 효과. 예를 들어, "필드 X가 변경되면 Y를 수행한다".

안티패턴 예시#

Groups::UpdateService는 엔티티 중심으로, 근본적으로 다른 유스케이스에 재사용됩니다:

  • 그룹 설명 업데이트 - 그룹 관리자 접근 권한이 필요합니다.

  • shared_runners_minutes_limit와 같은 컴퓨트 할당량에 대한 네임스페이스 수준 제한 설정 - 인스턴스 관리자 접근 권한이 필요합니다.

이 2가지 서로 다른 유스케이스는 서로 다른 파라미터 세트를 지원합니다. 인스턴스 관리자가 shared_runners_minutes_limit와 그룹 설명을 동시에 업데이트하는 것은 가능하지도 않고 예상되지도 않습니다. 마찬가지로, 사용자가 브랜치 보호 규칙과 인스턴스 러너 설정을 동시에 변경하는 것도 예상되지 않습니다. 이것들은 서로 다른 도메인에서 나온 서로 다른 유스케이스를 나타냅니다.

해결 방법#

엔티티가 아닌 유스케이스를 중심으로 설계하세요. 페르소나, 유스케이스 및 의도가 다르다면 별도의 추상화를 생성하세요:

  • 유스케이스의 특정 도메인에 중첩된 다른 엔드포인트(컨트롤러, GraphQL 또는 REST).

  • 특정 권한과 응집력 있는 파라미터 세트를 포함하는 다른 서비스 객체. 예를 들어, 그룹 관리자가 일반적인 그룹 설정을 업데이트하기 위한 Groups::UpdateService. Ci::Minutes::UpdateLimitService는 인스턴스 관리자를 위한 것으로, 완전히 다른 권한, 기대값, 파라미터 및 부수 효과를 가집니다.

궁극적으로 이는 전지전능 클래스 제어하기의 원칙을 활용해야 합니다. 관련 없는 유스케이스 로직을 단일하고 응집력이 낮은 클래스에 결합하는 것을 피함으로써 느슨한 결합과 높은 응집력을 달성하고자 합니다. 그 결과 권한이 전체 작업에 일관되게 적용되므로 더 안전한 시스템이 됩니다. 마찬가지로 별도의 모델이나 테이블에 정의된 경우 관리자 수준의 데이터를 의도치 않게 노출하지 않습니다. 동일한 유스케이스에 일관되게 속하는 데이터를 읽거나 쓰기 전에 단일 권한 검사를 가질 수 있습니다.

Cells 호환성#

GitLab은 서로 다른 조직이 별도의 GitLab 물리적 인스턴스에서 제공되는 Cells 아키텍처로 이동하고 있습니다. 모든 새 코드는 Cells 호환성을 고려하여 설계되어야 합니다.

전체 개발 원칙은 Cells 개발 원칙을 참조하세요.