Skip to content

Navigation Menu

Sign in
Sign up
RAprogramm edited this page Jan 7, 2026 · 3 revisions

생명주기 훅

엔티티 작업 전후에 커스텀 로직을 실행합니다. 훅은 검증, 정규화, 부수 효과, 권한 부여를 가능하게 합니다.

빠른 시작

#[derive(Entity)]
#[entity(table = "users", hooks)]
pub struct User {
 #[id]
 pub id: Uuid,
 #[field(create, update, response)]
 pub email: String,
 #[field(create, response)]
 pub name: String,
 #[field(skip)]
 pub password_hash: String,
 #[field(response)]
 #[auto]
 pub created_at: DateTime<Utc>,
}

생성되는 코드

hooks 속성은 비동기 트레이트를 생성합니다:

/// entity-derive에 의해 생성됨
#[async_trait]
pub trait UserHooks: Send + Sync {
 type Error: std::error::Error + Send + Sync;
 /// 새 엔티티 생성 전에 호출됨.
 /// DTO를 수정하거나 에러를 반환하여 중단.
 async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
 /// 엔티티 생성 후에 호출됨.
 async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
 /// 엔티티 업데이트 전에 호출됨.
 /// DTO를 수정하거나 에러를 반환하여 중단.
 async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;
 /// 엔티티 업데이트 후에 호출됨.
 async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;
 /// 엔티티 삭제 전에 호출됨.
 /// 에러를 반환하여 중단.
 async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
 /// 엔티티 삭제 후에 호출됨.
 async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}

구현 예제

use async_trait::async_trait;
struct UserService {
 pool: PgPool,
 cache: RedisPool,
 email_sender: EmailService,
}
#[async_trait]
impl UserHooks for UserService {
 type Error = AppError;
 async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
 // 이메일 정규화
 dto.email = dto.email.trim().to_lowercase();
 // 이메일 형식 검증
 if !dto.email.contains('@') {
 return Err(AppError::Validation("잘못된 이메일 형식".into()));
 }
 // 중복 이메일 확인
 let exists = sqlx::query_scalar::<_, bool>(
 "SELECT EXISTS(SELECT 1 FROM users WHERE email = 1ドル)"
 )
 .bind(&dto.email)
 .fetch_one(&self.pool)
 .await?;
 if exists {
 return Err(AppError::Conflict("이미 등록된 이메일".into()));
 }
 Ok(())
 }
 async fn after_create(&self, entity: &User) -> Result<(), Self::Error> {
 // 환영 이메일 발송
 self.email_sender
 .send_welcome(&entity.email, &entity.name)
 .await?;
 // 새 사용자 캐시
 self.cache.set(&format!("user:{}", entity.id), entity).await?;
 Ok(())
 }
 async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error> {
 // 제공된 경우 이메일 정규화
 if let Some(ref mut email) = dto.email {
 *email = email.trim().to_lowercase();
 // 중복 확인 (현재 사용자 제외)
 let exists = sqlx::query_scalar::<_, bool>(
 "SELECT EXISTS(SELECT 1 FROM users WHERE email = 1ドル AND id != 2ドル)"
 )
 .bind(&*email)
 .bind(id)
 .fetch_one(&self.pool)
 .await?;
 if exists {
 return Err(AppError::Conflict("이미 사용 중인 이메일".into()));
 }
 }
 Ok(())
 }
 async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
 // 캐시 무효화
 self.cache.del(&format!("user:{}", entity.id)).await?;
 Ok(())
 }
 async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
 // 삭제 가능 여부 확인
 let has_orders = sqlx::query_scalar::<_, bool>(
 "SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = 1ドル AND status = 'pending')"
 )
 .bind(id)
 .fetch_one(&self.pool)
 .await?;
 if has_orders {
 return Err(AppError::Forbidden("대기 중인 주문이 있는 사용자는 삭제할 수 없습니다".into()));
 }
 Ok(())
 }
 async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
 // 캐시 무효화
 self.cache.del(&format!("user:{}", id)).await?;
 // 관련 데이터 정리
 sqlx::query("DELETE FROM user_sessions WHERE user_id = 1ドル")
 .bind(id)
 .execute(&self.pool)
 .await?;
 Ok(())
 }
}

사용 사례

검증

async fn before_create(&self, dto: &mut CreateProductRequest) -> Result<(), Self::Error> {
 // 가격 검증
 if dto.price_cents <= 0 {
 return Err(AppError::Validation("가격은 양수여야 합니다".into()));
 }
 // SKU 형식 검증
 if !dto.sku.chars().all(|c| c.is_alphanumeric() || c == '-') {
 return Err(AppError::Validation("잘못된 SKU 형식".into()));
 }
 Ok(())
}

정규화

async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
 // 이메일 정규화
 dto.email = dto.email.trim().to_lowercase();
 // 이름 정규화
 dto.name = dto.name.trim().to_string();
 // 각 단어의 첫 글자 대문자로
 dto.name = dto.name
 .split_whitespace()
 .map(|word| {
 let mut chars = word.chars();
 match chars.next() {
 None => String::new(),
 Some(first) => first.to_uppercase().chain(chars).collect(),
 }
 })
 .collect::<Vec<_>>()
 .join(" ");
 Ok(())
}

권한 부여

async fn before_update(&self, id: &Uuid, _dto: &mut UpdatePostRequest) -> Result<(), Self::Error> {
 // 컨텍스트에서 현재 사용자 가져오기
 let current_user = self.current_user()?;
 // 소유권 확인
 let post = sqlx::query_as::<_, Post>(
 "SELECT * FROM posts WHERE id = 1ドル"
 )
 .bind(id)
 .fetch_optional(&self.pool)
 .await?
 .ok_or(AppError::NotFound)?;
 if post.author_id != current_user.id && !current_user.is_admin {
 return Err(AppError::Forbidden("다른 사용자의 게시물을 수정할 수 없습니다".into()));
 }
 Ok(())
}

부수 효과

async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
 // 재고 업데이트
 for item in &entity.items {
 sqlx::query(
 "UPDATE products SET stock = stock - 1ドル WHERE id = 2ドル"
 )
 .bind(item.quantity)
 .bind(item.product_id)
 .execute(&self.pool)
 .await?;
 }
 // 알림 발송
 self.notifications.send_order_confirmation(entity).await?;
 // 이행 작업 예약
 self.job_queue.enqueue(FulfillOrderJob { order_id: entity.id }).await?;
 Ok(())
}

감사 로깅

async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
 sqlx::query(
 "INSERT INTO audit_log (entity_type, entity_id, action, performed_by, performed_at)
 VALUES ('user', 1,ドル 'update', 2,ドル NOW())"
 )
 .bind(entity.id)
 .bind(self.current_user_id())
 .execute(&self.pool)
 .await?;
 Ok(())
}

소프트 삭제와 함께 사용

soft_delete가 활성화되면 추가 훅이 생성됩니다:

#[derive(Entity)]
#[entity(table = "documents", hooks, soft_delete)]
pub struct Document { /* ... */ }

생성되는 훅:

#[async_trait]
pub trait DocumentHooks: Send + Sync {
 type Error: std::error::Error + Send + Sync;
 // 표준 CRUD 훅...
 async fn before_create(&self, dto: &mut CreateDocumentRequest) -> Result<(), Self::Error>;
 async fn after_create(&self, entity: &Document) -> Result<(), Self::Error>;
 async fn before_update(&self, id: &Uuid, dto: &mut UpdateDocumentRequest) -> Result<(), Self::Error>;
 async fn after_update(&self, entity: &Document) -> Result<(), Self::Error>;
 async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>; // 소프트 삭제
 async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
 // 소프트 삭제 전용 훅
 async fn before_restore(&self, id: &Uuid) -> Result<(), Self::Error>;
 async fn after_restore(&self, entity: &Document) -> Result<(), Self::Error>;
 async fn before_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
 async fn after_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}

커맨드와 함께 사용

commandshooks가 모두 활성화되면 커맨드 훅이 생성됩니다:

#[derive(Entity)]
#[entity(table = "orders", hooks, commands)]
#[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> {
 match cmd {
 OrderCommand::Place(place) => {
 // 주문 가능 여부 검증
 if place.items.is_empty() {
 return Err(AppError::Validation("주문에는 상품이 있어야 합니다".into()));
 }
 }
 OrderCommand::Cancel(cancel) => {
 // 취소 가능 여부 확인
 let order = self.find_order(cancel.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(_), OrderCommandResult::Place(order)) => {
 self.send_order_confirmation(order).await?;
 }
 (OrderCommand::Cancel(_), OrderCommandResult::Cancel) => {
 // 환불 로직
 }
 }
 Ok(())
}

모범 사례

  1. 빠른 훅 유지 — 오래 걸리는 작업은 비동기 작업으로 처리
  2. 트랜잭션 사용 — 훅 + 리포지토리 호출을 트랜잭션으로 래핑
  3. 에러를 우아하게 처리 — 의미 있는 에러 타입 반환
  4. 로직 중복 방지 — 횡단 관심사에 훅 사용
  5. 독립적으로 테스트 — 훅 구현을 단위 테스트

에러 처리 패턴

#[derive(Debug)]
pub enum HookError {
 Validation(String),
 Authorization(String),
 Conflict(String),
 Database(sqlx::Error),
}
impl std::error::Error for HookError {}
impl std::fmt::Display for HookError { /* ... */ }
impl From<sqlx::Error> for HookError {
 fn from(err: sqlx::Error) -> Self {
 HookError::Database(err)
 }
}
#[async_trait]
impl UserHooks for UserService {
 type Error = HookError;
 async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
 if dto.email.is_empty() {
 return Err(HookError::Validation("이메일이 필요합니다".into()));
 }
 Ok(())
 }
}

참고

🌐 Language

🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文


🇬🇧 English

Home

Getting Started

Features

Advanced


🇷🇺 Русский

Главная

Начало работы

Возможности

Продвинутое


🇰🇷 한국어

시작하기

기능

고급


🇪🇸 Español

Inicio

Comenzando

Características

Avanzado


🇨🇳 中文

首页

入门

功能

高级


📚 Reference

Clone this wiki locally

AltStyle によって変換されたページ (->オリジナル) /