InfoGrab DocsInfoGrab Docs

소프트웨어 설계 가이드

요약

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

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

코드는 제품과 사용자 문서에서 쓰는 것과 같은 유비쿼터스 언어를 사용해야 합니다. 유비쿼터스 언어를 올바르게 사용하지 않으면 용어를 계속 바꿔 말하거나 여러 용어를 함께 쓰게 되어 기여자와 고객에게 큰 혼란을 일으킬 수 있습니다. 또한 이는 GitLab의 커뮤니케이션 전략에도 어긋납니다.

아래 예시에서는 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에 쓰이거나, 사용자에게 노출되거나, 호환성이 깨지는 변경이 되는 경우입니다.

경계 컨텍스트#

경계 컨텍스트의 목표와 동기, 방향에 대한 자세한 내용은 Bounded Contexts 워킹 그룹과 GitLab Modular Monolith 설계 문서를 참고합니다.

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

건전한 애플리케이션은 작동 중인 경계 컨텍스트를 나타내는 거시 컴포넌트와 하위 컴포넌트로 나뉩니다. GitLab 코드에는 기능과 컴포넌트가 워낙 많아 어떤 컨텍스트가 관여하는지 파악하기 어렵습니다. 이러한 컴포넌트는 비즈니스 도메인이나 인프라 코드와 관련될 수 있습니다.

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

클래스를 해당 도메인 안에 네임스페이스로 묶으면 다음과 같은 이점이 있습니다.

  • 도메인이 의미를 분명히 해 주므로 비슷한 용어의 모호함이 사라집니다. 예를 들어 MergeRequests::Diff와 Notes::Diff가 그렇습니다.
  • 최상위 네임스페이스를 도메인 전문가로 지정된 하나 이상의 그룹과 연결할 수 있습니다.
  • 컴포넌트 사이의 상호작용과 결합을 더 잘 파악할 수 있습니다. 예를 들어 MergeRequests:: 도메인의 여러 클래스는 Ci:: 도메인과 더 많이 상호작용하고 Import:: 와는 덜 상호작용합니다.
# bad
class JobArtifact ... end

# good
module Ci
  class JobArtifact ... end
end

경계 컨텍스트 정의 방법#

허용된 경계 컨텍스트는 config/bounded_contexts.yml에 정의되며, 이 파일에는 도메인 계층과 인프라 계층의 네임스페이스가 들어 있습니다.

도메인 계층은 다음을 가리킵니다.

  1. 애플리케이션 어댑터(컨트롤러, API 엔드포인트, 뷰)를 제외한 app의 코드입니다.
  2. 도메인 로직과 직접 관련된 lib의 코드입니다.

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

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

인프라 계층은 범용 목적의 lib 코드 중 GitLab 비즈니스 개념을 담고 있지 않으며 Ruby gem으로 추출할 수 있는 코드를 가리킵니다.

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

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

Projects::와 Groups:: 네임스페이스는 그 자체와 엄격하게 관련된 개념에만 사용해야 합니다. 예를 들어 Project::CreateService 나 Groups::TransferService가 그렇습니다.

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

기능 카테고리는 앞으로 다른 그룹에 재배정될 수 있으므로 Stage 나 그룹 이름은 사용하지 않습니다.

# bad
module Create
  class Commit ... end
end

# good
module Repositories
  class Commit ... end
end

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

경계 컨텍스트(또는 최상위 네임스페이스)는 애플리케이션 전체에서 거시 컴포넌트로 볼 수 있습니다. 좋은 경계 컨텍스트는 깊어야 하므로, 도메인의 복잡한 부분을 더 잘게 나누기 위해 중첩 네임스페이스를 두는 방안을 검토합니다. 예를 들어 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#

  1. cop을 비활성화해야 하는 상황

    • cop은 비활성화하지 않아야 하지만, 위반하는 클래스나 모듈이 함께 옮겨야 할 클래스 묶음의 일부라면 일시적으로 비활성화할 수 있습니다. 이 경우 cop을 비활성화하고 모든 클래스를 한 번에 옮기기 위한 후속 이슈를 만듭니다.
  2. 기존 코드를 모두 규정에 맞게 리팩터링하는 권장 일정

    • 정해진 일정은 없지만, 코드를 빨리 통합할수록 일관성이 높아집니다.
  3. 경계 컨텍스트의 기존 Sidekiq 워커 적용 여부

    • 기존 워커는 이미 RuboCop TODO 파일에 있으므로 위반이 발생하지 않습니다. 다만 가능하면 경계 컨텍스트로 옮겨야 합니다. Sidekiq의 워커 이름 변경 가이드를 따릅니다.
  4. 기능 카테고리 이름을 변경할 때 이를 참조하는 config/bounded_contexts.yml 업데이트의 안전성

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

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

위의 지침은 주로 도메인 코드를 다룹니다. 도메인 코드는 특정 경계 컨텍스트(응집도 높은 기능과 역량의 집합)를 나타내는 네임스페이스 아래에 Ruby 클래스를 두어야 합니다.

도메인 코드는 GitLab 제품에만 있는 코드로, 비즈니스 로직과 정책, 데이터를 기술합니다. 이 코드는 GitLab 리포지터리에 있어야 합니다. 도메인 코드는 주로 app/과 lib/에 나뉘어 있습니다.

애플리케이션 코드베이스에는 인프라 수준의 동작을 수행하는 범용 코드도 있습니다. 로거, 계측, Redis 같은 데이터 저장소용 클라이언트, 데이터베이스 유틸리티 등이 여기에 해당합니다.

범용 코드는 애플리케이션 실행에 꼭 필요하지만 GitLab 제품 고유의 비즈니스 로직을 기술하지는 않습니다. 비즈니스 로직에 영향을 주지 않고 다시 작성하거나 기성 솔루션으로 대체할 수 있습니다. 따라서 범용 코드는 도메인 코드와 분리해야 합니다.

현재 많은 범용 코드가 lib/에 있지만 도메인 코드와 섞여 있습니다. Gem 개발 지침에 설명된 대로 gem을 gems/ 디렉터리로 추출해야 합니다.

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

전지전능 클래스(god object 라고도 합니다)에는 새 데이터와 동작을 추가하지 않는 방안을 검토해야 합니다. Project, User, MergeRequest, Ci::Pipeline과 1000 LOC를 넘는 모든 클래스를 전지전능 클래스로 봅니다.

이러한 클래스에는 책임이 지나치게 많습니다. 새 데이터와 동작은 대부분 별도의 전용 클래스로 추가할 수 있습니다.

지침은 다음과 같습니다.

  • 주로 객체 ID(예: Project#id) 참조만 필요하다면, 외래 키를 사용하는 새 모델을 추가하거나 특별한 동작을 더하기 위해 객체를 감싸는 얇은 래퍼를 둘 수 있습니다.
  • 전지전능 클래스에 메서드를 하나 추가하다가 다른 메서드(비공개든 공개든)까지 여럿 추가하게 된다면, 이 메서드들을 전용 클래스로 캡슐화해야 한다는 신호입니다.
  • Project는 데이터와 연관 관계의 출발점이므로 여기에 메서드를 추가하고 싶어지기 쉽습니다. 동작은 데이터(또는 그 일부)가 있는 곳이 아니라 그 동작이 속한 경계 컨텍스트에 정의합니다. 이렇게 하면 결합과 복잡도를 키우는 범용적이고 과부하된 객체 대신, 해당 경계 컨텍스트에서 훨씬 더 적절한 전지전능 객체의 단면을 만들 수 있습니다.

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

User에 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 작업을 나타내는 경향이 있습니다. 새 데이터베이스 칼럼은 서로 다른 유스케이스를 다루는데도 기존 엔티티 테이블에 추가되곤 합니다.

이 안티패턴은 흔히 다음 중 하나 이상으로 나타납니다.

  • 서로 다른 유스케이스에 대해 서로 다른 사전 조건을 확인합니다.
  • 같은 추상화(서비스 객체, 컨트롤러, 시리얼라이저)에서 서로 다른 권한을 확인합니다.
  • 여러 암묵적 유스케이스에 대해 같은 추상화에서 서로 다른 부수 효과를 실행합니다. 예를 들어 "if field X changed, do Y" 같은 식입니다.

안티패턴 예시#

Groups::UpdateService는 엔티티 중심이며 성격이 크게 다른 유스케이스에 재사용되고 있습니다.

  • 그룹 관리자 액세스가 필요한 그룹 설명 업데이트입니다.
  • 인스턴스 관리자 액세스가 필요한 shared_runners_minutes_limit처럼 컴퓨팅 할당량의 네임스페이스 수준 한도 설정입니다.

이 두 유스케이스는 서로 다른 파라미터 집합을 지원합니다. 인스턴스 관리자가 shared_runners_minutes_limit와 그룹 설명을 함께 수정하는 일은 흔하지도 않고 예상되지도 않습니다. 마찬가지로 사용자가 브랜치 보호 규칙과 인스턴스 러너 설정을 동시에 바꾸는 일도 예상되지 않습니다. 이들은 서로 다른 도메인에서 온 서로 다른 유스케이스입니다.

해결 방법#

엔티티가 아니라 유스케이스를 중심으로 설계합니다. 페르소나와 유스케이스, 의도가 다르다면 별도의 추상화를 만듭니다.

  • 해당 유스케이스의 특정 도메인 아래에 중첩된 별도의 엔드포인트(컨트롤러, GraphQL, REST)를 둡니다.
  • 특정 권한과 응집도 높은 파라미터 집합을 담은 별도의 서비스 객체를 둡니다. 예를 들어 Groups::UpdateService는 그룹 관리자가 일반 그룹 설정을 업데이트하는 데 사용합니다. Ci::Minutes::UpdateLimitService는 인스턴스 관리자를 위한 것으로, 권한과 기대 동작, 파라미터, 부수 효과가 완전히 다릅니다.

궁극적으로 이는 전지전능 클래스 제어하기의 원칙을 활용해야 합니다. 서로 관련 없는 유스케이스 로직을 응집도 낮은 하나의 클래스에 묶지 않음으로써 느슨한 결합과 높은 응집도를 달성하려는 것입니다. 그 결과 권한이 동작 전체에 일관되게 적용되므로 시스템이 더 안전해집니다. 또한 관리자 수준 데이터를 별도 모델이나 테이블에 정의하면 의도치 않게 노출하는 일도 없습니다. 같은 유스케이스에 일관되게 속하는 데이터를 읽거나 쓰기 전에 권한 검사를 한 번만 수행할 수 있습니다.

Cells 호환성#

GitLab은 서로 다른 조직이 각기 다른 물리적 GitLab 인스턴스로 서비스되는 Cells 아키텍처로 전환하고 있습니다. 모든 새 코드는 Cells 호환성을 고려해 설계해야 합니다.

개발 원칙 전체는 Cells 개발 원칙을 참고합니다.

소프트웨어 설계 가이드

GitLab v19.4
원문 보기

요약

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

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

코드는 제품과 사용자 문서에서 쓰는 것과 같은 유비쿼터스 언어를 사용해야 합니다. 유비쿼터스 언어를 올바르게 사용하지 않으면 용어를 계속 바꿔 말하거나 여러 용어를 함께 쓰게 되어 기여자와 고객에게 큰 혼란을 일으킬 수 있습니다. 또한 이는 GitLab의 커뮤니케이션 전략에도 어긋납니다.

아래 예시에서는 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에 쓰이거나, 사용자에게 노출되거나, 호환성이 깨지는 변경이 되는 경우입니다.

경계 컨텍스트#

경계 컨텍스트의 목표와 동기, 방향에 대한 자세한 내용은 Bounded Contexts 워킹 그룹과 GitLab Modular Monolith 설계 문서를 참고합니다.

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

건전한 애플리케이션은 작동 중인 경계 컨텍스트를 나타내는 거시 컴포넌트와 하위 컴포넌트로 나뉩니다. GitLab 코드에는 기능과 컴포넌트가 워낙 많아 어떤 컨텍스트가 관여하는지 파악하기 어렵습니다. 이러한 컴포넌트는 비즈니스 도메인이나 인프라 코드와 관련될 수 있습니다.

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

클래스를 해당 도메인 안에 네임스페이스로 묶으면 다음과 같은 이점이 있습니다.

  • 도메인이 의미를 분명히 해 주므로 비슷한 용어의 모호함이 사라집니다. 예를 들어 MergeRequests::Diff와 Notes::Diff가 그렇습니다.
  • 최상위 네임스페이스를 도메인 전문가로 지정된 하나 이상의 그룹과 연결할 수 있습니다.
  • 컴포넌트 사이의 상호작용과 결합을 더 잘 파악할 수 있습니다. 예를 들어 MergeRequests:: 도메인의 여러 클래스는 Ci:: 도메인과 더 많이 상호작용하고 Import:: 와는 덜 상호작용합니다.
# bad
class JobArtifact ... end

# good
module Ci
  class JobArtifact ... end
end

경계 컨텍스트 정의 방법#

허용된 경계 컨텍스트는 config/bounded_contexts.yml에 정의되며, 이 파일에는 도메인 계층과 인프라 계층의 네임스페이스가 들어 있습니다.

도메인 계층은 다음을 가리킵니다.

  1. 애플리케이션 어댑터(컨트롤러, API 엔드포인트, 뷰)를 제외한 app의 코드입니다.
  2. 도메인 로직과 직접 관련된 lib의 코드입니다.

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

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

인프라 계층은 범용 목적의 lib 코드 중 GitLab 비즈니스 개념을 담고 있지 않으며 Ruby gem으로 추출할 수 있는 코드를 가리킵니다.

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

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

Projects::와 Groups:: 네임스페이스는 그 자체와 엄격하게 관련된 개념에만 사용해야 합니다. 예를 들어 Project::CreateService 나 Groups::TransferService가 그렇습니다.

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

기능 카테고리는 앞으로 다른 그룹에 재배정될 수 있으므로 Stage 나 그룹 이름은 사용하지 않습니다.

# bad
module Create
  class Commit ... end
end

# good
module Repositories
  class Commit ... end
end

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

경계 컨텍스트(또는 최상위 네임스페이스)는 애플리케이션 전체에서 거시 컴포넌트로 볼 수 있습니다. 좋은 경계 컨텍스트는 깊어야 하므로, 도메인의 복잡한 부분을 더 잘게 나누기 위해 중첩 네임스페이스를 두는 방안을 검토합니다. 예를 들어 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#

  1. cop을 비활성화해야 하는 상황

    • cop은 비활성화하지 않아야 하지만, 위반하는 클래스나 모듈이 함께 옮겨야 할 클래스 묶음의 일부라면 일시적으로 비활성화할 수 있습니다. 이 경우 cop을 비활성화하고 모든 클래스를 한 번에 옮기기 위한 후속 이슈를 만듭니다.
  2. 기존 코드를 모두 규정에 맞게 리팩터링하는 권장 일정

    • 정해진 일정은 없지만, 코드를 빨리 통합할수록 일관성이 높아집니다.
  3. 경계 컨텍스트의 기존 Sidekiq 워커 적용 여부

    • 기존 워커는 이미 RuboCop TODO 파일에 있으므로 위반이 발생하지 않습니다. 다만 가능하면 경계 컨텍스트로 옮겨야 합니다. Sidekiq의 워커 이름 변경 가이드를 따릅니다.
  4. 기능 카테고리 이름을 변경할 때 이를 참조하는 config/bounded_contexts.yml 업데이트의 안전성

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

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

위의 지침은 주로 도메인 코드를 다룹니다. 도메인 코드는 특정 경계 컨텍스트(응집도 높은 기능과 역량의 집합)를 나타내는 네임스페이스 아래에 Ruby 클래스를 두어야 합니다.

도메인 코드는 GitLab 제품에만 있는 코드로, 비즈니스 로직과 정책, 데이터를 기술합니다. 이 코드는 GitLab 리포지터리에 있어야 합니다. 도메인 코드는 주로 app/과 lib/에 나뉘어 있습니다.

애플리케이션 코드베이스에는 인프라 수준의 동작을 수행하는 범용 코드도 있습니다. 로거, 계측, Redis 같은 데이터 저장소용 클라이언트, 데이터베이스 유틸리티 등이 여기에 해당합니다.

범용 코드는 애플리케이션 실행에 꼭 필요하지만 GitLab 제품 고유의 비즈니스 로직을 기술하지는 않습니다. 비즈니스 로직에 영향을 주지 않고 다시 작성하거나 기성 솔루션으로 대체할 수 있습니다. 따라서 범용 코드는 도메인 코드와 분리해야 합니다.

현재 많은 범용 코드가 lib/에 있지만 도메인 코드와 섞여 있습니다. Gem 개발 지침에 설명된 대로 gem을 gems/ 디렉터리로 추출해야 합니다.

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

전지전능 클래스(god object 라고도 합니다)에는 새 데이터와 동작을 추가하지 않는 방안을 검토해야 합니다. Project, User, MergeRequest, Ci::Pipeline과 1000 LOC를 넘는 모든 클래스를 전지전능 클래스로 봅니다.

이러한 클래스에는 책임이 지나치게 많습니다. 새 데이터와 동작은 대부분 별도의 전용 클래스로 추가할 수 있습니다.

지침은 다음과 같습니다.

  • 주로 객체 ID(예: Project#id) 참조만 필요하다면, 외래 키를 사용하는 새 모델을 추가하거나 특별한 동작을 더하기 위해 객체를 감싸는 얇은 래퍼를 둘 수 있습니다.
  • 전지전능 클래스에 메서드를 하나 추가하다가 다른 메서드(비공개든 공개든)까지 여럿 추가하게 된다면, 이 메서드들을 전용 클래스로 캡슐화해야 한다는 신호입니다.
  • Project는 데이터와 연관 관계의 출발점이므로 여기에 메서드를 추가하고 싶어지기 쉽습니다. 동작은 데이터(또는 그 일부)가 있는 곳이 아니라 그 동작이 속한 경계 컨텍스트에 정의합니다. 이렇게 하면 결합과 복잡도를 키우는 범용적이고 과부하된 객체 대신, 해당 경계 컨텍스트에서 훨씬 더 적절한 전지전능 객체의 단면을 만들 수 있습니다.

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

User에 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 작업을 나타내는 경향이 있습니다. 새 데이터베이스 칼럼은 서로 다른 유스케이스를 다루는데도 기존 엔티티 테이블에 추가되곤 합니다.

이 안티패턴은 흔히 다음 중 하나 이상으로 나타납니다.

  • 서로 다른 유스케이스에 대해 서로 다른 사전 조건을 확인합니다.
  • 같은 추상화(서비스 객체, 컨트롤러, 시리얼라이저)에서 서로 다른 권한을 확인합니다.
  • 여러 암묵적 유스케이스에 대해 같은 추상화에서 서로 다른 부수 효과를 실행합니다. 예를 들어 "if field X changed, do Y" 같은 식입니다.

안티패턴 예시#

Groups::UpdateService는 엔티티 중심이며 성격이 크게 다른 유스케이스에 재사용되고 있습니다.

  • 그룹 관리자 액세스가 필요한 그룹 설명 업데이트입니다.
  • 인스턴스 관리자 액세스가 필요한 shared_runners_minutes_limit처럼 컴퓨팅 할당량의 네임스페이스 수준 한도 설정입니다.

이 두 유스케이스는 서로 다른 파라미터 집합을 지원합니다. 인스턴스 관리자가 shared_runners_minutes_limit와 그룹 설명을 함께 수정하는 일은 흔하지도 않고 예상되지도 않습니다. 마찬가지로 사용자가 브랜치 보호 규칙과 인스턴스 러너 설정을 동시에 바꾸는 일도 예상되지 않습니다. 이들은 서로 다른 도메인에서 온 서로 다른 유스케이스입니다.

해결 방법#

엔티티가 아니라 유스케이스를 중심으로 설계합니다. 페르소나와 유스케이스, 의도가 다르다면 별도의 추상화를 만듭니다.

  • 해당 유스케이스의 특정 도메인 아래에 중첩된 별도의 엔드포인트(컨트롤러, GraphQL, REST)를 둡니다.
  • 특정 권한과 응집도 높은 파라미터 집합을 담은 별도의 서비스 객체를 둡니다. 예를 들어 Groups::UpdateService는 그룹 관리자가 일반 그룹 설정을 업데이트하는 데 사용합니다. Ci::Minutes::UpdateLimitService는 인스턴스 관리자를 위한 것으로, 권한과 기대 동작, 파라미터, 부수 효과가 완전히 다릅니다.

궁극적으로 이는 전지전능 클래스 제어하기의 원칙을 활용해야 합니다. 서로 관련 없는 유스케이스 로직을 응집도 낮은 하나의 클래스에 묶지 않음으로써 느슨한 결합과 높은 응집도를 달성하려는 것입니다. 그 결과 권한이 동작 전체에 일관되게 적용되므로 시스템이 더 안전해집니다. 또한 관리자 수준 데이터를 별도 모델이나 테이블에 정의하면 의도치 않게 노출하는 일도 없습니다. 같은 유스케이스에 일관되게 속하는 데이터를 읽거나 쓰기 전에 권한 검사를 한 번만 수행할 수 있습니다.

Cells 호환성#

GitLab은 서로 다른 조직이 각기 다른 물리적 GitLab 인스턴스로 서비스되는 Cells 아키텍처로 전환하고 있습니다. 모든 새 코드는 Cells 호환성을 고려해 설계해야 합니다.

개발 원칙 전체는 Cells 개발 원칙을 참고합니다.