InfoGrab DocsInfoGrab Docs

추상화 재사용 가이드라인

요약

GitLab이 성장함에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 등장했습니다. 코드 재사용은 좋은 관행이지만, 때로는 잘못된 추상화를 특정 사용 사례에 억지로 끼워 맞추는 결과를 초래할 수 있습니다. 예를 들어, IssuesFinder에서 ProjectsFinder를 사용하여 특정 프로젝트에 속한 이슈만 조회하도록 제한하는 경우를 들 수 있습니다.

GitLab이 성장함에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 등장했습니다. Service 클래스, Serializer, Presenter 등이 그 예입니다. 이러한 패턴 덕분에 코드를 쉽게 재사용할 수 있게 되었지만, 동시에 특정 상황에서 잘못된 추상화를 실수로 재사용하기도 쉬워졌습니다.

이 가이드라인이 필요한 이유#

코드 재사용은 좋은 관행이지만, 때로는 잘못된 추상화를 특정 사용 사례에 억지로 끼워 맞추는 결과를 초래할 수 있습니다. 이는 유지보수성, 문제 디버깅 용이성, 심지어 성능에도 부정적인 영향을 미칠 수 있습니다.

예를 들어, IssuesFinder에서 ProjectsFinder를 사용하여 특정 프로젝트에 속한 이슈만 조회하도록 제한하는 경우를 들 수 있습니다. 처음에는 좋은 아이디어처럼 보일 수 있지만, 두 클래스 모두 세밀한 제어가 거의 불가능한 매우 높은 수준의 인터페이스를 제공합니다. 이는 쿼리의 상당 부분이 ProjectsFinder의 내부 동작에 의해 제어되기 때문에 IssuesFinder가 더 최적화된 데이터베이스 쿼리를 생성하지 못할 수 있음을 의미합니다.

이 문제를 해결하려면 ProjectsFinder를 직접 사용하는 대신, ProjectsFinder가 사용하는 동일한 코드를 사용해야 합니다. 이를 통해 동작을 더 잘 구성할 수 있으며, 코드의 동작에 대한 더 많은 제어권을 갖게 됩니다.

이를 설명하기 위해 IssuableFinder#projects의 다음 코드를 살펴보겠습니다:

return @projects = project if project?

projects =
  if current_user && params[:authorized_only].presence && !current_user_related?
    current_user.authorized_projects
  elsif group
    finder_options = { include_subgroups: params[:include_subgroups], exclude_shared: true }
    GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
  else
    ProjectsFinder.new(current_user: current_user).execute
  end

@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)

여기서 우리는 세 가지 다른 방식을 사용하여 데이터의 범위를 지정할 프로젝트를 결정합니다. 그룹이 지정된 경우 GroupProjectsFinder를 사용하여 해당 그룹의 모든 프로젝트를 가져옵니다. 표면적으로는 무해해 보입니다. 사용하기 쉽고 두 줄의 코드만 필요합니다.

실제로는 상황이 매우 복잡해질 수 있습니다. 예를 들어, GroupProjectsFinder가 생성하는 쿼리는 처음에는 단순할 수 있습니다. 시간이 지남에 따라 이 (상위 수준) 인터페이스에 점점 더 많은 기능이 추가됩니다. 이는 필요한 경우에만 영향을 미치는 것이 아니라 IssuableFinder에도 부정적인 방식으로 영향을 미치기 시작할 수 있습니다. 예를 들어, GroupProjectsFinder가 생성하는 쿼리에 불필요한 조건이 포함될 수 있습니다. 여기서 finder를 사용하고 있기 때문에 해당 동작을 쉽게 제외할 수 없습니다. 그렇게 하기 위해 옵션을 추가할 수 있지만, 그렇게 되면 기능 수만큼 많은 옵션이 필요합니다. 각 옵션은 두 개의 코드 경로를 추가하므로, 네 가지 기능에 대해 8개의 서로 다른 코드 경로를 다뤄야 합니다.

이 문제를 처리하는 훨씬 더 신뢰할 수 있는 (그리고 쾌적한) 방법은 GroupProjectsFinder를 구성하는 기본 요소를 직접 사용하는 것입니다. 이는 IssuableFinder에 코드가 조금 더 필요할 수 있지만, 훨씬 더 많은 제어권과 확실성을 제공합니다. 결과적으로 다음과 같은 코드가 될 수 있습니다:

return @projects = project if project?

projects =
  if current_user && params[:authorized_only].presence && !current_user_related?
    current_user.authorized_projects
  elsif group
    current_user
      .owned_groups(subgroups: params[:include_subgroups])
      .projects
      .any_additional_method_calls
      .that_might_be_necessary
  else
    current_user
      .projects_visible_to_user
      .any_additional_method_calls
      .that_might_be_necessary
  end

@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)

이것은 단순한 스케치이지만, 일반적인 아이디어를 보여줍니다. GroupProjectsFinderProjectsFinder finder가 내부적으로 사용하는 것을 그대로 사용하는 것입니다.

최종 목표#

이 문서의 가이드라인은 무엇을 어디서 재사용할 수 있는지, 그리고 재사용할 수 없을 때는 무엇을 해야 하는지를 명확하게 정의하여 더 나은 코드 재사용을 장려하기 위한 것입니다. 추상화를 명확하게 분리하면 잘못된 추상화를 사용하기 어려워지고, 코드를 디버깅하기 쉬워지며, (바라건대) 성능 문제가 줄어듭니다.

추상화#

이제 사용 가능한 다양한 추상화 수준과 각각 재사용할 수 있는 (또는 재사용할 수 없는) 것들을 살펴보겠습니다. 이를 위해 다양한 추상화와 각각이 재사용할 수 있는(없는) 것들을 정의하는 다음 표를 사용할 수 있습니다:

추상화 Service 클래스 Finder Presenter Serializer 모델 인스턴스 메서드 모델 클래스 메서드 Active Record Worker
Controller/API 엔드포인트 Yes Yes Yes Yes Yes No No No
Service 클래스 Yes Yes No No Yes No No Yes
Finder No No No No Yes Yes No No
Presenter No Yes No No Yes Yes No No
Serializer No Yes No No Yes Yes No No
모델 클래스 메서드 No No No No Yes Yes Yes No
모델 인스턴스 메서드 No Yes No No Yes Yes Yes Yes
Worker Yes Yes No No Yes No No Yes

Controller#

app/controllers에 있는 모든 것입니다.

Controller는 자체적으로 많은 작업을 수행해서는 안 되며, 대신 입력을 다른 클래스에 전달하고 결과를 표시합니다.

View#

app/viewsee/app/views에 있는 모든 것입니다.

View는 표시만 담당합니다. Controller가 할당한 인스턴스 변수를 통해 데이터를 받아 HTML, XML, Markdown, 또는 텍스트로 렌더링합니다.

View는 다음을 수행해서는 안 됩니다:

  • 데이터베이스 쿼리 실행. 모든 데이터 조회는 Controller 또는 Presenter로 이동하고, 결과는 인스턴스 변수로 전달합니다. View의 쿼리는 캐싱 레이어를 우회하고, 쿼리 분석 도구에서 보이지 않으며, N+1 문제를 감지하기 어렵게 만듭니다.

  • 비즈니스 로직 포함. nil?, present?, 또는 boolean 속성 확인을 넘어서는 모델 상태를 평가하는 조건문을 피합니다. Service 객체 인스턴스화 및 다단계 연산을 피합니다. 이 로직을 helper, Presenter, 또는 ViewComponent로 추출합니다.

API 엔드포인트#

lib/api(REST API)와 app/graphql(GraphQL API)에 있는 모든 것입니다.

API 엔드포인트는 Controller와 동일한 추상화 수준을 가집니다.

Service 클래스#

app/services에 있는 모든 것입니다.

Service 클래스는 모델(엔티티 및 값 객체 등) 간의 변경을 조율하는 작업을 나타냅니다. 변경 사항은 애플리케이션의 상태에 영향을 미칩니다.

  • 객체가 애플리케이션의 상태를 변경하지 않는 경우, 그것은 Service가 아닙니다. Finder 또는 값 객체일 수 있습니다.

  • 작업이 없는 경우, Service를 실행할 필요가 없습니다. 해당 클래스는 엔티티, 값 객체, 또는 정책으로 더 잘 설계될 가능성이 높습니다.

Service 클래스를 구현할 때는 다음 패턴 사용을 고려하십시오:

Service 클래스 이니셜라이저의 인수에는 다음이 포함되어야 합니다:

작업 대상이 되는 모델 인스턴스. 이니셜라이저의 첫 번째 위치 인수여야 합니다. 인수 이름은 개발자의 재량에 맡겨집니다. 예: issue, project, merge_request.

Service가 사용자에 의해 시작되거나 사용자 컨텍스트에서 실행되는 작업을 나타내는 경우, 이니셜라이저에는 current_user: 키워드 인수가 있어야 합니다. current_user: 인수가 있는 Service는 상위 수준의 비즈니스 로직을 실행하며 작업 수행에 대한 사용자 인가를 검증해야 합니다.

Service에 사용자 컨텍스트가 없고 사용자에 의해 직접 시작되지 않는 경우(예: 백그라운드 Service 또는 부작용), current_user: 인수는 필요하지 않습니다. 이는 하위 수준의 도메인 로직 또는 인스턴스 전체 로직을 설명합니다.

Service에 필요한 모든 추가 데이터에 대해서는 명시적 키워드 인수를 권장합니다. Service에 너무 긴 인수 목록이 필요한 경우 다음으로 분리하는 것을 고려하십시오:

params: 직접 할당될 모델 속성을 담은 해시.

  • options: 추가 매개변수(처리가 필요하며 모델 속성이 아닌 것들)를 담은 해시. options 해시는 인스턴스 변수에 저장되어야 합니다.
# merge_request: A model instance that is being acted upon.
# assignee: new MR assignee that will be assigned to the MR
#   after the service is executed.
def initialize(merge_request, assignee:)
  @merge_request = merge_request
  @assignee = assignee
end
# issue: A model instance that is being acted upon.
# current_user: Current user.
# params: Model properties.
# options: Configuration for this service. Can be any of the following:
#   - notify: Whether to send a notification to the current user.
#   - cc: Email address to copy when sending a notification.
def initialize(issue:, current_user:, params: {}, options: {})
  @issue = issue
  @current_user = current_user
  @params = params
  @options = options
end

Service 클래스는 Service 클래스 동작을 호출하는 단일 public 인스턴스 메서드 #execute를 구현해야 합니다:

#execute 메서드는 인수를 받지 않습니다. 필요한 모든 데이터는 이니셜라이저에 전달됩니다.

반환값이 필요한 경우, #execute 메서드는 ServiceResponse 객체를 통해 결과를 반환해야 합니다.

여러 기본 클래스가 Service 클래스 규약을 구현합니다. 다음을 상속하는 것을 고려할 수 있습니다:

  • 컨테이너(프로젝트 또는 그룹)로 범위가 지정된 Service의 경우 BaseContainerService.

  • 프로젝트로 범위가 지정된 Service의 경우 BaseProjectService.

  • 그룹으로 범위가 지정된 Service의 경우 BaseGroupService.

일부 도메인 또는 bounded context에서는 Service 클래스가 다른 패턴을 사용하는 것이 합리적일 수 있습니다. 예를 들어, Remote Development 도메인은 표준 패턴을 따르는 별도의 도메인 레이어에 도메인 로직을 격리하는 레이어드 아키텍처를 사용하며, 이를 통해 단일 재사용 가능한 CommonService 클래스로만 구성된 매우 최소한의 Service 레이어를 가능하게 합니다. 또한 상태 없는 싱글톤 클래스 메서드를 사용하는 함수형 패턴을 사용합니다. 자세한 내용은 Remote Development Service 레이어 코드 예시를 참조하십시오. 그러나 이 패턴을 통한 Service 호출 서명이 다르더라도, 항상 ServiceResponse 객체를 통해 모든 결과를 반환하고 심층 방어 인가를 수행하는 표준 Service 레이어 계약을 여전히 준수합니다.

Service 객체가 아닌 클래스는 lib과 같이 다른 곳에 생성되어야 합니다.

ServiceResponse#

Service 클래스는 일반적으로 execute 메서드를 가지며, 이는 ServiceResponse를 반환할 수 있습니다. execute 메서드에서 응답을 반환하려면 ServiceResponse.successServiceResponse.error를 사용할 수 있습니다.

성공적인 경우:

response = ServiceResponse.success(message: 'Branch was deleted')

response.success? # => true
response.error? # => false
response.status # => :success
response.message # => 'Branch was deleted'

실패한 경우:

response = ServiceResponse.error(message: 'Unsupported operation')

response.success? # => false
response.error? # => true
response.status # => :error
response.message # => 'Unsupported operation'

추가 페이로드도 첨부할 수 있습니다:

response = ServiceResponse.success(payload: { issue: issue })

response.payload[:issue] # => issue

오류 응답은 호출자가 실패의 성격을 이해하는 데 사용할 수 있는 실패 reason을 지정할 수도 있습니다. HTTP 엔드포인트인 호출자는 reason 심볼을 HTTP 상태 코드로 변환할 수 있습니다:

response = ServiceResponse.error(
  message: 'Job is in a state that cannot be retried',
  reason: :job_not_retrieable)

if response.success?
  head :ok
elsif response.reason == :job_not_retriable
  head :unprocessable_entity
else
  head :bad_request
end

:not_found 또는 :forbidden과 같은 일반적인 실패의 경우, 도메인 로직에 충분히 구체적인 한 Rails HTTP 상태 심볼을 활용할 수 있습니다. 다른 실패의 경우 가능한 한 도메인별 이유를 사용합니다.

예: :job_not_retriable, :duplicate_package, :merge_request_not_mergeable.

Finder#

app/finders에 있는 모든 것으로, 일반적으로 데이터베이스에서 데이터를 조회하는 데 사용됩니다.

Finder는 생성하는 SQL 쿼리를 더 잘 제어하기 위해 다른 Finder를 재사용할 수 없습니다.

Finder의 execute 메서드는 ActiveRecord::Relation을 반환해야 합니다. 예외는 spec/support/finder_collection_allowlist.yml에 추가할 수 있습니다. 자세한 내용은 #298771을 참조하십시오.

Presenter#

app/presenters에 있는 모든 것으로, 많은 인스턴스 변수를 만들지 않고도 Rails View에 복잡한 데이터를 노출하는 데 사용됩니다.

자세한 내용은 문서를 참조하십시오.

Serializer#

app/serializers에 있는 모든 것으로, 일반적으로 JSON 형식으로 요청에 대한 응답을 표시하는 데 사용됩니다.

모델#

app/models의 클래스와 모듈은 데이터와 동작을 모두 캡슐화하는 도메인 개념을 나타냅니다.

이 클래스는 데이터 저장소(ActiveRecord 모델과 같은)와 직접 상호작용하거나, 더 풍부한 도메인 개념을 표현하기 위해 ActiveRecord 모델 위에 얇은 래퍼(Plain Old Ruby Objects)가 될 수 있습니다.

도메인 개념을 나타내는 엔티티와 값 객체는 도메인 모델로 간주됩니다.

몇 가지 예시:

모델 클래스 메서드#

다음 Active Record가 제공하는 메서드를 포함하여 GitLab 자체가 정의한 클래스 메서드입니다:

  • find

  • find_by_id

  • delete_all

  • destroy

  • destroy_all

find_by(some_column: X)와 같은 다른 메서드는 포함되지 않으며, 대신 "Active Record" 추상화에 해당합니다.

모델 인스턴스 메서드#

GitLab 자체가 Active Record 모델에 정의한 인스턴스 메서드입니다. Active Record가 제공하는 메서드는 다음 메서드를 제외하고 포함되지 않습니다:

  • save

  • update

  • destroy

  • delete

Active Record#

where 메서드, save, delete_all 등 Active Record 자체가 제공하는 API입니다.

Worker#

app/workers에 있는 모든 것입니다.

Sidekiq job을 예약하려면 SomeWorker.perform_async 또는 SomeWorker.perform_in을 사용하십시오. 절대 SomeWorker.new.perform을 사용하여 Worker를 직접 호출하지 마십시오.

기본 클래스의 추상 메서드#

서브클래스가 구현해야 하는 메서드를 가진 기본 클래스가 있는 경우, Gitlab::AbstractMethodError를 사용하여 메서드에 구현이 필요하다는 것을 명확하게 신호로 보내십시오.

대부분의 경우 상속보다 구성(Composition)과 덕 타이핑이 선호됩니다. 추상 메서드는 명확하게 적절한 경우(예: 공유 템플릿이 있는 ViewComponent 또는 프레임워크 통합 지점)에만 드물게 사용하십시오. 이 가이던스는 주로 NoMethodErrorNotImplementedError의 기존 잘못된 사용을 교정하기 위한 것입니다.

Gitlab::AbstractMethodError를 사용하는 이유#

  • 의미론적 명확성: 서브클래스가 메서드를 구현해야 함을 명시적으로 나타냅니다.

  • 기본 오류 메시지: 보일러플레이트 오류 메시지를 작성할 필요가 없습니다.

  • NoMethodError 문제 방지: respond_to? 동작과의 충돌이 없습니다.

  • NotImplementedError 오용 방지: NotImplementedError는 추상 메서드가 아닌 플랫폼별 기능을 위한 것입니다.

  • Exception에서 상속: 명시적인 rescue 블록으로만 잡을 수 있습니다.

  • 적용 가능: RuboCop 규칙으로 검증할 수 있습니다(향후 작업).

NotImplementedError나 NoMethodError를 사용하지 않는 이유#

Ruby의 NotImplementedError는 특정 플랫폼이나 구성에서 구현되지 않은 기능(예: Linux에서는 작동하지만 Windows에서는 작동하지 않는 메서드)을 위한 것이지, 객체 지향 설계의 추상 메서드를 위한 것이 아닙니다. Ruby 문서에 다음과 같이 나와 있습니다:

현재 플랫폼에서 기능이 구현되지 않은 경우 발생합니다. 예를 들어, fsync 또는 fork 시스템 호출에 의존하는 메서드는 기본 운영 체제 또는 Ruby 런타임이 이를 지원하지 않는 경우 이 예외를 발생시킬 수 있습니다.

추상 메서드에 NotImplementedError를 사용하면 오해의 소지가 있습니다. 서브클래스에서 구현이 필요한 것이 아니라 나중에 동일한 클래스에서 기능이 구현될 수 있음을 시사하기 때문입니다.

NoMethodError는 자체적인 의미론적 문제가 있습니다. NoMethodError를 발생시키는 메서드를 정의하면, 객체는 여전히 해당 메서드에 대해 respond_to?에 응답하는데, 이는 의미론적으로 잘못된 것입니다.

이러한 구분에 대한 자세한 내용은 NotImplementedError에 관한 이 글을 참조하십시오.

# good - using Gitlab::AbstractMethodError for abstract methods

# Real example from the GitLab codebase:
# From ee/app/components/gitlab_subscriptions/base_discover_component.rb
class GitlabSubscriptions::BaseDiscoverComponent < ViewComponent::Base
  def trial_type
    raise Gitlab::AbstractMethodError
  end

  def trial_active?
    raise Gitlab::AbstractMethodError
  end

  def hero_header_text
    raise Gitlab::AbstractMethodError
  end
end

# Example with custom message for additional context:
class BaseProcessor
  def process
    raise Gitlab::AbstractMethodError, 'Must return a hash with :status and :result keys'
  end
end
# bad - using generic raise, NotImplementedError, or NoMethodError
class PaymentProcessor
  def process_payment(amount)
    raise "Subclass must implement process_payment"  # Generic string error
  end
end

class DataExporter
  def export_format
    raise NotImplementedError  # Wrong: this is for platform-specific features
  end

  def export(data)
    raise NoMethodError  # Wrong: conflicts with respond_to? semantics
  end

  def transform(data)
    # No implementation - worst: fails silently
  end
end

추상화 재사용 가이드라인

GitLab v19.1
원문 보기
요약

GitLab이 성장함에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 등장했습니다. 코드 재사용은 좋은 관행이지만, 때로는 잘못된 추상화를 특정 사용 사례에 억지로 끼워 맞추는 결과를 초래할 수 있습니다. 예를 들어, IssuesFinder에서 ProjectsFinder를 사용하여 특정 프로젝트에 속한 이슈만 조회하도록 제한하는 경우를 들 수 있습니다.

GitLab이 성장함에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 등장했습니다. Service 클래스, Serializer, Presenter 등이 그 예입니다. 이러한 패턴 덕분에 코드를 쉽게 재사용할 수 있게 되었지만, 동시에 특정 상황에서 잘못된 추상화를 실수로 재사용하기도 쉬워졌습니다.

이 가이드라인이 필요한 이유#

코드 재사용은 좋은 관행이지만, 때로는 잘못된 추상화를 특정 사용 사례에 억지로 끼워 맞추는 결과를 초래할 수 있습니다. 이는 유지보수성, 문제 디버깅 용이성, 심지어 성능에도 부정적인 영향을 미칠 수 있습니다.

예를 들어, IssuesFinder에서 ProjectsFinder를 사용하여 특정 프로젝트에 속한 이슈만 조회하도록 제한하는 경우를 들 수 있습니다. 처음에는 좋은 아이디어처럼 보일 수 있지만, 두 클래스 모두 세밀한 제어가 거의 불가능한 매우 높은 수준의 인터페이스를 제공합니다. 이는 쿼리의 상당 부분이 ProjectsFinder의 내부 동작에 의해 제어되기 때문에 IssuesFinder가 더 최적화된 데이터베이스 쿼리를 생성하지 못할 수 있음을 의미합니다.

이 문제를 해결하려면 ProjectsFinder를 직접 사용하는 대신, ProjectsFinder가 사용하는 동일한 코드를 사용해야 합니다. 이를 통해 동작을 더 잘 구성할 수 있으며, 코드의 동작에 대한 더 많은 제어권을 갖게 됩니다.

이를 설명하기 위해 IssuableFinder#projects의 다음 코드를 살펴보겠습니다:

return @projects = project if project?

projects =
  if current_user && params[:authorized_only].presence && !current_user_related?
    current_user.authorized_projects
  elsif group
    finder_options = { include_subgroups: params[:include_subgroups], exclude_shared: true }
    GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
  else
    ProjectsFinder.new(current_user: current_user).execute
  end

@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)

여기서 우리는 세 가지 다른 방식을 사용하여 데이터의 범위를 지정할 프로젝트를 결정합니다. 그룹이 지정된 경우 GroupProjectsFinder를 사용하여 해당 그룹의 모든 프로젝트를 가져옵니다. 표면적으로는 무해해 보입니다. 사용하기 쉽고 두 줄의 코드만 필요합니다.

실제로는 상황이 매우 복잡해질 수 있습니다. 예를 들어, GroupProjectsFinder가 생성하는 쿼리는 처음에는 단순할 수 있습니다. 시간이 지남에 따라 이 (상위 수준) 인터페이스에 점점 더 많은 기능이 추가됩니다. 이는 필요한 경우에만 영향을 미치는 것이 아니라 IssuableFinder에도 부정적인 방식으로 영향을 미치기 시작할 수 있습니다. 예를 들어, GroupProjectsFinder가 생성하는 쿼리에 불필요한 조건이 포함될 수 있습니다. 여기서 finder를 사용하고 있기 때문에 해당 동작을 쉽게 제외할 수 없습니다. 그렇게 하기 위해 옵션을 추가할 수 있지만, 그렇게 되면 기능 수만큼 많은 옵션이 필요합니다. 각 옵션은 두 개의 코드 경로를 추가하므로, 네 가지 기능에 대해 8개의 서로 다른 코드 경로를 다뤄야 합니다.

이 문제를 처리하는 훨씬 더 신뢰할 수 있는 (그리고 쾌적한) 방법은 GroupProjectsFinder를 구성하는 기본 요소를 직접 사용하는 것입니다. 이는 IssuableFinder에 코드가 조금 더 필요할 수 있지만, 훨씬 더 많은 제어권과 확실성을 제공합니다. 결과적으로 다음과 같은 코드가 될 수 있습니다:

return @projects = project if project?

projects =
  if current_user && params[:authorized_only].presence && !current_user_related?
    current_user.authorized_projects
  elsif group
    current_user
      .owned_groups(subgroups: params[:include_subgroups])
      .projects
      .any_additional_method_calls
      .that_might_be_necessary
  else
    current_user
      .projects_visible_to_user
      .any_additional_method_calls
      .that_might_be_necessary
  end

@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)

이것은 단순한 스케치이지만, 일반적인 아이디어를 보여줍니다. GroupProjectsFinderProjectsFinder finder가 내부적으로 사용하는 것을 그대로 사용하는 것입니다.

최종 목표#

이 문서의 가이드라인은 무엇을 어디서 재사용할 수 있는지, 그리고 재사용할 수 없을 때는 무엇을 해야 하는지를 명확하게 정의하여 더 나은 코드 재사용을 장려하기 위한 것입니다. 추상화를 명확하게 분리하면 잘못된 추상화를 사용하기 어려워지고, 코드를 디버깅하기 쉬워지며, (바라건대) 성능 문제가 줄어듭니다.

추상화#

이제 사용 가능한 다양한 추상화 수준과 각각 재사용할 수 있는 (또는 재사용할 수 없는) 것들을 살펴보겠습니다. 이를 위해 다양한 추상화와 각각이 재사용할 수 있는(없는) 것들을 정의하는 다음 표를 사용할 수 있습니다:

추상화 Service 클래스 Finder Presenter Serializer 모델 인스턴스 메서드 모델 클래스 메서드 Active Record Worker
Controller/API 엔드포인트 Yes Yes Yes Yes Yes No No No
Service 클래스 Yes Yes No No Yes No No Yes
Finder No No No No Yes Yes No No
Presenter No Yes No No Yes Yes No No
Serializer No Yes No No Yes Yes No No
모델 클래스 메서드 No No No No Yes Yes Yes No
모델 인스턴스 메서드 No Yes No No Yes Yes Yes Yes
Worker Yes Yes No No Yes No No Yes

Controller#

app/controllers에 있는 모든 것입니다.

Controller는 자체적으로 많은 작업을 수행해서는 안 되며, 대신 입력을 다른 클래스에 전달하고 결과를 표시합니다.

View#

app/viewsee/app/views에 있는 모든 것입니다.

View는 표시만 담당합니다. Controller가 할당한 인스턴스 변수를 통해 데이터를 받아 HTML, XML, Markdown, 또는 텍스트로 렌더링합니다.

View는 다음을 수행해서는 안 됩니다:

  • 데이터베이스 쿼리 실행. 모든 데이터 조회는 Controller 또는 Presenter로 이동하고, 결과는 인스턴스 변수로 전달합니다. View의 쿼리는 캐싱 레이어를 우회하고, 쿼리 분석 도구에서 보이지 않으며, N+1 문제를 감지하기 어렵게 만듭니다.

  • 비즈니스 로직 포함. nil?, present?, 또는 boolean 속성 확인을 넘어서는 모델 상태를 평가하는 조건문을 피합니다. Service 객체 인스턴스화 및 다단계 연산을 피합니다. 이 로직을 helper, Presenter, 또는 ViewComponent로 추출합니다.

API 엔드포인트#

lib/api(REST API)와 app/graphql(GraphQL API)에 있는 모든 것입니다.

API 엔드포인트는 Controller와 동일한 추상화 수준을 가집니다.

Service 클래스#

app/services에 있는 모든 것입니다.

Service 클래스는 모델(엔티티 및 값 객체 등) 간의 변경을 조율하는 작업을 나타냅니다. 변경 사항은 애플리케이션의 상태에 영향을 미칩니다.

  • 객체가 애플리케이션의 상태를 변경하지 않는 경우, 그것은 Service가 아닙니다. Finder 또는 값 객체일 수 있습니다.

  • 작업이 없는 경우, Service를 실행할 필요가 없습니다. 해당 클래스는 엔티티, 값 객체, 또는 정책으로 더 잘 설계될 가능성이 높습니다.

Service 클래스를 구현할 때는 다음 패턴 사용을 고려하십시오:

Service 클래스 이니셜라이저의 인수에는 다음이 포함되어야 합니다:

작업 대상이 되는 모델 인스턴스. 이니셜라이저의 첫 번째 위치 인수여야 합니다. 인수 이름은 개발자의 재량에 맡겨집니다. 예: issue, project, merge_request.

Service가 사용자에 의해 시작되거나 사용자 컨텍스트에서 실행되는 작업을 나타내는 경우, 이니셜라이저에는 current_user: 키워드 인수가 있어야 합니다. current_user: 인수가 있는 Service는 상위 수준의 비즈니스 로직을 실행하며 작업 수행에 대한 사용자 인가를 검증해야 합니다.

Service에 사용자 컨텍스트가 없고 사용자에 의해 직접 시작되지 않는 경우(예: 백그라운드 Service 또는 부작용), current_user: 인수는 필요하지 않습니다. 이는 하위 수준의 도메인 로직 또는 인스턴스 전체 로직을 설명합니다.

Service에 필요한 모든 추가 데이터에 대해서는 명시적 키워드 인수를 권장합니다. Service에 너무 긴 인수 목록이 필요한 경우 다음으로 분리하는 것을 고려하십시오:

params: 직접 할당될 모델 속성을 담은 해시.

  • options: 추가 매개변수(처리가 필요하며 모델 속성이 아닌 것들)를 담은 해시. options 해시는 인스턴스 변수에 저장되어야 합니다.
# merge_request: A model instance that is being acted upon.
# assignee: new MR assignee that will be assigned to the MR
#   after the service is executed.
def initialize(merge_request, assignee:)
  @merge_request = merge_request
  @assignee = assignee
end
# issue: A model instance that is being acted upon.
# current_user: Current user.
# params: Model properties.
# options: Configuration for this service. Can be any of the following:
#   - notify: Whether to send a notification to the current user.
#   - cc: Email address to copy when sending a notification.
def initialize(issue:, current_user:, params: {}, options: {})
  @issue = issue
  @current_user = current_user
  @params = params
  @options = options
end

Service 클래스는 Service 클래스 동작을 호출하는 단일 public 인스턴스 메서드 #execute를 구현해야 합니다:

#execute 메서드는 인수를 받지 않습니다. 필요한 모든 데이터는 이니셜라이저에 전달됩니다.

반환값이 필요한 경우, #execute 메서드는 ServiceResponse 객체를 통해 결과를 반환해야 합니다.

여러 기본 클래스가 Service 클래스 규약을 구현합니다. 다음을 상속하는 것을 고려할 수 있습니다:

  • 컨테이너(프로젝트 또는 그룹)로 범위가 지정된 Service의 경우 BaseContainerService.

  • 프로젝트로 범위가 지정된 Service의 경우 BaseProjectService.

  • 그룹으로 범위가 지정된 Service의 경우 BaseGroupService.

일부 도메인 또는 bounded context에서는 Service 클래스가 다른 패턴을 사용하는 것이 합리적일 수 있습니다. 예를 들어, Remote Development 도메인은 표준 패턴을 따르는 별도의 도메인 레이어에 도메인 로직을 격리하는 레이어드 아키텍처를 사용하며, 이를 통해 단일 재사용 가능한 CommonService 클래스로만 구성된 매우 최소한의 Service 레이어를 가능하게 합니다. 또한 상태 없는 싱글톤 클래스 메서드를 사용하는 함수형 패턴을 사용합니다. 자세한 내용은 Remote Development Service 레이어 코드 예시를 참조하십시오. 그러나 이 패턴을 통한 Service 호출 서명이 다르더라도, 항상 ServiceResponse 객체를 통해 모든 결과를 반환하고 심층 방어 인가를 수행하는 표준 Service 레이어 계약을 여전히 준수합니다.

Service 객체가 아닌 클래스는 lib과 같이 다른 곳에 생성되어야 합니다.

ServiceResponse#

Service 클래스는 일반적으로 execute 메서드를 가지며, 이는 ServiceResponse를 반환할 수 있습니다. execute 메서드에서 응답을 반환하려면 ServiceResponse.successServiceResponse.error를 사용할 수 있습니다.

성공적인 경우:

response = ServiceResponse.success(message: 'Branch was deleted')

response.success? # => true
response.error? # => false
response.status # => :success
response.message # => 'Branch was deleted'

실패한 경우:

response = ServiceResponse.error(message: 'Unsupported operation')

response.success? # => false
response.error? # => true
response.status # => :error
response.message # => 'Unsupported operation'

추가 페이로드도 첨부할 수 있습니다:

response = ServiceResponse.success(payload: { issue: issue })

response.payload[:issue] # => issue

오류 응답은 호출자가 실패의 성격을 이해하는 데 사용할 수 있는 실패 reason을 지정할 수도 있습니다. HTTP 엔드포인트인 호출자는 reason 심볼을 HTTP 상태 코드로 변환할 수 있습니다:

response = ServiceResponse.error(
  message: 'Job is in a state that cannot be retried',
  reason: :job_not_retrieable)

if response.success?
  head :ok
elsif response.reason == :job_not_retriable
  head :unprocessable_entity
else
  head :bad_request
end

:not_found 또는 :forbidden과 같은 일반적인 실패의 경우, 도메인 로직에 충분히 구체적인 한 Rails HTTP 상태 심볼을 활용할 수 있습니다. 다른 실패의 경우 가능한 한 도메인별 이유를 사용합니다.

예: :job_not_retriable, :duplicate_package, :merge_request_not_mergeable.

Finder#

app/finders에 있는 모든 것으로, 일반적으로 데이터베이스에서 데이터를 조회하는 데 사용됩니다.

Finder는 생성하는 SQL 쿼리를 더 잘 제어하기 위해 다른 Finder를 재사용할 수 없습니다.

Finder의 execute 메서드는 ActiveRecord::Relation을 반환해야 합니다. 예외는 spec/support/finder_collection_allowlist.yml에 추가할 수 있습니다. 자세한 내용은 #298771을 참조하십시오.

Presenter#

app/presenters에 있는 모든 것으로, 많은 인스턴스 변수를 만들지 않고도 Rails View에 복잡한 데이터를 노출하는 데 사용됩니다.

자세한 내용은 문서를 참조하십시오.

Serializer#

app/serializers에 있는 모든 것으로, 일반적으로 JSON 형식으로 요청에 대한 응답을 표시하는 데 사용됩니다.

모델#

app/models의 클래스와 모듈은 데이터와 동작을 모두 캡슐화하는 도메인 개념을 나타냅니다.

이 클래스는 데이터 저장소(ActiveRecord 모델과 같은)와 직접 상호작용하거나, 더 풍부한 도메인 개념을 표현하기 위해 ActiveRecord 모델 위에 얇은 래퍼(Plain Old Ruby Objects)가 될 수 있습니다.

도메인 개념을 나타내는 엔티티와 값 객체는 도메인 모델로 간주됩니다.

몇 가지 예시:

모델 클래스 메서드#

다음 Active Record가 제공하는 메서드를 포함하여 GitLab 자체가 정의한 클래스 메서드입니다:

  • find

  • find_by_id

  • delete_all

  • destroy

  • destroy_all

find_by(some_column: X)와 같은 다른 메서드는 포함되지 않으며, 대신 "Active Record" 추상화에 해당합니다.

모델 인스턴스 메서드#

GitLab 자체가 Active Record 모델에 정의한 인스턴스 메서드입니다. Active Record가 제공하는 메서드는 다음 메서드를 제외하고 포함되지 않습니다:

  • save

  • update

  • destroy

  • delete

Active Record#

where 메서드, save, delete_all 등 Active Record 자체가 제공하는 API입니다.

Worker#

app/workers에 있는 모든 것입니다.

Sidekiq job을 예약하려면 SomeWorker.perform_async 또는 SomeWorker.perform_in을 사용하십시오. 절대 SomeWorker.new.perform을 사용하여 Worker를 직접 호출하지 마십시오.

기본 클래스의 추상 메서드#

서브클래스가 구현해야 하는 메서드를 가진 기본 클래스가 있는 경우, Gitlab::AbstractMethodError를 사용하여 메서드에 구현이 필요하다는 것을 명확하게 신호로 보내십시오.

대부분의 경우 상속보다 구성(Composition)과 덕 타이핑이 선호됩니다. 추상 메서드는 명확하게 적절한 경우(예: 공유 템플릿이 있는 ViewComponent 또는 프레임워크 통합 지점)에만 드물게 사용하십시오. 이 가이던스는 주로 NoMethodErrorNotImplementedError의 기존 잘못된 사용을 교정하기 위한 것입니다.

Gitlab::AbstractMethodError를 사용하는 이유#

  • 의미론적 명확성: 서브클래스가 메서드를 구현해야 함을 명시적으로 나타냅니다.

  • 기본 오류 메시지: 보일러플레이트 오류 메시지를 작성할 필요가 없습니다.

  • NoMethodError 문제 방지: respond_to? 동작과의 충돌이 없습니다.

  • NotImplementedError 오용 방지: NotImplementedError는 추상 메서드가 아닌 플랫폼별 기능을 위한 것입니다.

  • Exception에서 상속: 명시적인 rescue 블록으로만 잡을 수 있습니다.

  • 적용 가능: RuboCop 규칙으로 검증할 수 있습니다(향후 작업).

NotImplementedError나 NoMethodError를 사용하지 않는 이유#

Ruby의 NotImplementedError는 특정 플랫폼이나 구성에서 구현되지 않은 기능(예: Linux에서는 작동하지만 Windows에서는 작동하지 않는 메서드)을 위한 것이지, 객체 지향 설계의 추상 메서드를 위한 것이 아닙니다. Ruby 문서에 다음과 같이 나와 있습니다:

현재 플랫폼에서 기능이 구현되지 않은 경우 발생합니다. 예를 들어, fsync 또는 fork 시스템 호출에 의존하는 메서드는 기본 운영 체제 또는 Ruby 런타임이 이를 지원하지 않는 경우 이 예외를 발생시킬 수 있습니다.

추상 메서드에 NotImplementedError를 사용하면 오해의 소지가 있습니다. 서브클래스에서 구현이 필요한 것이 아니라 나중에 동일한 클래스에서 기능이 구현될 수 있음을 시사하기 때문입니다.

NoMethodError는 자체적인 의미론적 문제가 있습니다. NoMethodError를 발생시키는 메서드를 정의하면, 객체는 여전히 해당 메서드에 대해 respond_to?에 응답하는데, 이는 의미론적으로 잘못된 것입니다.

이러한 구분에 대한 자세한 내용은 NotImplementedError에 관한 이 글을 참조하십시오.

# good - using Gitlab::AbstractMethodError for abstract methods

# Real example from the GitLab codebase:
# From ee/app/components/gitlab_subscriptions/base_discover_component.rb
class GitlabSubscriptions::BaseDiscoverComponent < ViewComponent::Base
  def trial_type
    raise Gitlab::AbstractMethodError
  end

  def trial_active?
    raise Gitlab::AbstractMethodError
  end

  def hero_header_text
    raise Gitlab::AbstractMethodError
  end
end

# Example with custom message for additional context:
class BaseProcessor
  def process
    raise Gitlab::AbstractMethodError, 'Must return a hash with :status and :result keys'
  end
end
# bad - using generic raise, NotImplementedError, or NoMethodError
class PaymentProcessor
  def process_payment(amount)
    raise "Subclass must implement process_payment"  # Generic string error
  end
end

class DataExporter
  def export_format
    raise NotImplementedError  # Wrong: this is for platform-specific features
  end

  def export(data)
    raise NoMethodError  # Wrong: conflicts with respond_to? semantics
  end

  def transform(data)
    # No implementation - worst: fails silently
  end
end