추상화를 재사용하는 지침

GitLab의 성장에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 나타났습니다. 서비스 클래스, 직렬화기, 프레젠터 등이 몇 가지 예입니다. 이러한 패턴들은 코드를 재사용하기 쉽게 만들었지만, 동시에 특정한 위치에서 잘못된 추상화를 실수로 재사용하기 쉽게 만들기도 합니다.

이러한 지침이 필요한 이유

코드 재사용은 좋지만 때로는 이로 인해 잘못된 추상화를 특정 사용 사례에 억지로 맞추는 경우가 발생할 수 있습니다. 이는 결과적으로 유지 관리성에 부정적인 영향을 미칠 수 있으며, 문제를 쉽게 해결할 수 없게 하거나 심지어 성능에 문제를 일으킬 수 있습니다.

예를 들어, ‘ProjectsFinder’를 ‘IssuesFinder’에서 사용하여 특정 프로젝트에 속한 이슈를 제한하는 것입니다. 처음에는 좋은 아이디어처럼 보일 수 있지만, 두 클래스 모두 매우 높은 수준의 인터페이스를 제공하면서 제어력이 매우 적습니다. 이는 ‘IssuesFinder’가 더 나은 최적화된 데이터베이스 쿼리를 생성하지 못할 수 있음을 의미합니다. 왜냐하면 쿼리의 많은 부분이 ‘ProjectsFinder’의 내부에서 제어되기 때문입니다.

이 문제를 해결하기 위해 ‘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’에 의해 생성된 쿼리에 불필요한 조건이 포함될 수 있습니다. 파인더를 사용하고 있기 때문에 해당 동작을 쉽게 선택 해제할 수는 없습니다. 그렇게 하려면 옵션을 추가해야 합니다. 하지만 그럴 경우 각 기능마다 옵션이 필요하게 되고, 각 옵션은 두 개의 코드 경로를 추가하게 됩니다. 이는 네 가지 기능에 대해 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’ 파인더가 하부에 사용하는 것을 사용할 것입니다.

최종 목표

본 문서의 지침은 재사용할 수 있는 것과 그렇지 않은 것을 명확하게 정의함으로써 더 나은 코드 재사용을 유도하고, 잘못된 것을 사용하는 것을 어렵게 만들며, 코드를 쉽게 디버그할 수 있도록 분리하고 (희망적으로) 더 적은 성능 문제를 유발하도록 합니다.

추상화

이제 여러 가지 추상화 수준을 살펴볼 텐데, 어디에서 무엇을 재사용할 수 있는지, 그리고 재사용할 수 있는 것과 그렇지 않은 것을 정의하는 다음의 표를 사용할 수 있습니다:

추상화 서비스 클래스 파인더 프레젠터 직렬화기 모델 인스턴스 메서드 모델 클래스 메서드 액티브 레코드 워커
컨트롤러/API 엔드포인트 아니요 아니요 아니요
서비스 클래스 아니요 아니요 아니요 아니요
파인더 아니요 아니요 아니요 아니요 아니요 아니요
프레젠터 아니요 아니요 아니요 아니요 아니요
직렬화기 아니요 아니요 아니요 아니요 아니요
모델 클래스 메서드 아니요 아니요 아니요 아니요 아니요
모델 인스턴스 메서드 아니요 아니요 아니요
워커 아니요 아니요 아니요 아니요

컨트롤러

app/controllers의 모든 것입니다.

컨트롤러는 스스로 많은 작업을 수행해서는 안 되며, 대신 입력을 다른 클래스로 전달하고 결과를 제시합니다.

API 엔드포인트

lib/api (REST API) 및 app/graphql (GraphQL API)의 모든 것입니다.

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

서비스 클래스

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

서비스 클래스는 엔터티와 값 객체 등 모델 사이의 변경을 조정하는 작업을 나타냅니다. 변경 사항은 응용 프로그램의 상태에 영향을 미칩니다.

  1. 객체가 응용 프로그램의 상태에 변경 사항을 가하지 않는 경우 서비스가 아닙니다. 파인더나 값 객체일 수 있습니다.

  2. 동작이 없을 때는 서비스를 실행할 필요가 없습니다. 클래스가 엔터티, 값 객체 또는 정책으로 더 잘 설계될 수 있습니다.

서비스 클래스를 구현할 때 고려해야 할 사항:

  1. 서비스 클래스 이니셜라이저는 인수로 다음을 포함해야 합니다:
    1. 작용 대상이 되는 모델 인스턴스. 이는 이니셜라이저의 첫 번째 위치 매개변수여야 합니다. 이 매개변수의 이름은 개발자의 재량에 따라 결정됩니다. 예시: issue, project, merge_request.
    2. 사용자에 의해 시작된 동작 또는 사용자의 문맥에서 실행되는 동작을 나타내는 경우 이니셜라이저에는 current_user: 키워드 인수가 있어야 합니다. current_user: 인수를 갖는 서비스는 높은 수준의 비즈니스 로직을 수행하며 그 작동을 확인하기 위해 사용자 인증을 검증해야 합니다.
    3. 사용자 문맥이 없는 서비스이거나 사용자에 의해 직접 시작되는 동작이 아닌 경우 current_user: 인수가 필요하지 않습니다. 이는 낮은 수준의 도메인 로직이나 인스턴스 전체 로직을 나타냅니다.
    4. 서비스에 필요한 추가 데이터에 대해서는 명시적 키워드 인수를 권장합니다. 서비스에 너무 많은 매개변수가 필요한 경우에는 이를 다음과 같이 분할하는 것을 고려하십시오:
      • params: 직접 할당될 모델 속성을 가진 해시.
      • options: 추가 매개변수를 가진 해시(처리해야 하며 모델 속성이 아닌 것). options 해시는 인스턴스 변수에 저장되어야 합니다.
      # merge_request: 작용 대상이 되는 모델 인스턴스.
      # assignee: 서비스 실행 후 MR에 할당될 새로운 MR 담당자.
      def initialize(merge_request, assignee:)
        @merge_request = merge_request
        @assignee = assignee
      end
      
      # issue: 작용 대상이 되는 모델 인스턴스.
      # current_user: 현재 사용자.
      # params: 모델 속성.
      # options: 이 서비스를 위한 구성. 다음 중 하나일 수 있습니다:
      #   - notify: 현재 사용자에게 알림을 보낼지 여부.
      #   - cc: 알림 보낼 때 복사할 이메일 주소.
      def initialize(issue:, current_user:, params: {}, options: {})
        @issue = issue
        @current_user = current_user
        @params = params
        @options = options
      end
      
  2. 이는 단일 공용 인스턴스 메서드 #execute를 구현하는데, 이는 서비스 클래스 동작을 호출합니다:
    • #execute 메서드는 인수를 받지 않습니다. 필요한 모든 데이터는 이니셜라이저로 전달됩니다.
    • 옵션으로, 필요한 경우 #execute 메서드는 ServiceResponse를 통해 결과를 반환합니다.

여러 기본 클래스가 서비스 클래스 규칙을 구현합니다. 다음 클래스 중 상속을 고려할 수 있습니다:

  • 프로젝트 또는 그룹에 의해 제한된 서비스에는 BaseContainerService.
  • 프로젝트를 대상으로 한 서비스에는 BaseProjectService.
  • 그룹을 대상으로 한 서비스에는 BaseGroupService.

서비스 객체가 아닌 클래스는 기타 곳에 생성되어야 합니다. 예를 들어, lib에 위치합니다.

ServiceResponse

서비스 클래스에는 일반적으로 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 엔드포인트인 경우 호출자는 이유 심볼을 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 상태 심볼을 활용할 수 있습니다. 다른 실패의 경우 가능한 경우 도메인별 reasons을 사용하십시오.

예를 들어: :job_not_retriable, :duplicate_package, :merge_request_not_mergeable.

Finders

app/finders의 모든 내용은 일반적으로 데이터베이스에서 데이터를 검색하는 데 사용됩니다.

Finders는 다른 finders를 재사용할 수 없도록하여 생성한 SQL 쿼리를 더 잘 제어하기 위해 노력합니다.

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

Presenters

app/presenters의 모든 내용은 복잡한 데이터를 Rails 뷰에 노출시키기 위해 사용됩니다. 많은 인스턴스 변수를 생성할 필요 없이 복잡한 데이터를 공개화하는 데 사용됩니다.

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

Serializers

app/serializers의 모든 내용은 요청에 대한 응답을 보여주기 위해 주로 JSON 형식으로 사용됩니다.

Models

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

이러한 클래스는 데이터 리포지터리(예: ActiveRecord 모델)와 직접 상호 작용할 수도 있으며 더 풍부한 도메인 개념을 표현하기 위해 ActiveRecord 모델 위에 얇은 래퍼(Plain Old Ruby Objects)일 수도 있습니다.

도메인 개념을 나타내는 Entities and Value Objects가 도메인 모델로 간주됩니다.

일부 예시:

Model 클래스 메서드

이들은 _GitLab 자체_에 의해 정의된 클래스 메서드로, 다음과 같은 Active Record에 의해 제공된 메서드를 포함합니다:

  • find
  • find_by_id
  • delete_all
  • destroy
  • destroy_all

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

Model 인스턴스 메서드

_GitLab 자체_에서 Active Record 모델에 정의된 인스턴스 메서드입니다. Active Record에서 제공하는 메서드는 다음과 같은 메서드를 제외하고 포함되지 않습니다:

  • save
  • update
  • destroy
  • delete

Active Record

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

Worker

app/workers의 모든 것입니다.

Sidekiq 작업을 예약하기 위해 SomeWorker.perform_async 또는 SomeWorker.perform_in을 사용하십시오. SomeWorker.new.perform을 직접적으로 호출하는 것은 절대로 사용하지 마십시오.