추상화 재사용을 위한 가이드라인

GitLab이 성장함에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 나타났습니다. 서비스 클래스, 직렬화기 및 프리젠터 등이 이에 해당됩니다. 이러한 패턴들은 코드 재사용을 용이하게 했지만, 동시에 특정한 장소에서 오해로운 추상화를 실수로 재사용할 수 있게 했습니다.

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

코드 재사용은 좋지만 때로는 이로 인해 특정한 사용 사례에서 잘못된 추상화를 강제하는 결과를 초래할 수 있습니다. 이는 결국 유지 관리성, 문제 해결의 용이성 또는 심지어 성능에 부정적인 영향을 미칠 수 있습니다.

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

이 문제를 해결하기 위해, ProjectsFinder 자체를 직접 사용하는 대신, 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를 반환할 수 있습니다. ServiceResponse.successServiceResponse.error를 사용하여 execute 메서드에서 응답을 반환할 수 있습니다.

성공적인 경우:

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_retrievable)

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

도메인 논리에 충분히 구체적인 경우 레일스 HTTP 상태 심볼들을 활용할 수 있습니다. 그 밖의 실패의 경우 도메인별 이유를 사용하십시오.

예: :job_not_retrievable, :duplicate_package, :merge_request_not_mergeable.

파인더

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

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

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

프리젠터

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

더 많은 정보는 문서에서 확인하세요.

직렬화기

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

모델

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

이러한 클래스는 데이터 스토어(예: ActiveRecord 모델)와 직접 상호 작용할 수도 있으며 ActiveRecord 모델 위에 (PORO)를 사용하여 더 풍부한 도메인 개념을 표현할 수도 있습니다.

도메인 개념을 나타내는 Entity 및 Value Object는 도메인 모델로 간주됩니다.

일부 예시:

모델 클래스 메서드

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

  • find
  • find_by_id
  • delete_all
  • destroy
  • destroy_all

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

모델 인스턴스 메서드

_GitLab 자체_에서 Active Record 모델에 정의된 인스턴스 메서드입니다. Active Record에서 제공하는 메서드는 다음을 포함합니다:

  • save
  • update
  • destroy
  • delete

Active Record

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

워커

app/workers의 모든 것입니다.

사이드킥 작업을 예약하려면 SomeWorker.perform_async 또는 SomeWorker.perform_in을 사용하십시오. 절대로 SomeWorker.new.perform을 사용하여 워커를 직접 호출하지 마십시오.