일반적인 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 }
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?;
참고
- [[훅|훅]] — 커맨드 훅을 포함한 생명주기 훅
- [[이벤트|이벤트]] — 감사 로깅을 위한 이벤트 생성
- [[속성|속성]] — 전체 속성 참조