추상화 재사용을 위한 가이드라인
GitLab이 성장함에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 나타났습니다. 서비스 클래스, 직렬화기 및 프리젠터 등이 이에 해당됩니다. 이러한 패턴들은 코드 재사용을 용이하게 했지만, 동시에 특정한 장소에서 오해로운 추상화를 실수로 재사용할 수 있게 했습니다.
이러한 가이드라인이 필요한 이유
코드 재사용은 좋지만 때로는 이로 인해 특정한 사용 사례에서 잘못된 추상화를 강제하는 결과를 초래할 수 있습니다. 이는 결국 유지 관리성, 문제 해결의 용이성 또는 심지어 성능에 부정적인 영향을 미칠 수 있습니다.
예를 들면 ProjectsFinder
를 IssuesFinder
에서 사용하여 일부 프로젝트에 속한 문제를 제한하는 것입니다. 처음에는 좋은 아이디어처럼 보일 수 있지만, 두 클래스 모두 매우 높은 수준의 인터페이스를 제공하면서 매우 적은 제어를 제공합니다. 이는 IssuesFinder
가 더 나은 최적화된 데이터베이스 쿼리를 생성하지 못할 수 있음을 의미합니다. 왜냐하면 쿼리의 대부분은 ‘ProjectsFinder’의 내부에 의해 제어되기 때문입니다.
이 문제를 해결하기 위해, ProjectsFinder
자체를 직접 사용하는 대신, 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
.
서비스 객체가 아닌 클래스는 다른 곳에 만들어져야 합니다. 예를 들어 lib
에 있습니다.
ServiceResponse
서비스 클래스에는 일반적으로 execute
메서드가 있으며, 이 메서드는 ServiceResponse
를 반환할 수 있습니다. ServiceResponse.success
및 ServiceResponse.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는 도메인 모델로 간주됩니다.
일부 예시:
-
DesignManagement::DesignAtVersion
는 디자인 및 버전을 결합하기 위해 검증을 활용하는 모델입니다. -
Ci::Minutes::Usage
은 특정 Namespace에 대한 사용량 계산을 제공하는 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
을 사용하여 워커를 직접 호출하지 마십시오.