추상화 재사용 가이드라인

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

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

코드 재사용은 좋지만 때로는 특정 사용 사례에 잘못된 추상화를 강제로 사용할 수 있습니다. 이는 결과적으로 유지 보수성, 문제 해결의 용이함 또는 성능에 부정적인 영향을 미칠 수 있습니다.

예를 들어 ProjectsFinderIssuesFinder에서 사용하여 일부 프로젝트에 속한 이슈를 제한하는 것이 있습니다. 처음에는 좋은 아이디어처럼 보일 수 있지만, 두 클래스 모두 매우 높은 수준의 인터페이스를 제공하면서 제어가 거의 없습니다. 이는 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를 사용하여 해당 그룹의 모든 프로젝트를 검색합니다. 표면적으로는 해armless해 보이지만, 실제로는 상황이 매우 빨리 복잡해질 수 있습니다. 예를 들어, 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 프리젠터 직렬화기 모델 인스턴스 메서드 모델 클래스 메서드 Active Record Worker
컨트롤러/API 엔드포인트 아니요 아니요 아니요
서비스 클래스 아니요 아니요 아니요 아니요
Finder 아니요 아니요 아니요 아니요 아니요 아니요
프리젠터 아니요 아니요 아니요 아니요 아니요
직렬화기 아니요 아니요 아니요 아니요 아니요
모델 클래스 메서드 아니요 아니요 아니요 아니요 아니요
모델 인스턴스 메서드 아니요 아니요 아니요
Worker 아니요 아니요 아니요 아니요

컨트롤러

app/controllers에 있는 모든 것.

컨트롤러는 자체적으로 많은 작업을 수행하지 않아야 하며, 대신 입력을 다른 클래스로 전달하고 결과를 제시해야 합니다.

API 엔드포인트

lib/api(REST API) 및 app/graphql (GraphQL API)에 있는 모든 것.

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

서비스 클래스

app/services에 속하는 모든 것.

서비스 클래스는 모델(엔터티 및 값 객체) 사이의 변경을 조정하는 작업을 나타냅니다.

  1. 객체가 응용 프로그램의 상태에 변경을 가하지 않는 경우 서비스가 아닙니다. Finder 또는 값 객체가 될 수 있습니다.
  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 메서드가 있으며 execute 메서드에서 ServiceResponse를 반환할 수 있습니다.

성공한 경우:

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.

Finders

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

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

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

Presenters

app/presenters에 있는 모든 것은 복잡한 데이터를 Rails 뷰에 노출시키는 데 사용됩니다.

자세한 내용은 문서를 참조하세요.

Serializers

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

Models

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

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

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

일부 예시:

Model 클래스 메서드

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

  • find
  • find_by_id
  • delete_all
  • destroy
  • destroy_all

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

Model 인스턴스 메서드

자체 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을 사용하여 직접 worker를 호출하지 마세요.