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)

이는 개략적인 예시이지만 전체 취지를 보여 줍니다. GroupProjectsFinder와 ProjectsFinder 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/views와 ee/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 클래스는 모델(엔티티, 값 객체 등) 사이의 변경을 조율하는 작업을 나타냅니다. 이 변경은 애플리케이션의 상태에 영향을 줍니다.

  1. 객체가 애플리케이션의 상태를 변경하지 않으면 그것은 Service가 아닙니다. Finder나 값 객체일 수 있습니다.
  2. 작업이 없으면 Service를 실행할 필요가 없습니다. 해당 클래스는 엔티티, 값 객체, 정책으로 설계하는 편이 더 적합할 가능성이 높습니다.

Service 클래스를 구현할 때는 다음 패턴을 고려합니다:

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

    1. 작업 대상이 되는 모델 인스턴스입니다. 이니셜라이저의 첫 번째 위치 인수여야 합니다. 인수 이름은 개발자 재량에 맡기며, 예로는 issue·project· merge_request가 있습니다.

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

    3. Service에 사용자 컨텍스트가 없고 사용자가 직접 시작한 것이 아니라면 (백그라운드 Service 나 부수 효과 등) current_user: 인수는 필요하지 않습니다. 이는 하위 수준 도메인 로직이나 인스턴스 전체 로직에 해당합니다.

    4. 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
      
  2. Service 클래스는 Service 클래스 동작을 호출하는 단일 public 인스턴스 메서드 #execute를 구현해야 합니다:

    • #execute 메서드는 인수를 받지 않습니다. 필요한 모든 데이터는 이니셜라이저로 전달됩니다.
  3. 반환값이 필요하면 #execute 메서드는 ServiceResponse 객체로 결과를 반환해야 합니다.

  4. 필요하다면 Gitlab::Utils::Executable로 클래스 수준 execute 메서드를 추가합니다:

    • 호출자가 Service 인스턴스가 아니라 execute의 결과만 필요한 경우에 사용합니다.
    • 이 믹스인은 선택 적용입니다. 모든 Service에 일괄 적용하지 않고 적합한 Service 클래스에만 추가합니다.
    • 인스턴스 자체가 필요한 호출자를 위해 new는 그대로 사용할 수 있습니다.
    class MyService
      include Gitlab::Utils::Executable
    
      def execute
        # ...
      end
    end
    

여러 기본 클래스가 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.success와 ServiceResponse.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 상태 심볼을 활용할 수 있습니다. 그 외의 실패에는 가능한 한 도메인별 reason을 사용합니다.

예: :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 뷰에 노출할 때 사용합니다.

자세한 내용은 문서를 참고합니다.

Serializer#

app/serializers에 있는 모든 것이며, 요청에 대한 응답을 표현할 때 사용합니다. 보통 JSON 형식입니다.

모델#

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

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

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

예시는 다음과 같습니다:

모델 클래스 메서드#

GitLab 자체 가 정의한 클래스 메서드이며, Active Record가 제공하는 다음 메서드도 포함합니다:

  • 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으로 워커를 직접 호출하지 않습니다.

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

하위 클래스가 반드시 구현해야 하는 메서드를 가진 기본 클래스가 있다면, Gitlab::AbstractMethodError로 해당 메서드에 구현이 필요하다는 점을 명확히 알립니다.

Note

대부분의 경우 상속보다 컴포지션과 덕 타이핑을 권장합니다. 추상 메서드는 공유 템플릿을 사용하는 ViewComponent 나 프레임워크 통합 지점처럼 명확히 적합한 경우에만 제한적으로 사용합니다. 이 지침은 주로 기존의 NoMethodError 및 NotImplementedError 사용을 교정하기 위한 것입니다.

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

  • 의미 명확성: 하위 클래스가 해당 메서드를 구현해야 함을 명시적으로 나타냅니다
  • 기본 오류 메시지: 상용구 오류 메시지를 작성할 필요가 없습니다
  • NoMethodError 문제 회피: respond_to? 동작과 충돌하지 않습니다
  • 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.4
원문 보기

요약

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)

이는 개략적인 예시이지만 전체 취지를 보여 줍니다. GroupProjectsFinder와 ProjectsFinder 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/views와 ee/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 클래스는 모델(엔티티, 값 객체 등) 사이의 변경을 조율하는 작업을 나타냅니다. 이 변경은 애플리케이션의 상태에 영향을 줍니다.

  1. 객체가 애플리케이션의 상태를 변경하지 않으면 그것은 Service가 아닙니다. Finder나 값 객체일 수 있습니다.
  2. 작업이 없으면 Service를 실행할 필요가 없습니다. 해당 클래스는 엔티티, 값 객체, 정책으로 설계하는 편이 더 적합할 가능성이 높습니다.

Service 클래스를 구현할 때는 다음 패턴을 고려합니다:

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

    1. 작업 대상이 되는 모델 인스턴스입니다. 이니셜라이저의 첫 번째 위치 인수여야 합니다. 인수 이름은 개발자 재량에 맡기며, 예로는 issue·project· merge_request가 있습니다.

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

    3. Service에 사용자 컨텍스트가 없고 사용자가 직접 시작한 것이 아니라면 (백그라운드 Service 나 부수 효과 등) current_user: 인수는 필요하지 않습니다. 이는 하위 수준 도메인 로직이나 인스턴스 전체 로직에 해당합니다.

    4. 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
      
  2. Service 클래스는 Service 클래스 동작을 호출하는 단일 public 인스턴스 메서드 #execute를 구현해야 합니다:

    • #execute 메서드는 인수를 받지 않습니다. 필요한 모든 데이터는 이니셜라이저로 전달됩니다.
  3. 반환값이 필요하면 #execute 메서드는 ServiceResponse 객체로 결과를 반환해야 합니다.

  4. 필요하다면 Gitlab::Utils::Executable로 클래스 수준 execute 메서드를 추가합니다:

    • 호출자가 Service 인스턴스가 아니라 execute의 결과만 필요한 경우에 사용합니다.
    • 이 믹스인은 선택 적용입니다. 모든 Service에 일괄 적용하지 않고 적합한 Service 클래스에만 추가합니다.
    • 인스턴스 자체가 필요한 호출자를 위해 new는 그대로 사용할 수 있습니다.
    class MyService
      include Gitlab::Utils::Executable
    
      def execute
        # ...
      end
    end
    

여러 기본 클래스가 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.success와 ServiceResponse.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 상태 심볼을 활용할 수 있습니다. 그 외의 실패에는 가능한 한 도메인별 reason을 사용합니다.

예: :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 뷰에 노출할 때 사용합니다.

자세한 내용은 문서를 참고합니다.

Serializer#

app/serializers에 있는 모든 것이며, 요청에 대한 응답을 표현할 때 사용합니다. 보통 JSON 형식입니다.

모델#

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

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

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

예시는 다음과 같습니다:

모델 클래스 메서드#

GitLab 자체 가 정의한 클래스 메서드이며, Active Record가 제공하는 다음 메서드도 포함합니다:

  • 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으로 워커를 직접 호출하지 않습니다.

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

하위 클래스가 반드시 구현해야 하는 메서드를 가진 기본 클래스가 있다면, Gitlab::AbstractMethodError로 해당 메서드에 구현이 필요하다는 점을 명확히 알립니다.

Note

대부분의 경우 상속보다 컴포지션과 덕 타이핑을 권장합니다. 추상 메서드는 공유 템플릿을 사용하는 ViewComponent 나 프레임워크 통합 지점처럼 명확히 적합한 경우에만 제한적으로 사용합니다. 이 지침은 주로 기존의 NoMethodError 및 NotImplementedError 사용을 교정하기 위한 것입니다.

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

  • 의미 명확성: 하위 클래스가 해당 메서드를 구현해야 함을 명시적으로 나타냅니다
  • 기본 오류 메시지: 상용구 오류 메시지를 작성할 필요가 없습니다
  • NoMethodError 문제 회피: respond_to? 동작과 충돌하지 않습니다
  • 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