Rails MVC와 ‘네 번째 버킷’의 필요성 (0:58)
Rails는 기본적으로 모델(Model), 뷰(View), 컨트롤러(Controller) 세 가지 구조를 제공하며, DHH(Rails 창시자)는 이 세 가지면 충분하다고 주장합니다. 특히 ‘Fat Model’ 패턴을 선호하여 대부분의 비즈니스 로직을 Active Record 모델에 집중시키도록 권장합니다. 그러나 이 방식은 애플리케이션이 복잡해지고 규모가 커지면서 ‘God Model’(단일 모델에 모든 로직이 집중되어 거대하고 다루기 어려워지는 현상)을 초래하는 문제점이 있습니다. 컨트롤러는 재사용성이 낮고 HTTP 요청에 의존한다는 단점이 있어 비즈니스 로직을 담기에는 부적합합니다. 이에 따라 많은 개발자들은 모델, 뷰, 컨트롤러 외에 비즈니스 로직을 분리할 ‘네 번째 버킷’을 모색하게 되었고, 서비스 객체(Service Objects)나 인터랙터(Interactor)와 같은 다양한 패턴이 제안되었습니다. UP 코드베이스에서는 이를 ‘커맨드(Commands)’라고 명명했습니다.
UP 커맨드 패턴의 역사 (4:15)
1. 초기 도입 (2017년)
- 최초 커맨드: 2017년 Langers에 의해
Engine::Command
네임스페이스에 첫 커맨드가 작성되었습니다. - 구조: 상속받는 부모 클래스 없이 순수 Ruby 클래스로, 단일 클래스 메서드
run
을 가졌습니다. 이 메서드는 인자를 받아 데이터베이스를 변경하는 등의 작업을 수행했습니다. - 의도: 커밋 메시지(“새로운 기능을 더 명확하게 추가하기 위해
create_transaction
클래스를 별도 파일로 이동”)에서 알 수 있듯이, 복잡해지는 도메인 로직을 분리하려는 의도였습니다. GraphQL에서 이미 ‘뮤테이션(mutation)’이라는 용어를 사용하고 있었기 때문에, 혼동을 피하고자 ‘커맨드’라는 이름을 선택했습니다. 이는 CQRS(Command Query Responsibility Segregation)의 ‘커맨드’처럼 시스템을 변경하는(mutate) 역할을 의도했음을 시사합니다.
2. 패턴의 진화 (초기)
- 인스턴스 기반 실행: 몇 주 후,
run
클래스 메서드가 인자를 받아 인스턴스를 생성하고 해당 인스턴스의run
메서드를 호출하는 패턴으로 변경되었습니다. 이는 Ruby에서 흔히 사용되는 ‘함수형 객체(function as an object)’ 디자인 패턴으로, 자연스러운 발전 과정이었습니다. - Interactor Gem 제거: 관리자 영역에서 복잡한 도메인 로직을 처리하기 위해
interactor
Gem이 도입되었으나, 6개월 후 해당 로직이 단순화되면서 Gem을 유지할 필요성이 사라졌습니다. Langers는interactor
Gem을 제거하고 관련 로직을 기존 ‘커맨드’ 패턴으로 전환했습니다.
3. 네임스페이스 및 기반 모듈 변경 (2019년 - 2021년)
- 네임스페이스 제거 (2년 후): 94개의 커맨드가 존재하던 시점에
Engine
네임스페이스의 개념이 불필요해져 삭제되었고, 현재와 같은 전역Command
네임스페이스가 확립되었습니다. - Sorbet 도입과
T::Struct
: 약 480개의 커맨드가 작성된 시점에서 코드베이스에 Sorbet이 도입되었습니다. 커맨드들은 여전히 순수 Ruby 객체였으나, Sorbet의T::Struct
를 사용하여 타입이 지정된 속성을 정의하기 시작했습니다. CommandRunnable
모듈 도입:T::Struct
와 기존 ‘클래스 메서드에서 인스턴스 메서드 호출’ 패턴 간의 타입 추론 문제(Sorbet이 클래스 메서드의 타입을 제대로 인식하지 못함)가 발생했습니다. 이를 해결하기 위해CommandRunnable
모듈이 도입되었습니다. 초기에는 이 모듈 자체가 코드 로직을 제공하기보다는, Sorbet 커스텀 플러그인이 해당 클래스를 인식하고 타입 문제를 해결하도록 ‘태그’하는 역할을 했습니다.
4. 기능 추가 및 현재 (2022년 - 현재)
- 백그라운드 작업 지원:
CommandRunnable
모듈에 메서드가 추가되기 시작했습니다. 첫 번째로 추가된 기능은 커맨드를 백그라운드 작업으로 실행하는 것이었습니다.define_job
DSL을 통해 우선순위를 지정하고,run_later
를 호출하여 커맨드를 백그라운드 큐에 추가할 수 있게 되었습니다. - DataDog 트레이싱: 이후 모든 커맨드에 기본적으로 DataDog 트레이싱 기능이 추가되었습니다. 이는 프로덕션 환경에서 성능 특성 및 호출 빈도 등 관측 가능성을 높여 디버깅에 큰 도움을 줍니다.
- 현재 상태: 2023년 현재 약 1,135개의 커맨드가 존재하며,
CommandRunnable
모듈은 108라인으로 성장했습니다. 이 모듈은 Sorbet 호환성, 함수형 객체 패턴, 백그라운드 작업 실행, 그리고 자동 DataDog 트레이싱 기능을 제공합니다.
회고: 잘된 점과 개선점 (11:57)
잘된 점
- ‘네 번째 버킷’ 역할 수행: 모델, 컨트롤러, GraphQL 로직을 단순하게 유지하며 비즈니스 로직을 효과적으로 분리했습니다.
- 재사용성: GraphQL이나 Rails 컨트롤러 등 다양한 호출 지점에서 동일한 커맨드를 재사용할 수 있습니다.
- 낮은 복잡도: 8년 동안 단 108라인의
CommandRunnable
모듈만으로 모든 기능을 제공하며, 학습 곡선이 낮아 신규 개발자들이 쉽게 패턴을 익힐 수 있습니다. - 테스트 용이성: 비즈니스 로직을 호출 지점과 분리하여 테스트하기 용이하며, 테스트 속도도 향상됩니다.
- 뛰어난 관측 가능성: DataDog 트레이싱이 기본 제공되어 프로덕션 환경에서의 디버깅 및 성능 모니터링이 매우 효율적입니다.
개선점
- 모호한 이름: ‘커맨드’라는 이름이 너무 일반적이어서 Gang of Four의 커맨드 패턴, CQRS의 커맨드, 이벤트 소싱의 커맨드 등 다양한 개념과 혼동을 야기합니다. 이는 신규 입사자들에게 학습의 어려움을 줍니다.
- 개념의 모호화: 원래 시스템을 변경하는(mutate) 역할을 의도했으나, 시간이 지나면서 비즈니스 로직이 없거나, 시스템을 변경하지 않는 순수 함수, 심지어 쿼리(query)로 사용되는 경우도 생겨 원래의 의도가 희석되었습니다.
- 초기 문서화 부족: 초기에 ‘커맨드’의 개념과 사용 목적에 대한 명확한 문서화가 이루어지지 않아, ‘부족 지식(tribal knowledge)’에 의존하게 되었고, 이는 조직 규모가 커지면서 개념의 모호화를 심화시켰습니다.
- 유효성 검사 메커니즘 부재: 커맨드 내에 내장된 유효성 검사 메커니즘이 없어, 개발자들이 예외(exception)를 던지는 방식으로 처리하고 있습니다. 이는 유효성 검사 로직의 재사용성을 저해하고, GraphQL이나 Rails 응답에서 에러를 구조화하여 반환하기 어렵게 만듭니다.
- 비즈니스 로직과 백그라운드 작업의 혼용: 비즈니스 로직(커맨드)과 백그라운드 작업(job)의 개념이 혼용되어 있습니다. 모든 비즈니스 로직이 백그라운드 작업으로 실행될 필요는 없으며, 모든 백그라운드 작업이 비즈니스 로직을 포함하는 것은 아닙니다. 이로 인해 개발자들이 단순히 백그라운드 작업을 만들고자 할 때
CommandRunnable
을 포함시켜 불필요하게 ‘커맨드’의 역할을 부여하는 경우가 발생합니다. 또한, 백그라운드 작업은 인자 직렬화가 필수적인데, 모든 커맨드의 인자가 직렬화 가능한 것은 아니어서 문제가 발생할 수 있습니다.