소프트웨어 디자인 가이드

CRUD 용어 대신 주도 언어 사용

코드는 제품 및 사용자 설명서에서 사용되는 것과 같은 주도 언어를 사용해야 합니다. 주도 언어를 올바르게 사용하지 않는 것은 번역이나 여러 용어 사용이 지속되는 경우 기여자 및 고객들에게 혼란을 일으킬 수 있는 주요한 이유가 될 수 있습니다. 이는 또한 저희의 커뮤니케이션 전략에 반하는 것입니다.

아래 예시에서 CRUD 용어는 모호함을 도입합니다. 이름은 epic_issues 연관 레코드를 생성하고 있음을 나타냅니다만, 실제로는 기존 이슈를 epic에 추가하고 있습니다. Rails 규칙에서 사용된 epic_issues 이름은 서비스 오브젝트와 같은 더 높은 추상화 수준에 노출됩니다. 코드는 주도 언어가 아닌 프레임워크 용어를 사용합니다.

# 나쁨
EpicIssues::CreateService

주도 언어를 사용하면 코드가 명확해지며 독자가 프레임워크 용어를 번역하려고 시도할 때 무언가를 이해할 때 발생하는 인지 부담을 도입하지 않습니다.

# 좋음
Epic::AddExistingIssueService

프로젝트 생성과 같이 모호하지 않은 간단한 개념을 나타내는 경우와 기존 주도 언어와 일치하는 경우 CRUD를 사용할 수 있습니다.

# 괜찮음: 제품 언어와 일치합니다.
Projects::CreateService

새 클래스 및 데이터베이스 테이블은 주도 언어를 사용해야 합니다. 이 경우 모델 이름 및 테이블 이름은 Rails 규칙을 따릅니다.

주도 언어를 따르지 않는 기존 클래스는 가능한 경우 이름을 바꾸어야 합니다. 데이터베이스 테이블과 같은 낮은 수준의 추상화는 이름을 바꿀 필요가 없습니다. 예를 들어, 모델 이름이 테이블 이름과 다른 경우 self.table_name=을 사용하세요.

이름을 변경하기 어려운 경우에만 예외를 허용할 수 있습니다. 예를 들어, 이름이 사용자에게 노출되거나, 변경이 불가피한 경우입니다.

Bounded contexts

Bounded Contexts 워킹 그룹GitLab Modular Monolith 디자인 문서를 참조하여 Bounded Contexts와 관련된 목표, 동기 및 방향에 대해 자세히 살펴보세요.

Bounded contexts 정의에 네임스페이스 사용

건강한 애플리케이션은 플레이 중인 Bounded Context를 나타내는 매크로 및 서브 구성 요소로 나누어집니다. GitLab 코드에는 많은 기능 및 구성 요소가 있기 때문에 어떤 컨텍스트가 연관되어 있는지 파악하기가 어렵습니다. 이러한 구성 요소는 비즈니스 도메인 또는 인프라 코드와 관련될 수 있습니다.

어떤 클래스든 운영되는 컨텍스트를 나타내는 모듈/네임스페이스 내에 정의되도록 기대해야 합니다. 이러한 컨텍스트를 정의하기 위해 허용된 네임스페이스 목록을 유지합니다.

우리가 클래스를 도메인 내에 네임스페이스화할 때:

  • 도메인이 의미를 명확히하므로 유사한 용어는 모호성이 사라집니다: 예를 들어, MergeRequests::DiffNotes::Diff.
  • 최상위 네임스페이스는 도메인 전문가로 식별된 하나 이상의 그룹에 연계될 수 있습니다.
  • 구성 요소 간의 상호 작용 및 결합을 더 잘 식별할 수 있습니다. 예를 들어, MergeRequests:: 도메인 내의 여러 클래스는 Ci:: 도메인보다는 더 많이 상호 작용하고 Import:: 도메인과는 덜 상호 작용합니다.
# 나쁨
class JobArtifact ... end

# 좋음
module Ci
  class JobArtifact ... end
end

Bounded contexts 정의 방법

허용된 Bounded Context는 config/bounded_contexts.yml에 정의되어 있으며, 도메인 레이어 및 인프라 레이어의 네임스페이스를 포함합니다.

도메인 레이어에 대해서는:

  1. application adapters (컨트롤러, API 엔드포인트 및 뷰)를 제외한 app 내의 코드.
  2. 도메인 논리에 특별히 관련된 lib 내의 코드.

이에는 ActiveRecord 모델, 서비스 오브젝트, 워커, 도메인 고유의 Plain Old Ruby Objects이 포함됩니다.

현재 우리는 노력을 줄이기 위해 및 특정 엔드포인트가 항상 단일 도메인에 맞지는 않기 때문에 응용모듈이 모듈화에서 제외하지만 (예: 설정, 병합 요청 뷰, 프로젝트 뷰 등) 이에 포함합니다.

인프라 레이어에 대해서는 일반적인 용도의 lib 내의 코드로, GitLab 비즈니스 개념을 포함하지 않고 Ruby 젬으로 추출될 수 있는 코드입니다.

최상위 네임스페이스(경계된 컨텍스트)에 이름을 지정하는 좋은 지침은 관련 기능 범주를 사용하는 것입니다. 예를 들어, Continuous Integration 기능 범주는 Ci:: 네임스페이스에 매핑됩니다.

프로젝트 및 그룹은 일반적으로 테넌트를 식별하는 컨테이너 개념입니다. 기능은 프로젝트 또는 그룹 수준에서 존재하지만, 해당 기능을 Projects:: 또는 Groups:: 아래가 아닌 관련된 경계된 컨텍스트 아래에 포함해야 합니다.

Projects::Groups:: 네임스페이스는 엄격하게 관련된 개념에만 사용해야 합니다. 예를 들어, Project::CreateService 또는 Groups::TransferService.

컨트롤러의 경우 app/controllers/projectsapp/controllers/groups도 예외로 허용하며, 이는 bounded contexts가 응용 프로그램 레이어에 적용되지 않기 때문입니다. 우리는 특정 웹 엔드포인트의 범위를 나타내기 위해 이 규칙을 사용합니다.

기능 범주를 사용하지 말고 단계 또는 그룹 이름을 사용하지 마세요. 왜냐하면 향후 다른 그룹으로 재할당 될 가능성이 있는 기능 범주가 있기 때문입니다.

# 나쁨
module Create
  class Commit ... end
end

# 좋음
module Repositories
  class Commit ... end
end

반면에 feature 범주는 때때로 너무 세심할 수 있습니다. 기능은 Product 및 Marketing에 따라 다르게 처리되는 경향이 있으며, 그들은 내부적으로 도메인 모델 및 동작을 많이 공유할 수 있습니다. 이 경우, 지나치게 많은 bounded contexts를 가지면 얕아지고 다른 컨텍스트와 더 많이 결합될 수 있습니다.

Bounded contexts(또는 최상위 네임스페이스)는 전반적인 앱 중의 매크로 컴포넌트로 볼 수 있습니다. 좋은 bounded contexts는 심층적이어야 하므로 도메인의 복잡한 부분을 더 자세히 나누기 위해 중첩된 네임스페이스를 고려하십시오. 예를 들어, Ci::Config::.

예를 들어, ContainerScanning::, ContainerHostSecurity::, ContainerNetworkSecurity::와 같이 별도로 세부 및 세분화된 bounded contexts대신:

module Security::Container
  module Scanning ... end

  module NetworkSecurity ... end

  module HostSecurity ... end
end

네임스페이스에 정의된 클래스가 다른 네임스페이스의 클래스와 많은 공통점을 갖는 경우, 이 두 네임스페이스가 동일한 bounded context의 일부임을 의미할 가능성이 높습니다.

GitLab/BoundedContexts RuboCop offenses 해결 방법

Gitlab/BoundedContexts RuboCop 셜자는 모든 루비 클래스 또는 모듈이 config/bounded_contexts.yml에 있는 최상위 루비 네임스페이스에 중첩되어 있는지 확인합니다.

위반 사항은 상수를 기존 bound 코드 네임 스페이스 안으로 중첩시켜 해결해야 합니다.

  • config/bounded_contexts.yml에서 기능과 더 밀접하게 관련된 네임스페이스를 검색하고, 예를 들어 기능 카테고리와 일치시켜 찾습니다.
  • 필요한 경우, 하위 네임스페이스를 사용하여 상수를 네임스페이스 안에 추가적으로 중첩합니다. 예: Repositories::Mirrors::SyncService.
  • 기존 관련 코드를 동일한 네임스페이스로 이동시키기 위해 후속 이슈를 생성합니다.

특별한 사례에서 우리는 목록에 새로운 bounded context를 추가해야 할 수 있습니다. 이는 다음 경우에 수행할 수 있습니다.

  • 기존의 bounded context와 일치하지 않는 새로운 제품 카테고리를 도입하는 경우.
  • 기존 것이 너무 커서 둘을 분리하고 싶다면 기존 것에서 bounded context를 추출하는 경우.

GitLab/BoundedContexts 및 config/bounded_contexts.yml FAQ

  1. 어떤 상황에서 이 셜자를 비활성화해야 하는 경우가 있나요?

    • 이 셜자는 비활성화해서는 안 되지만, 해당 클래스나 모듈이 함께 이동해야 하는 클래스 모음의 일부인 경우 일시적으로 비활성화할 수 있습니다. 이 경우 셜자를 비활성화하고 한꺼번에 모든 클래스를 이동하기 위한 후속 이슈를 생성할 수 있습니다.
  2. 기존 코드를 규정 준수하도록 리팩토링하는 데 권장되는 시간 계획이 있나요?

    • 우리는 정의된 시간 계획이 없지만, 코드를 조화롭게 통일시키는 대로 빨리하는 것이 좋습니다.
  3. 기존 Sidekiq 워커에 bounded context가 적용되나요?

    • 기존 워커는 이미 RuboCop TODO 파일에 있으므로 오류를 발생시키지 않습니다. 그러나 가능한 경우 bounded context로 이동해야 합니다. Sidekiq 워커 이름 바꾸기 가이드를 따르세요.
  4. 기능 카테고리를 이름을 변경하고 config/bounded_contexts.yml이 해당 내용을 참조하는 경우 안전하게 업데이트해도 되나요?

    • 예, 파일은 기능 카테고리가 bounded context로 매핑되어 config/feature_categories.yml에 정의되어 있다고 예상하고 있으며 이러한 값에 특별히 의존하는 내용은 없습니다. 이 매핑은 주로 기여자가 특정 기능이 코드베이스에서 어디에 있는지 이해하는 데 사용됩니다.

도메인 코드와 일반 코드를 구별하기

위의 가이드라인은 주로 도메인 코드에 해당합니다. 도메인 코드에는 주어진 bounded context를 나타내는 네임스페이스 아래에 루비 클래스를 넣어야 합니다(일관된 기능과 기능 세트).

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

응용 프로그램 코드베이스에는 GitLab 제품에 고유하지 않은 비즈니스 로직을 설명하지 않는 일반 코드도 있습니다. 이는 로거, 계측, Redis와 같은 데이터 저장소를 위한 클라이언트, 데이터베이스 유틸리티 등일 수 있습니다.

응용 프로그램이 실행되기 위해 꼭 필요하지만 GitLab 제품에 고유한 비즈니스 로직을 설명하지 않습니다. 이것은 일반 코드와도 관련이 없으며 오프 스텔프 솔루션으로 다시 작성하거나 교체할 수 있습니다. 따라서 일반 코드는 도메인 코드와 별도로 존재해야 합니다.

오늘날 많은 일반 코드가 lib/에 존재하지만 도메인 코드와 섞여 있습니다. 우리는 젬 개발 가이드라인에 설명된 대로 젬을 gems/ 디렉터리로 추출해야 합니다.

오미니언트 클래스 다스리기

우리는 오미니언트 클래스(또는 god 객체로도 알려짐)에 새로운 데이터와 동작을 추가하지 않는 것을 고려해야 합니다. 우리는 Project, User, MergeRequest, Ci::Pipeline 및 1000 LOC 이상의 모든 클래스를 오미니언트**로 간주합니다.

지침:

  • 주로 객체 ID에 대한 참조가 필요한 경우 (예: Project#id) 외부 키를 사용하거나 특별한 동작을 추가할 수 있는 가늠자로 사용할 객체를 추가할 수 있습니다.
  • 오미니언트 클래스에 메서드를 추가할 때 몇 가지 다른 메서드(비공개 또는 공개)를 추가하는 것으로 알게 된 경우 이러한 메서드는 전용 클래스에 캡슐화되어야 한다는 신호입니다.
  • 데이터와 연합 클래스에 메서드를 추가하기로 결정했습니다. 이것은 시작점이어도 되지만 데이터 (또는 그 중 일부)가 있는 곳이 아니라 해당 bounded context에 동작을 정의하려고 노력해야 합니다. 이는 오미니언트 객체의 개선된 페이셋을 만들어 갖추는 데 도움이 되며 일반적이고 과부하가 많은 객체보다 한정되고 복잡성을 가져옵니다.

예: 일반 모델 주변에서 얇은 도메인 객체 정의

여러 메서드를 User에 추가하는 대신 abuse_trust_scores에 연관이 있기 때문에 여러 메서드를 User에 추가하는 대신 의존성을 뒤집어서 시도하세요.

##
# 나쁨: User 객체에 동작 추가됨.
class User
  def spam_score
    abuse_trust_scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    # 경고: 특정 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

# 사용법:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_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

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

실제 예시는 머지 요청에서 확인할 수 있습니다.

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

##
# 나쁨: 프로젝트 내에서 정의된 통합과 관련된 메서드들.
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

  # 많은 메서드들...
end
##
# 좋음: 모든 통합에 관련된 로직은 `Integrations::` 바운디드 컨텍스트 내에 포함됩니다.
module Integrations
  class ProjectIntegrations
    def initialize(project)
      @project = project
    end

    def all_integrations
      @project.integrations # 여전히 AR 연관성 캐싱을 활용할 수 있음
    end

    def find_or_initialize(name)
      # ...
    end

    def all_disabled
      all_integrations.disabled
    end

    def all_ci
      all_integrations.ci_integration
    end
  end
end

유사한 리팩터링의 실제 예시.

유즈케이스를 중심으로 소프트웨어 디자인하기

Active Record의 기능을 통해 Rails는 개발자들에게 엔티티 중심 소프트웨어를 디자인하도록 유도합니다. 컨트롤러와 API 엔드포인트는 주로 엔티티와 서비스 객체의 CRUD 작업을 나타냅니다. 새로운 데이터베이스 컬럼은 서로 다른 유즈케이스를 참조할지라도 기존 엔티티 테이블에 추가될 경향이 있습니다.

이 안티 패턴은 다음 중 하나 이상의 형태로 자주 나타납니다:

  • 다른 전제 조건 여러 사용 사례를 위해 체크됨.
  • 같은 추상화 내에서 체크된 다른 권한 (서비스 객체, 컨트롤러, 직렬화기).
  • 같은 추상화 내에서 실행된 다른 부작용이 서로 다른 암시적 사용 사례를 위해. 예를 들어, “필드 X가 변경되었을 때, Y를 수행하세요”.

안티 패턴 예시

우리는 엔티티 중심인 Groups::UpdateService가 근본적으로 다른 사용 사례를 위해 재사용되는 것을 가지고 있습니다:

  • 그룹 설명 업데이트, 이를 위해 그룹 관리자 액세스가 필요합니다.
  • 컴퓨트 할당량을 위한 네임스페이스 수준 리미트 설정, shared_runners_minutes_limit를 예로 들 수 있으며, 이를 위해 인스턴스 관리자 액세스가 필요합니다.

이 두 가지 다른 사용 사례는 서로 다른 파라미터 세트를 지원합니다. 기대되지도, 예상되지도 않습니다. 인스턴스 관리자가 shared_runners_minutes_limit를 업데이트하고 그룹 설명도 동시에 업데이트할 것으로 예상되지 않습니다. 마찬가지로 사용자가 동시에 브랜치 보호 규칙과 공유 실행자 설정을 변경할 것으로 예상하지 않습니다. 이는 서로 다른 도메인에서 나오는 서로 다른 사용 사례를 대표합니다.

해결책

엔티티 대신 사용 사례를 중심으로 디자인합니다. 만약 개인, 사용 사례, 의도가 다르다면 별도의 추상화를 생성하세요:

  • 특정 사용 사례의 도메인에 중첩된 다른 엔드포인트 (컨트롤러, GraphQL, 또는 REST).
  • 특정 권한과 일관된 파라미터 세트를 포함하는 다른 서비스 객체. 예를 들어, 그룹 관리자가 일반적인 그룹 설정을 업데이트하는 데 사용되는 Groups::UpdateService. 인스턴스 관리자용 Ci::Minutes::UpdateLimitService는 완전히 다른 권한, 기대, 파라미터 및 부작용을 가질 것입니다.

이는 최종적으로 Omniscient 클래스 길들이기의 원칙을 활용하는 것을 필요로 합니다. 우리는 라이트 커플링과 하이 코헤전스를 달성하기를 원하며, 관련없는 사용 사례 로직을 단일하고, 덜 결합된 클래스에 치우치지 않음으로써 이를 피합니다. 결과는 권한이 작업 전체에 일관되게 적용되므로 보안이 강화된 시스템입니다. 마찬가지로, 우리는 일관되게 같은 사용 사례에 속하는 데이터를 읽거나 쓰기 전에 단일 권한 검사를 할 수 있습니다. 또한, 별도의 모델이나 테이블에 정의된 경우에도 관리자 수준 데이터를 우연히 노출시키지 않습니다.