-
-
Notifications
You must be signed in to change notification settings - Fork 0
github-actions[bot] edited this page Jul 26, 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>; }
hooks 속성은 {Entity}Repo<H>도 생성합니다. 풀과 훅 구현을 함께 소유하며 모든 변경 연산 주위에서 훅을 실행하는 리포지토리입니다:
let repo = UserRepo::new(pool, MyUserHooks); let user = repo.create(dto).await?; // before_create → INSERT → after_create let user = repo.update(id, patch).await?; // before_update → UPDATE → after_update let gone = repo.delete(id).await?; // 실제로 행이 영향받은 경우에만 after_delete let found = repo.find_by_id(id).await?; // 읽기에는 훅이 없습니다
before_*가 실패하면 아무것도 기록되기 전에 중단됩니다. 읽기를 비롯한 나머지 리포지토리 메서드는 래퍼를 통해 그대로 풀로 전달되며, 순수한 풀은 훅 없이 계속 동작합니다 — 래퍼는 선택 사항입니다.
훅 오류는 리포지토리 오류로 변환되기만 하면 되므로, 훅은 자체 오류 타입을 유지할 수 있습니다:
impl From<HookError> for sqlx::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>; }
commands와 hooks가 모두 활성화되면 커맨드 훅이 생성됩니다:
#[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(()) }
- 빠른 훅 유지 — 오래 걸리는 작업은 비동기 작업으로 처리
- 트랜잭션 사용 — 훅 + 리포지토리 호출을 트랜잭션으로 래핑
- 에러를 우아하게 처리 — 의미 있는 에러 타입 반환
- 로직 중복 방지 — 횡단 관심사에 훅 사용
- 독립적으로 테스트 — 훅 구현을 단위 테스트
#[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(()) } }
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
Getting Started
Features
Advanced
Начало работы
Возможности
Продвинутое
시작하기
기능
고급
Comenzando
Características
Avanzado
入门
功能
高级