추상화 재사용에 대한 가이드라인
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
에 있는 모든 것.
서비스 클래스는 모델 간의 변경 사항을 조정하는 작업을 나타냅니다(예: 엔터티 및 값 객체).
변경 사항은 애플리케이션의 상태에 영향을 미칩니다.
-
객체가 애플리케이션의 상태에 변경을 가하지 않으면 서비스가 아닙니다. 그것은 파인더 또는 값 객체일 수 있습니다.
-
작업이 없으면 서비스를 실행할 필요가 없습니다. 이 클래스는 아마도 엔터티, 값 객체 또는 정책으로 더 잘 설계될 수 있습니다.
서비스 클래스를 구현할 때, 다음 패턴을 사용하는 것을 고려하세요:
- 서비스 클래스 초기화자는 인수에 다음을 포함해야 합니다:
- 작용하고 있는 모델 인스턴스. 초기화자의 첫 번째 위치 인수여야 합니다.
인수 이름은 개발자의 재량에 따라 결정되며, 예:
issue
,project
,merge_request
. - 서비스가 사용자에 의해 시작된 작업이나 사용자 맥락에서 실행되는 경우,
초기화자는
current_user:
키워드 인수를 가져야 합니다.current_user:
인수가 있는 서비스는 고급 비즈니스 로직을 실행하며, 작업을 수행하는 데 대한 사용자 권한을 검증해야 합니다. - 서비스에 사용자 맥락이 없고 사용자가 직접 시작하지 않는 경우(예: 백그라운드 서비스 또는 부작용),
current_user:
인수가 필요하지 않습니다. 이는 저수준 도메인 로직 또는 인스턴스 전체 로직을 설명합니다. - 서비스에 필요한 모든 추가 데이터는 명시적 키워드 인수가 권장됩니다.
서비스에 너무 긴 인수 목록이 필요한 경우, 다음과 같이 분할하는 것을 고려하세요:
-
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
-
- 작용하고 있는 모델 인스턴스. 초기화자의 첫 번째 위치 인수여야 합니다.
인수 이름은 개발자의 재량에 따라 결정되며, 예:
- 서비스 클래스는 단일 공용 인스턴스 메서드
#execute
를 구현해야 하며, 이는 서비스 클래스 동작을 호출합니다:-
#execute
메서드는 인수를 받지 않습니다. 모든 필수 데이터는 초기화자로 전달됩니다.
-
- 반환 값이 필요한 경우,
#execute
메서드는ServiceResponse
객체를 통해 결과를 반환해야 합니다.
여러 기본 클래스가 서비스 클래스 관습을 구현합니다. 다음과 같은 클래스에서 상속받는 것을 고려할 수 있습니다:
- 컨테이너(프로젝트나 그룹)에 의해 스코프가 제한된 서비스의 경우
BaseContainerService
. - 프로젝트에 의해 스코프가 제한된 서비스의 경우
BaseProjectService
. - 그룹에 의해 스코프가 제한된 서비스의 경우
BaseGroupService
.
일부 도메인 또는 바운디드 컨텍스트에서는
서비스 클래스가 다른 패턴을 사용하는 것이 합리적일 수 있습니다.
예를 들어, 원격 개발 도메인은
레이어드 아키텍처를 사용하여
도메인 로직을 표준 패턴을 따르는 별도의 도메인 레이어에 고립시킵니다.
이는 단일 재사용 가능한 CommonService
클래스로만 구성된 매우
미니멀한 서비스 레이어를 허용합니다.
또한 상태가 없는 싱글턴 클래스 메서드와 함께
기능적 패턴을 사용합니다.
자세한 내용은 원격 개발 서비스 레이어 코드 예제를 참조하세요.
하지만, 이 패턴을 통한 서비스 호출 서명이 다르더라도,
모든 결과를 ServiceResponse
객체를 통해 반환하는
표준 서비스 클래스 계약을 여전히 준수합니다.
서비스 객체가 아닌 클래스는
다른 곳에서 생성되어야 하며,
예를 들어 lib
에서 생성되어야 합니다.
서비스 응답
서비스 클래스는 일반적으로 execute
메서드를 가지고 있으며, 이는 ServiceResponse
를 반환할 수 있습니다. execute
메서드에서 ServiceResponse.success
및 ServiceResponse.error
를 사용하여 응답을 반환할 수 있습니다.
성공적인 경우:
response = ServiceResponse.success(message: '브랜치가 삭제되었습니다')
response.success? # => true
response.error? # => false
response.status # => :success
response.message # => '브랜치가 삭제되었습니다'
실패한 경우:
response = ServiceResponse.error(message: '지원하지 않는 작업')
response.success? # => false
response.error? # => true
response.status # => :error
response.message # => '지원하지 않는 작업'
추가 페이로드도 첨부할 수 있습니다:
response = ServiceResponse.success(payload: { issue: issue })
response.payload[:issue] # => issue
오류 응답은 고유한 reason
을 지정할 수도 있으며, 이는 호출자가 실패의 본질을 이해하는 데 사용될 수 있습니다.
HTTP 엔드포인트인 호출자는 이 reason
심볼을 HTTP 상태 코드로 변환할 수 있습니다:
response = ServiceResponse.error(
message: '작업이 재시도할 수 없는 상태입니다',
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
.
파인더
모든 것은 app/finders
에 있으며, 일반적으로 데이터베이스에서 데이터를 조회하는 데 사용됩니다.
파인더는 생성하는 SQL 쿼리를 더 잘 제어하기 위해 다른 파인더를 재사용할 수 없습니다.
파인더의 execute
메서드는 ActiveRecord::Relation
을 반환해야 합니다. 예외는 spec/support/finder_collection_allowlist.yml
에 추가할 수 있습니다.
자세한 사항은 #298771
을 참조하십시오.
프레젠터
모든 것은 app/presenters
에 있으며, 복잡한 데이터를 Rails 뷰에 노출시키는 데 사용되며, 많은 인스턴스 변수를 만들 필요가 없습니다.
자세한 정보는 문서를 참조하십시오.
직렬 변환기
모든 것은 app/serializers
에 있으며, 일반적으로 요청에 대한 응답을 JSON으로 표시하는 데 사용됩니다.
모델
app/models
의 클래스와 모듈은 데이터를 캡슐화하는 도메인 개념을 나타내며 데이터와 동작을 포함합니다.
이 클래스는 데이터 저장소(예: ActiveRecord 모델)와 직접 상호작용하거나, ActiveRecord 모델 위에 얇은 래퍼(Plain Old Ruby Objects)로 richer 도메인 개념을 표현할 수 있습니다.
도메인 개념을 나타내는 엔티티와 값 개체는 도메인 모델로 간주됩니다.
일부 예시:
-
DesignManagement::DesignAtVersion
디자인과 버전을 결합하기 위한 검증을 활용하는 모델입니다. -
Ci::Minutes::Usage
특정 네임스페이스에 대한 사용량 계산을 제공하는 값 개체입니다.
모델 클래스 메서드
이것들은 _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입니다.
워커
app/workers
의 모든 내용.
SomeWorker.perform_async
또는 SomeWorker.perform_in
을 사용하여 Sidekiq 작업을 예약합니다. SomeWorker.new.perform
을 사용하여 워커를 직접 호출하지 마십시오.