Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

일반적인 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!("커스텀 작업"),
}

커맨드 훅

commandshooks가 모두 활성화된 경우:

#[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(())
}

모범 사례

  1. 도메인 언어 — 비즈니스 용어 사용: CreateUser 대신 RegisterUser
  2. 단일 책임 — 하나의 커맨드 = 하나의 비즈니스 작업
  3. 명시적 의도 — 커맨드 이름은 작업을 설명해야 함
  4. 핸들러에서 검증 — 검증 로직은 커맨드 핸들러에 유지
  5. 가능하면 멱등성 — 안전하게 재시도할 수 있도록 커맨드 설계
  6. 컨텍스트 사용 — 요청 메타데이터(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?;

참고

  • [[훅|훅]] — 커맨드 훅을 포함한 생명주기 훅
  • [[이벤트|이벤트]] — 감사 로깅을 위한 이벤트 생성
  • [[속성|속성]] — 전체 속성 참조