추상화 재사용 가이드라인

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를 사용하여 해당 그룹의 모든 프로젝트를 검색합니다. 표면적으로는 무해해 보일 수 있지만, 실제로는 상황이 매우 복잡해질 수 있습니다. 예를 들어, GroupProjectsFinder에 의해 생성된 쿼리는 간단하게 시작될 수 있지만, 시간이 지날수록 더 많은 기능이 추가됩니다.

더 신뢰할 수 있는 (그리고 즐거운) 처리 방법은 GroupProjectsFinder의 구성 요소를 직접 사용하는 것입니다. 이는 IssuableFinder에 더 많은 코드가 필요할 수 있지만, 더 많은 제어와 확신을 제공합니다.

최종 목표

본 문서의 가이드라인은 명확히 어디에서 무엇을 재사용할 수 있는지, 재사용할 수 없는 경우 어떻게 해야 하는지를 정의함으로써 더 좋은 코드 재사용을 촉진하는 데 목적이 있습니다. 추상화를 명확히 분리함으로써 잘못된 추상화를 사용하는 것을 어렵게 만들고, 코드를 쉽게 디버깅할 수 있게 하며, (희망컨대) 성능 문제를 줄이도록 합니다.

추상화

이제 사용 가능한 다양한 추상화 수준을 살펴보고, 그것들이 무엇을 (또는 할 수 없는지) 재사용할 수 있는지 살펴보겠습니다. 여기에서는 다음의 표를 사용하여 각각의 추상화를 정의하고 그것들이 무엇을 (또는 할 수 없는지) 재사용할 수 있는지를 정의합니다.

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

컨트롤러

app/controllers의 모든 것.

컨트롤러는 자체적으로 크게 일하지 않아야 하며, 대신 입력을 다른 클래스로 전달하고 결과를 제공해야 합니다.

API 엔드포인트

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

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

서비스 클래스

app/services에 있는 모든 것.

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

  1. 객체가 애플리케이션의 상태에 변경 사항을 주지 않으면 서비스가 아닙니다. 이는 파인더 또는 값 객체일 수 있습니다.
  2. 조작이 없는 경우 서비스를 실행할 필요는 없습니다. 클래스는 아마도 엔터티, 값 객체 또는 정책으로 더 잘 설계될 수 있습니다.

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

  1. 서비스 클래스 이니셜라이저는 다음의 인수를 포함해야 합니다:
    1. 작업을 수행 중인 모델 인스턴스. 이는 이니셜라이저의 첫 번째 위치 인수여야 합니다. 이러한 인수의 이름은 건의된이나 프로젝트와 같이 개발자의 재량에 따라 지정될 수 있습니다.
    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 메소드는 인수를 가져서는 안됩니다. 필요한 모든 데이터는 이니셜라이저를 통해 전달됩니다.
  3. 반환 값이 필요한 경우 #execute 메소드는 결과를 ServiceResponse 객체를 통해 반환해야 합니다.

몇 가지 베이스 클래스는 서비스 클래스 규칙을 구현합니다. 다음 중 하나를 상속하는 것을 고려할 수 있습니다:

  • 서비스 컨테이너에 의해 범위가 지정된 서비스에는 BaseContainerService.
  • 프로젝트 범위로 제한된 서비스는 BaseProjectService.
  • 그룹에 범위로 제한된 서비스는 BaseGroupService.

일부 도메인이나 유계적 컨텍스트를 위해 서비스 클래스가 다양한 패턴을 사용하는 것이 의미가 있을 수 있습니다. 예를 들어 원격 개발 도메인은 계층화된 아키텍처를 사용하여 도메인 논리를 독립적으로 분리시킵니다. 이는 매우 최소한의 서비스 레이어로 구성되는 하나의 재사용 가능한 CommonService 클래스만 포함합니다. 또한 상태없는 싱글톤 클래스 메소드를 사용하는 함수형 패턴를 사용합니다. 자세한 내용은 Remote Development 서비스 레이어 코드 예제를 참조하십시오. 그러나 이 패턴을 통해 서비스 호출 시그니처가 다르더라도, 항상 모든 결과를 항상 ServiceResponse 객체를 통해 반환함으로써 표준 서비스 클래스 계약을 준수합니다.

서비스가 아닌 클래스는 다른 곳, 예를 들어 lib에서 생성되어야 합니다.

ServiceResponse

서비스 클래스에는 일반적으로 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을 이해하는 데 사용할 수 있는 실패 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과 같이 일반적인 실패의 경우, 도메인 로직에 충분히 특정한 한정된 서형상태 심볼을 활용할 수 있습니다. 다른 실패의 경우에는 가능한한 도메인별 이유를 사용하세요.

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

Finders

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

Finders는 다른 finders를 재사용하지 않으려고 시도하여 생성되는 SQL 쿼리를 더 잘 제어하려고 합니다.

Finders의 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)로 사용될 수도 있습니다.

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

일부 예시:

Model 클래스 메서드

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

  • 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를 사용하여 worker를 직접적으로 호출하지 마십시오.