소프트웨어 디자인 가이드
CRUD 용어 대신 보편적인 언어 사용하기
코드는 제품 및 사용자 문서에서 사용되는 것과 동일한 보편적 언어를 사용해야 합니다. 보편적 언어를 올바르게 사용하지 않으면 기여자 및 고객들에게 혼란을 야기시킬 수 있으며 또한 번역이나 여러 용어 사용이 계속될 때 주요 혼란의 원인이 될 수 있습니다. 이는 또한 커뮤니케이션 전략에 반합니다.
아래 예에서 CRUD 용어는 모호함을 도입합니다. 이 이름은 우리가 epic_issues
연관 레코드를 생성하고 있지만 실제로는 기존 이슈를 epic에 추가하는 것을 나타냅니다. Rails 컨벤션에서 사용된 epic_issues
라는 이름은 서비스 오브젝트와 같은 더 높은 추상화에 누출됩니다. 이 코드는 보편적 언어가 아닌 프레임워크 용어로 이야기합니다.
# 나쁨
EpicIssues::CreateService
보편적 언어 사용은 코드를 명확하게 만들며 독자가 프레임워크 용어를 번역하려고 노력하는 인지 부담을 도입하지 않습니다.
# 좋음
Epic::AddExistingIssueService
보편적 언어를 사용하는 것은 모호하지 않은 단순한 개념을 나타내거나 기존의 보편적 언어와 일치할 때에 사용할 수 있습니다. 예를들어, 프로젝트를 생성하는 것과 기존의 보편적 언어와 일치하는 경우입니다.
# 좋음: 제품 언어와 일치합니다.
Projects::CreateService
신규 클래스 및 데이터베이스 테이블은 보편적 언어를 사용해야 합니다. 이 경우 모델 이름과 테이블 이름은 Rails 컨벤션을 따릅니다.
보편적 언어를 따르지 않는 기존 클래스는 가능한 한 이름을 변경해야 합니다. 데이터베이스 테이블과 같은 일부 하위 수준의 추상화는 이름을 변경할 필요가 없습니다. 예를들어, 모델 이름이 테이블 이름과 다른 경우 self.table_name=
을 사용하세요.
이름 변경이 어려운 경우에만 예외를 허용할 수 있습니다. 예를들어, 네이밍이 STI에 사용되는 경우, 사용자에게 노출되는 경우 또는 파괴적인 변경이 될 경우입니다.
바운드된 컨텍스트
바운드된 컨텍스트에 관련된 목표, 동기 및 방향에 대한 자세한 내용은 바운드된 컨텍스트 워킹 그룹 및 GitLab Modular Monolith 블루프린트을 참조하세요.
네임스페이스 사용하여 바운드된 컨텍스트 정의
건강한 애플리케이션은 바운드된 컨텍스트를 나타내는 매크로 및 서브 컴포넌트로 나뉩니다. GitLab 코드에는 많은 기능 및 컴포넌트가 있어 어떤 컨텍스트가 관련되어 있는지 확인하기 어렵습니다. 이러한 컴포넌트는 비즈니스 도메인 또는 인프라 코드와 관련될 수 있습니다.
어떤 클래스든 해당하는 컨텍스트를 나타내는 모듈/네임스페이스 내에 정의되어야 합니다. 이러한 컨텍스트를 정의하는데 사용되는 허용된 네임스페이스 디렉터리을 유지합니다.
우리가 클래스를 도메인 내부의 네임스페이스에 네임스페이스 할 때:
- 도메인이 의미를 명확하게 하므로 유사한 용어는 모호하지 않습니다:
예를들어,
MergeRequests::Diff
와Notes::Diff
. - 상위 레벨 네임스페이스는 한 개 이상의 그룹과 관련될 수 있으며 도메인 전문가로 식별될 수 있습니다.
- 컴포넌트 간의 상호 작용 및 결합을 더 잘 식별할 수 있습니다.
예를들어,
MergeRequests::
도메인 내의 여러 클래스는Ci::
도메인과 더 많이 상호 작용하고Import::
와는 적게 상호 작용합니다.
# 나쁨
class JobArtifact ... end
# 좋음
module Ci
class JobArtifact ... end
end
바운드된 컨텍스트 정의 방법
허용된 바운드된 컨텍스트는 config/bounded_contexts.yml
에서 도메인 레이어 및 인프라 레이어를 위한 네임스페이스를 포함합니다.
도메인 레이어에서 다음을 참조합니다:
-
application adapters(컨트롤러, API 엔드포인트 및 뷰)를 제외한
app
의 코드. - 도메인 로직에 구체적으로 관련된
lib
의 코드.
이에는 ActiveRecord
모델, 서비스 오브젝트, 워커 및 도메인 별 Plain Old Ruby Objects가 포함됩니다.
현재는 대규모화를 위해서 애플리케이션 어댑터를 제외하고 지금은 여러 도메인에 일치하지 않는 것들은 모듈화에서 제외합니다 (예: 설정, Merge Request 뷰, 프로젝트 뷰 등).
인프라레이어에서는 일반적인 목적으로 사용되는 lib
의 코드를 가리킵니다. 이는 GitLab 비즈니스 개념을 포함하지 않으며 Ruby 젬으로 추출될 수 있습니다.
상위 레벨 네임스페이스(바운드된 컨텍스트)의 명명에 대한 좋은 지침은 관련된 기능 범주를 사용하는 것입니다. 예를들어, Continuous Integration
기능 범주는 Ci::
네임스페이스로 매핑됩니다.
프로젝트 및 그룹은 일반적으로 프로젝트나 그룹을 식별하는 특수한 개념입니다. 기능은 리포지터리나 러너와 같이 프로젝트나 그룹 수준에서 존재합니다만 이러한 기능을Projects::
나 Groups::
아래에 중첩시키지 말아야 합니다. 대신에 상대적인 바운드된 컨텍스트 아래에 중첩시켜야 합니다.
Projects::
와 Groups::
네임스페이스는 그들과 엄격하게 관련된 개념에만 사용되어야 합니다. 예를들어 Project::CreateService
나 Groups::TransferService
.
컨트롤러의 경우에는 애플리케이션 레이어에 바운드된 컨텍스트가 적용되지 않기 때문에 app/controllers/projects
와 app/controllers/groups
를 예외로 허용합니다. 우리는 이러한 컨벤션을 주어진 웹 엔드포인트의 범위를 나타내기 위해 사용합니다.
미래에 기능 범주가 다른 그룹에 할당 될 수 있기 때문에 stage나 group 이름을 사용하지 마세요.
# 나쁨
module Create
class Commit ... end
end
# 좋음
module Repositories
class Commit ... end
end
반면에, 기능 범주는 때로는 너무 촘촘할 수 있습니다. 기능은 제품 및 마케팅에 따라 다르게 처리되지만 내부적으로는 많은 도메인 모델과 동작을 공유할 수 있습니다. 이 경우, 바운드된 컨텍스트가 너무 많으면 얇아지고 다른 컨텍스트와 더 많이 결합될 수 있습니다.
바운드된 컨텍스트(또는 상위 수준 네임스페이스)는 전체 앱에서 매크로 컴포넌트로 볼 수 있습니다. 좋은 바운드된 컨텍스트는 깊은 컨텍스트여야 합니다. 복잡한 도메인의 일부를 더 쪼개기 위해 중첩된 네임스페이스 사용을 고려하세요. 예를들어, Ci::Config::
입니다.
예를들어, ContainerScanning::
, ContainerHostSecurity::
, ContainerNetworkSecurity::
와 같이 개별화된 바운드된 컨텍스트 대신에:
module Security::Container
module Scanning ... end
module NetworkSecurity ... end
module HostSecurity ... end
end
네임스페이스에 정의된 클래스 중 많이 비슷한 다른 네임스페이스에 정의된 클래스와 많은 공통점이 있다면 이 두 네임스페이스가 동일한 바운드된 컨텍스트의 일부입니다.
도메인 코드와 일반 코드의 구분
위에서 언급한 가이드라인은 주로 도메인 코드에 관련됩니다. 도메인 코드에 대해서는 특정 바운드된 컨텍스트를 나타내는 네임스페이스 밑에 루비 클래스를 놓아야 합니다 (특정 기능 및 기능에 대한 일관된 집합).
도메인 코드는 GitLab 제품에 고유한 것입니다. 비즈니스 로직, 정책 및 데이터를 설명합니다. 이 코드는 GitLab 리포지터리에 존재해야 합니다. 도메인 코드는 주로 app/
과 lib/
으로 분할됩니다.
응용 프로그램 코드베이스에서는 더 많은 인프라 수준 작업을 수행할 수 있는 일반 코드도 있습니다. 이는 로거, 계측, Redis와 같은 데이터 스토어를 위한 클라이언트, 데이터베이스 유틸리티 등이 될 수 있습니다.
응용 프로그램을 실행하는 데 중요하지만, 고유한 비지니스 로직에 영향을 미치지 않고 바닥장의 솔루션으로 재작성되거나 대체될 수 있는 일반 코드는 개별적으로 관리되어야 합니다.
오늘날 많은 일반 코드가 lib/
에 있지만 도메인 코드와 혼합됩니다. 우리는 젬 개발 가이드라인에서 설명한 대로 젬을 gems/
디렉터리로 추출해야 합니다.
전지구 클래스의 정복
전지구 클래스(god objects로도 알려짐)에 새로운 데이터와 동작을 추가하지 않는 것을 고려해야 합니다. 우리는 Project
, User
, MergeRequest
, Ci::Pipeline
및 1000줄 이상의 클래스를 전지구 클래스로 간주합니다.
그러한 클래스들은 책임이 과중합니다. 대부분의 경우 새로운 데이터와 동작은 별도 및 전용 클래스로 추가할 수 있습니다.
지침:
- 주로 객체 ID에 대한 참조가 필요한 경우(예:
Project#id
), 외래 키를 사용하거나 객체 주변에 얇은 래퍼를 추가할 수 있습니다. - 전지구 클래스에 메소드를 추가하면 추가 메소드(비공개 또는 공개) 몇 개를 추가해야 한다는 것은 이러한 메소드를 전용 클래스에 캡슐화해야 한다는 신호입니다.
- 데이터 및 연관을 시작하는 곳이
Project
이기 때문에Project
에 메소드를 추가하는 것이 유혹스럽습니다. 해당 데이터가 속한 경계 컨텍스트에 행동을 정의하고, 제네릭하고 과중된 객체보다는 경계 컨텍스트에서 훨씬 관련성이 더 높은 전지구 객체의 측면을 만드는 데 도움이 됩니다.
예: 일반 모델 주변에 얇은 도메인 객체 정의
User
에 여러 메소드를 추가하는 대신 abuse_trust_scores
와 연관이 있기 때문에, 의존성을 역전시켜 보세요.
##
# 나쁨: User 객체에 추가된 동작.
class User
def spam_score
abuse_trust_scores.spamcheck.average(:score) || 0.0
end
def spammer?
# 경고 표시: 특정한 경계 컨텍스트에 속하는 상수를 사용했습니다!
spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
end
def telesign_score
abuse_trust_scores.telesign.recent_first.first&.score || 0.0
end
def arkose_global_score
abuse_trust_scores.arkose_global_score.recent_first.first&.score || 0.0
end
def arkose_custom_score
abuse_trust_scores.arkose_custom_score.recent_first.first&.score || 0.0
end
end
# 사용:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_score
##
# 좋음: 사용자 신뢰 점수를 나타내는 얇은 클래스 정의
class Abuse::UserTrustScore
def initialize(user)
@user = user
end
def spam
scores.spamcheck.average(:score) || 0.0
end
def spammer?
spam > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
end
def telesign
scores.telesign.recent_first.first&.score || 0.0
end
def arkose_global
scores.arkose_global_score.recent_first.first&.score || 0.0
end
def arkose_custom
scores.arkose_custom_score.recent_first.first&.score || 0.0
end
private
def scores
Abuse::TrustScore.for_user(@user)
end
end
# 사용:
user = User.find(1)
user_score = Abuse::UserTrustScore.new(user)
user_score.spam
user_score.spammer?
user_score.telesign
user_score.arkose_global
실제 예시는 Merge Request를 참고하세요.
예: 의존성 역전을 사용하여 도메인 개념 추출
##
# 나쁨: Project에 정의된 통합과 관련된 메소드.
class Project
has_many :integrations
def find_or_initialize_integrations
# ...
end
def find_or_initialize_integration(name)
# ...
end
def disabled_integrations
# ...
end
def ci_integrations
# ...
end
# 많은 메소드...
end
##
# 좋음: 모든 논리를 `Integrations::` 경계 컨텍스트 안에 포함시킨 `Integrations::` 프로젝트 통합
module Integrations
class ProjectIntegrations
def initialize(project)
@project = project
end
def all_integrations
@project.integrations # 여전히 AR 연관성 캐싱을 활용할 수 있습니다.
end
def find_or_initialize(name)
# ...
end
def all_disabled
all_integrations.disabled
end
def all_ci
all_integrations.ci_integration
end
end
end
유사한 리팩터링의 실제 예시를 참고하세요.
사용 사례를 중심으로 소프트웨어 디자인
Active Record의 강력함을 통해 Rails는 엔티티 중심 소프트웨어를 디자인하도록 개발자를 장려합니다. 컨트롤러 및 API 엔드포인트는 엔티티 및 서비스 객체의 CRUD 동작을 대표합니다. 새로운 데이터베이스 열은 서로 다른 사용 사례를 나타내더라도 기존 엔티티 테이블에 추가되곤 합니다.
이 안티 패턴은 다음 중 하나 이상의 방식으로 자주 나타납니다:
- 서로 다른 사용 사례에 대한 다른 전제 조건을 확인합니다.
- 동일한 추상화(서비스 객체, 컨트롤러, 직렬화기)에서 다른 권한을 확인합니다.
- 동일한 추상화에서 다른 부작용을 확인합니다. 예를 들어, “필드 X가 변경되었을 때 Y를 수행하는”과 같은 여러 암시적 사용 사례에 대해 실행됩니다.
안티 패턴 예시
완전히 다른 사용 사례를 지원하는 Groups::UpdateService
가 있습니다:
- 그룹 설명을 업데이트하려면 그룹 관리자 액세스가 필요합니다.
-
shared_runners_minutes_limit
과 같은 컴퓨팅 할당량을 위해 네임스페이스 수준 제한을 설정하려면 인스턴스 관리자 액세스가 필요합니다.
이 두 가지 다른 사용 사례는 서로 다른 매개변수 집합을 지원합니다. 예상치 않은 사용자가 branch 보호 규칙과 shared runners 설정을 동시에 변경하는 것은 기대되지 않으며 동일한 추상화에서 다른 쪽으로 변경되는 인스턴스 관리자가 shared_runners_minutes_limit
를 업데이트하고 그룹 설명도 업데이트하는 것도 기대되지 않습니다. 이는 서로 다른 사용 사례를 나타내며, 서로 다른 도메인에서 나온 것입니다.
솔루션
엔티티 대신 사용 사례 중심으로 디자인합니다. 사람, 사용 사례 및 의도가 다르면 별도의 추상화를 만듭니다:
- 특정 사용 사례의 중첩된 다른 엔드포인트(컨트롤러, GraphQL 또는 REST).
- 특정 권한 및 일관된 매개변수 세트를 내장하는 다른 서비스 객체. 예를 들어, 일반 그룹 설정을 업데이트하는 그룹 관리자용
Groups::UpdateService
와 인스턴스 관리자용Ci::Minutes::UpdateLimitService
는 완전히 다른 권한, 기대치, 매개변수 및 부작용이 있습니다.
최종적으로 이는 전지구 클래스를 정복의 원칙을 활용하는 것을 요구합니다. 우리는 무관한 사용 사례 로직을 하나의 덜 응집된 클래스에 결합하여 느슨하게 결합되고 높은 응집력을 달성하려고 합니다. 결과적으로 권한은 전체 동작 전반에 일관되게 적용되므로 더 안전한 시스템이 됩니다. 마찬가지로 별도의 모델이나 테이블에 정의될 때 관리자 수준의 데이터를 우연히 노출하지 않습니다. 데이터를 읽거나 쓰기 전에 일관되게 동일한 사용 사례에 속하는 데이터를 처리하기 때문입니다.