일반적인 CRUD 대신 비즈니스 지향 커맨드를 정의합니다. 커맨드는 도메인 언어를 API에 도입하고 CQRS(Command Query Responsibility Segregation) 패턴을 가능하게 합니다.
빠른 시작
#[derive(Entity)]
#[entity(table = "users", commands)]
#[command(Register)]
#[command(UpdateEmail: email)]
#[command(Deactivate, requires_id)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub email: String,
#[field(create, response)]
pub name: String,
#[field(response)]
pub active: bool,
}
생성되는 코드
커맨드 구조체
/// User의 Register 작업을 위한 커맨드 페이로드.
#[derive(Debug, Clone)]
pub struct RegisterUser {
pub email: String,
pub name: String,
}
/// User의 UpdateEmail 작업을 위한 커맨드 페이로드.
#[derive(Debug, Clone)]
pub struct UpdateEmailUser {
pub id: Uuid,
pub email: String,
}
/// User의 Deactivate 작업을 위한 커맨드 페이로드.
#[derive(Debug, Clone)]
pub struct DeactivateUser {
pub id: Uuid,
}
커맨드 열거형
/// User 엔티티용 커맨드 열거형.
#[derive(Debug, Clone)]
pub enum UserCommand {
Register(RegisterUser),
UpdateEmail(UpdateEmailUser),
Deactivate(DeactivateUser),
}
impl EntityCommand for UserCommand {
fn kind(&self) -> CommandKind {
match self {
UserCommand::Register(_) => CommandKind::Create,
UserCommand::UpdateEmail(_) => CommandKind::Update,
UserCommand::Deactivate(_) => CommandKind::Custom,
}
}
fn name(&self) -> &'static str {
match self {
UserCommand::Register(_) => "Register",
UserCommand::UpdateEmail(_) => "UpdateEmail",
UserCommand::Deactivate(_) => "Deactivate",
}
}
}
결과 열거형
/// User 커맨드 실행 결과 열거형.
#[derive(Debug, Clone)]
pub enum UserCommandResult {
Register(User),
UpdateEmail(User),
Deactivate,
}
핸들러 트레이트
/// User 커맨드 처리를 위한 비동기 트레이트.
#[async_trait]
pub trait UserCommandHandler: Send + Sync {
type Error: std::error::Error + Send + Sync;
type Context: Send + Sync;
/// 커맨드를 적절한 핸들러로 디스패치.
async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
-> Result<UserCommandResult, Self::Error>;
/// Register 커맨드 처리.
async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
-> Result<User, Self::Error>;
/// UpdateEmail 커맨드 처리.
async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
-> Result<User, Self::Error>;
/// Deactivate 커맨드 처리.
async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
-> Result<(), Self::Error>;
}
커맨드 구문 참조
기본 커맨드
모든 #[field(create)] 필드를 사용합니다:
#[command(Register)]
// 생성됨: RegisterUser { email, name }
특정 필드
지정된 필드만 사용합니다 (자동으로 requires_id 추가):
#[command(UpdateEmail: email)]
// 생성됨: UpdateEmailUser { id, email }
#[command(UpdateProfile: name, bio, avatar)]
// 생성됨: UpdateProfileUser { id, name, bio, avatar }
도메인 오퍼레이션
sets(...)를 선언한 커맨드는 지정한 컬럼을 직접 씁니다. 해당 컬럼이 #[field(update)]일 필요가 없으므로 공개 패치 DTO에도, upsert의 SET 목록에도 들어가지 않습니다:
#[command(VerifyPassport, payload(passport_provider), sets(
passport_verified = "true",
passport_verified_at = "NOW()"
))]
// 생성: VerifyPassportUser { id, passport_provider }
// pool.verify_passport(command) -> User
하나의 UPDATE가 고정 표현식과 페이로드 컬럼만 씁니다. 표현식은 #[column(default = "...")]과 마찬가지로 그대로 문장에 들어가며, 컬럼 이름은 컴파일 타임에 엔티티와 대조됩니다.
transactions를 켜면 같은 연산이 트랜잭션 어댑터에도 생겨 다른 쓰기와 함께 커밋된다. 여기서 없는 행은 어댑터의 다른 메서드처럼 Ok(None)이다.
let mut tx = pool.begin().await?;
let verified = UserTransactionRepo::new(&mut tx)
.verify_passport(VerifyPassportUser { id, passport_provider: Some("gov".into()) })
.await?;
tx.commit().await?;
ID만 있는 커맨드
ID 필드만 추가합니다:
#[command(Deactivate, requires_id)]
// 생성됨: DeactivateUser { id }
#[command(Delete, requires_id, kind = "delete")]
// 생성됨: DeleteUser { id }, () 반환
커스텀 페이로드
외부 구조체를 사용합니다:
pub struct TransferPayload {
pub from_account: Uuid,
pub to_account: Uuid,
pub amount: i64,
}
#[command(Transfer, payload = "TransferPayload")]
// TransferPayload를 직접 사용
커스텀 결과
커스텀 결과 타입을 사용합니다:
pub struct TransferResult {
pub transaction_id: Uuid,
pub success: bool,
}
#[command(Transfer, payload = "TransferPayload", result = "TransferResult")]
// 엔티티 대신 TransferResult 반환
소스 옵션
사용할 필드를 제어합니다:
#[command(Create, source = "create")] // #[field(create)] 필드 사용 (기본값)
#[command(Modify, source = "update")] // #[field(update)] 필드 사용 (선택적)
#[command(Ping, source = "none")] // 페이로드 필드 없음
종류 힌트
결과 타입 추론에 영향을 줍니다:
#[command(Create, kind = "create")] // 엔티티 반환 (기본값)
#[command(Update, kind = "update")] // 엔티티 반환
#[command(Remove, kind = "delete")] // () 반환
#[command(Process, kind = "custom")] // 소스에서 추론
구현 예제
use async_trait::async_trait;
struct UserHandler {
pool: PgPool,
email_service: EmailService,
}
struct RequestContext {
user_id: Option<Uuid>,
correlation_id: Uuid,
}
#[async_trait]
impl UserCommandHandler for UserHandler {
type Error = AppError;
type Context = RequestContext;
async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
-> Result<UserCommandResult, Self::Error>
{
match cmd {
UserCommand::Register(c) => {
let user = self.handle_register(c, ctx).await?;
Ok(UserCommandResult::Register(user))
}
UserCommand::UpdateEmail(c) => {
let user = self.handle_update_email(c, ctx).await?;
Ok(UserCommandResult::UpdateEmail(user))
}
UserCommand::Deactivate(c) => {
self.handle_deactivate(c, ctx).await?;
Ok(UserCommandResult::Deactivate)
}
}
}
async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
-> Result<User, Self::Error>
{
// 검증
if cmd.email.is_empty() {
return Err(AppError::Validation("이메일이 필요합니다".into()));
}
// 사용자 생성
let user = User {
id: Uuid::now_v7(),
email: cmd.email.to_lowercase(),
name: cmd.name,
active: true,
};
// 저장
sqlx::query(
"INSERT INTO users (id, email, name, active) VALUES ($1, $2, $3, $4)"
)
.bind(user.id)
.bind(&user.email)
.bind(&user.name)
.bind(user.active)
.execute(&self.pool)
.await?;
// 부수 효과
self.email_service.send_welcome(&user.email).await?;
Ok(user)
}
async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
-> Result<User, Self::Error>
{
// 권한 확인
if ctx.user_id != Some(cmd.id) {
return Err(AppError::Forbidden("다른 사용자의 이메일을 업데이트할 수 없습니다".into()));
}
// 업데이트
let user: User = sqlx::query_as(
"UPDATE users SET email = $1 WHERE id = $2 RETURNING *"
)
.bind(&cmd.email.to_lowercase())
.bind(cmd.id)
.fetch_one(&self.pool)
.await?;
// 인증 발송
self.email_service.send_verification(&user.email).await?;
Ok(user)
}
async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
-> Result<(), Self::Error>
{
sqlx::query("UPDATE users SET active = false WHERE id = $1")
.bind(cmd.id)
.execute(&self.pool)
.await?;
Ok(())
}
}
커맨드 사용하기
async fn register_user(
handler: &impl UserCommandHandler,
email: String,
name: String,
) -> Result<User, AppError> {
let cmd = RegisterUser { email, name };
let ctx = RequestContext {
user_id: None,
correlation_id: Uuid::new_v4(),
};
match handler.handle(UserCommand::Register(cmd), &ctx).await? {
UserCommandResult::Register(user) => Ok(user),
_ => unreachable!(),
}
}
// 또는 특정 핸들러를 직접 호출
async fn update_email(
handler: &impl UserCommandHandler,
user_id: Uuid,
new_email: String,
ctx: &RequestContext,
) -> Result<User, AppError> {
let cmd = UpdateEmailUser {
id: user_id,
email: new_email,
};
handler.handle_update_email(cmd, ctx).await
}
EntityCommand 트레이트
모든 커맨드 열거형은 EntityCommand 트레이트를 구현합니다:
use entity_derive::{EntityCommand, CommandKind};
let cmd = UserCommand::Register(register_data);
// 커맨드 메타데이터 가져오기
assert_eq!(cmd.name(), "Register");
assert!(matches!(cmd.kind(), CommandKind::Create));
// 패턴 매칭
match cmd.kind() {
CommandKind::Create => println!("엔티티 생성"),
CommandKind::Update => println!("엔티티 업데이트"),
CommandKind::Delete => println!("엔티티 삭제"),
CommandKind::Custom => println!("커스텀 작업"),
}
커맨드 훅
commands와 hooks가 모두 활성화된 경우:
#[derive(Entity)]
#[entity(table = "orders", commands, hooks)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }
생성되는 훅:
#[async_trait]
pub trait OrderHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// 표준 CRUD 훅...
// 커맨드 전용 훅
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}
사용법:
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error> {
// 커맨드 로깅
tracing::info!(command = cmd.name(), "커맨드 처리 중");
// 권한 부여
match cmd {
OrderCommand::Cancel(c) => {
let order = self.find_order(c.id).await?;
if order.status == "shipped" {
return Err(AppError::Forbidden("배송된 주문은 취소할 수 없습니다".into()));
}
}
_ => {}
}
Ok(())
}
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error> {
// 감사 로그
match (cmd, result) {
(OrderCommand::Place(c), OrderCommandResult::Place(order)) => {
self.audit_log("order_placed", order.id).await?;
}
(OrderCommand::Cancel(c), OrderCommandResult::Cancel) => {
self.audit_log("order_cancelled", c.id).await?;
}
}
Ok(())
}
모범 사례
- 도메인 언어 — 비즈니스 용어 사용:
CreateUser대신RegisterUser - 단일 책임 — 하나의 커맨드 = 하나의 비즈니스 작업
- 명시적 의도 — 커맨드 이름은 작업을 설명해야 함
- 핸들러에서 검증 — 검증 로직은 커맨드 핸들러에 유지
- 가능하면 멱등성 — 안전하게 재시도할 수 있도록 커맨드 설계
- 컨텍스트 사용 — 요청 메타데이터(user, correlation ID)는 컨텍스트로 전달
CQRS 패턴
커맨드는 CQRS의 절반입니다. 쿼리 측면을 위해 프로젝션과 결합하세요:
#[derive(Entity)]
#[entity(table = "orders", commands)]
#[projection(Summary: id, status, total_cents, created_at)]
#[projection(Details: id, status, items, shipping_address, total_cents)]
#[command(Place)]
#[command(Ship, requires_id)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }
// 커맨드 (쓰기 측면)
let result = handler.handle(OrderCommand::Place(place_order), &ctx).await?;
// 쿼리 (읽기 측면)
let summary = repo.find_by_id_summary(order_id).await?;
let details = repo.find_by_id_details(order_id).await?;
참고
- [[훅|훅]] — 커맨드 훅을 포함한 생명주기 훅
- [[이벤트|이벤트]] — 감사 로깅을 위한 이벤트 생성
- [[속성|속성]] — 전체 속성 참조