무엇인가요?
entity-derive는 단일 엔티티 정의에서 완전한 도메인 레이어를 생성하는 Rust 프로시저 매크로입니다. 단순한 CRUD가 아닌 — 이벤트, 훅, 커맨드, 타입 안전 필터링을 갖춘 아키텍처 프레임워크입니다.
문제점
약 10개 엔티티를 가진 일반적인 Rust 백엔드:
| 컴포넌트 | 코드 라인 수 | 문제점 |
|---|---|---|
| DTO (Create, Update, Response) | 엔티티당 ~60줄 | 수동 동기화, 누락된 필드 |
| Repository trait + impl | 엔티티당 ~150줄 | 런타임 SQL 오류, 복사-붙여넣기 |
| Entity ↔ DTO 매핑 | 엔티티당 ~40줄 | 데이터 누출 (Response에 password_hash) |
| 검증 및 훅 | 서비스에 분산 | 중복, 단일 소스 없음 |
| 이벤트/감사 | 없거나 임시 방편 | 변경 이력 없음 |
총계: 10개 엔티티에 ~2500줄 보일러플레이트. 그리고 스키마 변경마다 5곳 이상 수동 수정 필요.
해결책
#[derive(Entity)]
#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[filter(like)]
pub email: String,
#[field(skip)] // API에 절대 노출 안됨
pub password_hash: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
15줄 → 완전한 도메인 레이어:
CreateUserRequest,UpdateUserRequest,UserResponse- 타입 안전 SQL을 갖춘
UserRepository UserEvent::Created,Updated,Deleted- 비즈니스 로직을 위한
UserHooks RegisterUser,DeactivateUser커맨드- 필터링을 위한
UserQuery
초보자도 쉽게
최소 예제 — 10줄:
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(create, update, response)]
pub content: String,
}
완료. 다음을 갖게 됩니다:
CreatePostRequest,UpdatePostRequest,PostResponsecreate(),find_by_id(),update(),delete(),list()를 가진PostRepository- PostgreSQL용 타입 안전 SQL
- 모든 것이 바로 작동
마법 없음. cargo expand 실행 — 직접 작성했을 코드와 정확히 동일. 단지 오류 없이 몇 초 만에.
왜 이벤트인가?
문제: CRUD 애플리케이션에는 이력이 없습니다. 누가 레코드를 변경했나요? 언제? 이전에 무엇이 있었나요? 감사에는 별도의 인프라와 규율이 필요합니다.
해결책: #[entity(events)]가 타입화된 이벤트를 생성합니다:
pub enum UserEvent {
Created(User),
Updated { id: Uuid, changes: UpdateUserRequest },
Deleted(Uuid),
}
장점:
- 기본 감사 — 이벤트를 구독하고 로그에 저장
- Event Sourcing — 이벤트 이력에서 상태 복원 가능
- 통합 — Kafka, WebSocket 알림, 캐시 무효화
- 디버깅 — 모든 엔티티의 완전한 변경 이력
왜 훅인가?
문제: 비즈니스 로직이 분산되어 있습니다. 이메일 검증 — 컨트롤러에. 비밀번호 해싱 — 서비스에. 이메일 발송 — 별도 워커에. 사용자 생성 로직을 어디서 찾아야 하나요?
해결책: #[entity(hooks)]가 라이프사이클을 중앙화합니다:
impl UserHooks for MyHooks {
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Error> {
dto.email = dto.email.to_lowercase(); // 정규화
validate_email(&dto.email)?; // 검증
Ok(())
}
async fn after_create(&self, user: &User) -> Result<(), Error> {
self.mailer.send_welcome(user).await?; // 비즈니스 액션
Ok(())
}
}
장점:
- 단일 장소 — 모든 엔티티 로직이 정의 옆에
- 예측 가능성 — 무엇이 언제 실행되는지 명확
- 테스트 용이성 — 훅을 모킹하고 독립적으로 테스트 가능
- 구성 — 다른 컨텍스트에 다른 구현
왜 커맨드인가?
문제: REST API는 의도를 숨깁니다. POST /users — 등록인가요? 관리자 생성인가요? CSV 임포트인가요? PATCH /users/123 — 비활성화? 이메일 변경? 차단?
해결책: #[command(...)]가 비즈니스 도메인을 표현합니다:
#[command(Register)] // 자가 등록
#[command(Invite)] // 관리자 초대
#[command(Deactivate, requires_id)] // 계정 비활성화
#[command(Ban, requires_id)] // 위반으로 차단
비교:
// CRUD (무슨 일이 일어나고 있나요?)
pool.update(user_id, UpdateUserRequest { active: Some(false), ..default() }).await?;
// 커맨드 (명확한 의도)
handler.handle(DeactivateUser { id: user_id }).await?;
장점:
- 자체 문서화 API — 커맨드 이름 = 비즈니스 어휘
- 다른 로직 —
Deactivate와Ban은 다른 사이드 이펙트 가능 - CQRS 준비 — 커맨드를 라우팅, 로깅, 재시도하기 쉬움
- 타입 안전성 — 컴파일러가 커맨드 존재 확인
왜 타입 안전 필터링인가?
문제: 문자열 쿼리 파라미터는 런타임 오류의 원인:
GET /users?stauts=active // 오타 — 조용히 무시됨
GET /users?created_at=tomorrow // 잘못된 날짜 — 런타임 패닉
해결책: #[filter]가 타입화된 구조체를 생성:
let query = UserQuery {
email: Some("@company.com".into()), // ILIKE '%@company.com%'
created_at_min: Some(week_ago), // >= week_ago
created_at_max: Some(now), // <= now
..Default::default()
};
let users = pool.list_filtered(&query, 100, 0).await?;
장점:
- 컴파일 타임 검사 — 필드 이름 오타 = 컴파일 오류
- 타입 안전성 —
DateTime을String과 비교 불가 - 자동완성 — IDE가 사용 가능한 필터 제안
- SQL 인젝션 보호 — 파라미터가 바인딩됨, 연결되지 않음
투명성
매크로는 로직을 숨기지 않습니다. 생성되는 모든 것은 일반 Rust 코드이며:
- 읽기 —
cargo expand가 모든 생성 코드 표시 - 이해 — 런타임 리플렉션 없음, 구조체와 트레이트만
- 재정의 —
sql = "trait"로 자체 SQL 작성 - 디버그 — 컴파일러 오류가 매크로 내부가 아닌 사용자 코드를 가리킴
// 무엇이 생성되는지 이해하고 싶다면?
cargo expand --lib | grep -A 50 "impl UserRepository"
Zero magic. 매크로가 깨지면 — 항상 수동으로 코드 작성 가능. 락인 없음.
Rust의 전체 파워
컴파일 타임 보장
#[field(skip)]
pub password_hash: String,
이것은 “이 필드를 직렬화하지 마세요“라는 런타임 검사가 아닙니다. UserResponse 구조체에서 필드의 물리적 부재입니다. 실수로 반환할 수 없음 — 필드가 단순히 존재하지 않음.
Zero-cost 추상화
생성된 코드는:
- Box/dyn 없는 일반
struct - 중간 레이어 없는 직접 sqlx 호출
- 핫 패스에
#[inline] - 필요 이상의 할당 없음
벤치마크: 생성된 리포지토리는 수작업 코드와 동일한 속도로 실행. 왜냐하면 동일한 코드이기 때문.
기본 Async
// 모든 것이 async, 모든 것이 Send + Sync
let user = pool.find_by_id(id).await?;
let users = pool.list(100, 0).await?;
tokio, async-std, 모든 async 런타임과 완전 호환.
엄격한 타이핑
// 컴파일 오류: 그런 필드 없음
let query = UserQuery { naem: "test".into(), ..default() };
^^^^ unknown field
// 컴파일 오류: 잘못된 타입
let query = UserQuery { created_at_min: "yesterday".into(), ..default() };
^^^^^^^^^^^^ expected DateTime<Utc>
코드가 컴파일되면 — 올바르게 작동합니다.
전문적인 아키텍처
Clean Architecture 준비
Domain Layer (entity-derive)
├── Entities — #[derive(Entity)]
├── DTOs — CreateRequest, UpdateRequest, Response
├── Repository Trait — 저장소 추상화
├── Events — 도메인 이벤트
├── Commands — 비즈니스 작업
└── Hooks — 라이프사이클 로직
Infrastructure Layer (사용자 코드)
├── Repository Impl — PgPool 자동 또는 커스텀
├── Event Handlers — 이벤트 구독
├── Command Handlers — 비즈니스 로직 구현
└── External Services — 통합
깨끗한 분리. Domain은 HTTP, 데이터베이스, Kafka에 대해 알지 못함. 그것들은 구현 세부사항.
CQRS/Event Sourcing 준비
// Command side
handler.handle(RegisterUser { email, name }).await?;
// Query side
let users = pool.list_filtered(&query, 100, 0).await?;
// Event side
match event {
UserEvent::Created(user) => kafka.send("user.created", &user).await?,
UserEvent::Updated { id, changes } => audit_log.record(id, changes).await?,
_ => {}
}
간단한 CRUD를 원하시나요? 가능합니다. 전체 CQRS를 원하시나요? commands와 events 활성화. 아키텍처가 프로젝트와 함께 성장.
확장성
레벨 1: 기본 CRUD
#[entity(table = "users")]
레벨 2: + 필터링
#[entity(table = "users")]
// + 필드에 #[filter]
레벨 3: + 이벤트와 훅
#[entity(table = "users", events, hooks)]
레벨 4: + CQRS 커맨드
#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
레벨 5: 전체 제어
#[entity(table = "users", sql = "trait", events, hooks, commands)]
// 자체 SQL, 자체 로직, 모든 DTO와 타입 유지
간단하게 시작. 성장하면서 기능 추가. 재작성하지 말고 — 확장.
보안
#[field(skip)]
pub password_hash: String,
skip은: 이 필드가 절대 나타나지 않음을 의미:
CreateUserRequest(외부에서 전달 불가)UpdateUserRequest(API로 수정 불가)UserResponse(클라이언트에 실수로 반환 불가)
password_hash를 다루는 유일한 방법 — 코드에서 엔티티를 통해 직접. 설계상 누출 불가능.
왜 이것이 대단한가
| 측면 | 얻는 것 |
|---|---|
| 개발 속도 | 하루 대신 한 시간에 10개 엔티티 |
| 신뢰성 | 모든 것의 컴파일 타임 검증 |
| 보안 | 실수로 데이터 누출 불가능 |
| 성능 | Zero-cost, 수작업 코드와 동일 |
| 명확성 | 투명한 생성, 마법 없음 |
| 유연성 | 하나의 속성으로 단순 CRUD에서 CQRS까지 |
| 확장성 | 프로젝트와 함께 성장하는 아키텍처 |
| 유지보수성 | 단일 진실 소스, 적은 버그 |
문서
| 주제 | 설명 |
|---|---|
| [[속성|속성]] | 전체 속성 참조 |
| [[필터링|필터링]] | 타입 안전 쿼리 필터링 |
| [[관계|관계]] | belongs_to와 has_many |
| [[이벤트|이벤트]] | 라이프사이클 이벤트 |
| [[훅|훅]] | Before/after 훅 |
| [[커맨드|커맨드]] | CQRS 패턴 |
| [[커스텀 SQL|커스텀-SQL]] | 복잡한 쿼리 |
| [[예제|예제]] | 실제 사용 사례 |
| [[웹 프레임워크|웹-프레임워크]] | Axum, Actix 통합 |
| [[모범 사례|모범-사례]] | 프로덕션 가이드라인 |
이것은 삶을 지시하는 프레임워크가 아닙니다. 이것은 루틴을 제거하고 올바르게 구축할 수 있게 해주는 도구입니다.
속성
entity-derive가 지원하는 모든 속성에 대한 완전한 가이드입니다.
엔티티 레벨 속성
#[entity(...)]를 사용하여 구조체에 적용합니다:
#[derive(Entity)]
#[entity(
table = "users",
schema = "core",
sql = "full",
dialect = "postgres",
uuid = "v7",
soft_delete,
returning = "full",
error = "AppError",
events,
hooks,
commands
)]
pub struct User { /* ... */ }
빠른 참조
| 속성 | 필수 | 기본값 | 설명 |
|---|---|---|---|
table | 예 | — | 데이터베이스 테이블 이름 |
schema | 아니오 | "public" | 데이터베이스 스키마 |
sql | 아니오 | "full" | SQL 생성 레벨 |
dialect | 아니오 | "postgres" | 데이터베이스 방언 |
uuid | 아니오 | "v7" | ID 생성용 UUID 버전 |
soft_delete | 아니오 | false | 소프트 삭제 활성화 |
returning | 아니오 | "full" | RETURNING 절 모드 |
upsert(...) | 아니오 | — | INSERT ... ON CONFLICT 기반 upsert 메서드 생성 |
api(guard = "...") | 아니오 | — | 생성된 핸들러에 적용되는 FromRequestParts 가드 |
error | 아니오 | sqlx::Error | 커스텀 에러 타입 |
events | 아니오 | false | 생명주기 이벤트 생성 |
hooks | 아니오 | false | 생명주기 훅 트레이트 생성 |
commands | 아니오 | false | CQRS 커맨드 패턴 활성화 |
table (필수)
데이터베이스 테이블 이름입니다.
#[entity(table = "users")] // → FROM users
#[entity(table = "user_profiles")] // → FROM user_profiles
schema (선택)
데이터베이스 스키마입니다. 기본값: "public".
#[entity(table = "users")] // → FROM public.users
#[entity(table = "users", schema = "core")] // → FROM core.users
#[entity(table = "users", schema = "auth")] // → FROM auth.users
sql (선택)
SQL 생성 레벨입니다. 기본값: "full".
| 값 | Repository Trait | PgPool 구현 | 사용 사례 |
|---|---|---|---|
"full" | 예 | 예 | 표준 CRUD 엔티티 |
"trait" | 예 | 아니오 | 커스텀 쿼리 (joins, CTE) |
"none" | 아니오 | 아니오 | DTO만, 데이터베이스 없음 |
#[entity(table = "users", sql = "full")] // 전체 자동화 (기본값)
#[entity(table = "users", sql = "trait")] // 트레이트만, SQL은 직접 구현
#[entity(table = "users", sql = "none")] // 데이터베이스 레이어 없음
dialect (선택)
SQL 생성용 데이터베이스 방언입니다. 기본값: "postgres".
| 방언 | 별칭 | 클라이언트 타입 | 상태 |
|---|---|---|---|
"postgres" | "pg", "postgresql" | sqlx::PgPool | 안정 |
"clickhouse" | "ch" | clickhouse::Client | 예정 |
"mongodb" | "mongo" | mongodb::Client | 예정 |
uuid (선택)
자동 생성되는 기본 키용 UUID 버전입니다. 기본값: "v7".
| 버전 | 메서드 | 속성 |
|---|---|---|
"v7" | Uuid::now_v7() | 시간순 정렬, 정렬 가능 (권장) |
"v4" | Uuid::new_v4() | 랜덤, 널리 호환 |
#[entity(table = "users", uuid = "v7")] // 시간순 정렬 (기본값)
#[entity(table = "sessions", uuid = "v4")] // 랜덤 UUID
UUID v7을 사용하는 이유:
- 시간순 정렬: 생성 시간별 자연스러운 정렬
- 더 나은 데이터베이스 인덱스 성능
- 조정이 필요 없음 (시퀀스와 달리)
- 분산 시스템에서 전역 고유
soft_delete (선택)
레코드를 삭제하는 대신 삭제로 표시하는 소프트 삭제를 활성화합니다.
#[derive(Entity)]
#[entity(table = "documents", soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>, // 필수 필드
}
생성되는 메서드:
delete()— DELETE 대신deleted_at = NOW()설정hard_delete()— 레코드를 영구적으로 삭제restore()—deleted_at = NULL설정find_by_id()/list()— 자동으로 삭제된 레코드 필터링find_by_id_with_deleted()/list_with_deleted()— 삭제된 레코드 포함
returning (선택)
INSERT/UPDATE 후 어떤 데이터를 가져올지 제어합니다. 기본값: "full".
| 모드 | SQL 절 | 사용 사례 |
|---|---|---|
"full" | RETURNING * | DB 생성 값 포함 모든 필드 가져오기 (기본값) |
"id" | RETURNING id | 삽입 확인, 미리 빌드된 엔티티 반환 |
"none" | (RETURNING 없음) | Fire-and-forget, 가장 빠른 옵션 |
"col1, col2" | RETURNING col1, col2 | 특정 컬럼 반환 |
#[entity(table = "logs", returning = "none")] // 가장 빠름
#[entity(table = "users", returning = "full")] // DB 생성 값 가져오기
#[entity(table = "events", returning = "id, created_at")] // 커스텀 컬럼
events(outbox) (선택)
트랜잭셔널 아웃박스를 통한 내구성 있는 이벤트 전달. events만으로는 enum만 생성되며, streams의 NOTIFY는 fire-and-forget입니다. events(outbox)는 생성된 모든 쓰기가 직렬화된 이벤트를 같은 트랜잭션 안에서 entity_outbox 테이블에 삽입하게 하고, OutboxDrainer 런타임(entity-core, outbox 피처)이 FOR UPDATE SKIP LOCKED, 지수 백오프, max_attempts 초과 시 보류 처리로 행을 전달합니다. At-least-once — 핸들러는 멱등해야 합니다. streams와 조합 가능합니다.
#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "orders", events(outbox), migrations)]
pub struct Order { /* ... */ }
sqlx::query(Order::MIGRATION_OUTBOX).execute(&pool).await?;
struct Notifier;
#[async_trait::async_trait]
impl entity_core::outbox::OutboxHandler for Notifier {
type Error = anyhow::Error;
async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error> {
deliver(&row.entity, &row.payload).await
}
}
entity_core::outbox::OutboxDrainer::new(pool, Notifier).run().await;
upsert(...) (선택)
INSERT ... ON CONFLICT 기반의 upsert 리포지토리 메서드를 생성합니다.
#[derive(Entity)]
#[entity(table = "users", upsert(conflict = "email"))]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, response)]
#[column(unique)]
pub email: String,
#[field(create, update, response)]
pub name: String,
}
| 옵션 | 필수 | 기본값 | 설명 |
|---|---|---|---|
conflict | 예 | — | 쉼표로 구분된 충돌 대상 컬럼 |
action | 아니오 | "update" | "update" (DO UPDATE) 또는 "nothing" (DO NOTHING) |
생성되는 코드:
action = "update"→async fn upsert(&self, dto: CreateUserRequest) -> Result<User, Error>— 충돌 컬럼을 제외한 모든 컬럼을 덮어쓰고(DO UPDATE SET col = EXCLUDED.col) 저장된 행을 반환action = "nothing"→async fn upsert(&self, dto: CreateUserRequest) -> Result<Option<User>, Error>— 기존 행을 그대로 유지;None은 충돌 행이 이미 존재했음을 의미
컴파일 타임 검증:
- 충돌 컬럼이 존재해야 하며 고유성 보장이 필요 (
#[id],#[column(unique)]또는 일치하는unique_index(...)) returning = "full"필요 (기본값)action = "update"는 충돌 컬럼 외에 갱신 가능한 컬럼이 최소 하나 필요
streams가 활성화되면 upsert는 반환하는 모든 행에 대해 Created 알림을 발행합니다.
api(guard = "...") (선택)
생성된 핸들러에 인증을 강제합니다. security = "..."는 OpenAPI 문서화만 수행하지만, guard는 axum FromRequestParts extractor를 모든 생성된 CRUD·커맨드 핸들러의 선행 인자로 주입하여, 추출 실패 시 핸들러 본문 실행 전에 요청을 거부합니다.
pub struct RequireAuth;
impl<S: Send + Sync> FromRequestParts<S> for RequireAuth {
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
parts.headers.contains_key("authorization")
.then_some(Self)
.ok_or(StatusCode::UNAUTHORIZED)
}
}
#[derive(Entity)]
#[entity(table = "users", api(tag = "Users", handlers, guard = "RequireAuth", guard(list = "none")))]
pub struct User { /* ... */ }
작업별 오버라이드: guard(create = "Admin", list = "none", ...) — 사용 가능한 작업은 create, get, update, delete, list, commands; 리터럴 "none"은 가드를 비활성화합니다. public = [...]에 나열된 커맨드는 가드를 받지 않습니다.
error (선택)
리포지토리용 커스텀 에러 타입입니다. 기본값: sqlx::Error.
#[derive(Debug)]
pub enum AppError {
Database(sqlx::Error),
NotFound,
Validation(String),
}
impl std::error::Error for AppError {}
impl std::fmt::Display for AppError { /* ... */ }
// 필수: sqlx::Error에서 변환
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Database(err)
}
}
#[derive(Entity)]
#[entity(table = "users", error = "AppError")]
pub struct User { /* ... */ }
// 생성된 리포지토리는 AppError를 사용:
// impl UserRepository for PgPool {
// type Error = AppError;
// ...
// }
events (선택)
생명주기 이벤트 열거형을 생성합니다. 자세한 내용은 [[이벤트|이벤트]]를 참조하세요.
#[entity(table = "orders", events)]
생성됨:
pub enum OrderEvent {
Created(Order),
Updated { id: Uuid, changes: UpdateOrderRequest },
Deleted(Uuid),
}
hooks (선택)
생명주기 훅 트레이트를 생성합니다. 자세한 내용은 [[훅|훅]]을 참조하세요.
#[entity(table = "users", hooks)]
생성됨:
#[async_trait]
pub trait UserHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
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>;
}
commands (선택)
CQRS 커맨드 패턴을 활성화합니다. 자세한 내용은 [[커맨드|커맨드]]를 참조하세요.
#[entity(table = "users", commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
필드 레벨 속성
개별 필드에 적용됩니다.
#[id]
기본 키 필드를 표시합니다.
동작:
- UUID 자동 생성 (기본 v7,
uuid속성으로 설정 가능) ResponseDTO에 항상 포함CreateRequest와UpdateRequest에서 제외
#[id]
pub id: Uuid,
#[auto]
자동 생성 필드를 표시합니다 (timestamps, sequences).
동작:
From<CreateRequest>에서Default::default()획득CreateRequest와UpdateRequest에서 제외#[field(response)]로Response에 포함 가능
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[field(...)]
DTO 포함을 제어합니다. 여러 옵션을 조합하세요:
#[field(create)] // CreateRequest에만
#[field(update)] // UpdateRequest에만
#[field(response)] // Response에만
#[field(create, response)] // Create와 Response에
#[field(create, update, response)] // 세 가지 모두에
#[field(skip)] // 모든 DTO에서 제외
create
CreateRequest DTO에 필드를 포함합니다.
#[field(create)]
pub email: String,
// 생성됨:
pub struct CreateUserRequest {
pub email: String,
}
update
UpdateRequest DTO에 필드를 포함합니다.
중요: 비선택적 필드는 부분 업데이트를 위해 자동으로 Option<T>로 래핑됩니다.
#[field(update)]
pub name: String, // Option 아님
// 생성됨:
pub struct UpdateUserRequest {
pub name: Option<String>, // 자동으로 래핑됨
}
response
Response DTO에 필드를 포함합니다.
#[field(response)]
pub email: String,
// 생성됨:
pub struct UserResponse {
pub id: Uuid, // 항상 포함 (#[id] 있음)
pub email: String, // 포함됨
}
skip
모든 DTO에서 필드를 제외합니다. 민감한 데이터에 사용하세요.
#[field(skip)]
pub password_hash: String,
중요: skip은 다른 모든 필드 옵션을 무시합니다. 필드는 다음에만 존재합니다:
- 원본 엔티티 구조체
Row구조체 (데이터베이스 읽기용)Insertable구조체 (데이터베이스 쓰기용)
#[column(pg_enum = "...")]
ValueObject Postgres enum을 DDL 생성에 연결합니다.
#[derive(ValueObject, Debug, Clone, Serialize, Deserialize)]
#[value_object(pg_type = "order_status", sqlx)]
pub enum OrderStatus { Pending, Shipped, Delivered }
#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[column(pg_enum = "order_status")]
pub status: OrderStatus,
}
for ddl in Order::MIGRATION_TYPES {
sqlx::query(ddl).execute(&pool).await?;
}
sqlx::query(Order::MIGRATION_UP).execute(&pool).await?;
- DDL 컬럼 타입을 설정 (지정하지 않으면 enum 필드는 TEXT로 폴백)
- enum의 멱등
PG_CREATE_TYPEDDL을{Entity}::MIGRATION_TYPES에 등록 —MIGRATION_UP전에 실행하세요 - 선언한 이름은 컴파일 타임에 enum의
PG_TYPE상수와 대조 검증되며, 불일치 시 빌드가 실패합니다 ValueObject의 선택적sqlx플래그는sqlx::Type/Encode/Decode구현을 생성합니다; 이미sqlx::Type을 derive한다면 생략하세요
#[owner]
행 단위 소유권 스코핑. 소유 주체의 id를 담는 컬럼을 표시하면, 리포지토리에 스코프 메서드가 생성됩니다 — 다른 소유자의 행 존재 여부를 노출하지 않으며 soft_delete를 준수합니다.
#[derive(Entity)]
#[entity(table = "orders")]
pub struct Order {
#[id]
pub id: Uuid,
#[owner]
pub user_id: Uuid,
#[field(create, update, response)]
pub note: String,
}
let mine = pool.list_by_owner(user_id, 20, 0).await?;
let order = pool.find_by_id_scoped(id, user_id).await?;
let updated = pool.update_scoped(id, user_id, patch).await?;
let removed = pool.delete_scoped(id, user_id).await?;
생성 메서드: find_by_id_scoped, list_by_owner, update_scoped(update 필드가 있을 때; 소유자의 행이 아니면 None), delete_scoped. #[owner] 필드는 최대 하나이며 #[id]와의 조합은 컴파일 타임에 거부됩니다.
#[filter] / #[filter(...)]
쿼리 필터 필드를 생성합니다. 자세한 내용은 [[필터링|필터링]]을 참조하세요.
#[filter] // 정확히 일치: WHERE field = $n
#[filter(eq)] // 위와 동일
#[filter(like)] // 패턴 일치: WHERE field ILIKE $n
#[filter(range)] // 범위: WHERE field >= $n AND field <= $m
#[belongs_to(Entity)]
외래 키 관계입니다. 자세한 내용은 [[관계|관계]]를 참조하세요.
#[belongs_to(User)]
pub user_id: Uuid,
생성됨: 리포지토리에 find_user() 메서드.
#[has_many(Entity)]
일대다 관계 (엔티티 레벨)입니다. 자세한 내용은 [[관계|관계]]를 참조하세요.
#[has_many(Post)]
pub struct User { /* ... */ }
생성됨: 리포지토리에 find_posts() 메서드.
#[projection(Name: fields)]
부분 뷰 구조체를 생성합니다 (엔티티 레벨).
#[projection(Public: id, name, avatar)]
#[projection(Admin: id, name, email, role)]
pub struct User { /* ... */ }
생성됨:
UserPublic { id, name, avatar }UserAdmin { id, name, email, role }From<User>구현find_by_id_public(),find_by_id_admin()메서드
커맨드 속성
#[command(...)]를 사용하여 엔티티 레벨에 적용됩니다.
빠른 참조
| 구문 | 효과 |
|---|---|
#[command(Name)] | 모든 #[field(create)] 필드 사용 |
#[command(Name: field1, field2)] | 지정된 필드만 사용 (requires_id 추가) |
#[command(Name, requires_id)] | ID 필드 추가, 다른 필드 없음 |
#[command(Name, source = "create")] | 명시적으로 create 필드 사용 (기본값) |
#[command(Name, source = "update")] | update 필드 사용 (선택적, requires_id 추가) |
#[command(Name, source = "none")] | payload 필드 없음 |
#[command(Name, payload = "Type")] | 커스텀 payload 구조체 사용 |
#[command(Name, result = "Type")] | 커스텀 결과 타입 사용 |
#[command(Name, kind = "create")] | 힌트: 엔티티 생성 (기본값) |
#[command(Name, kind = "update")] | 힌트: 엔티티 수정 |
#[command(Name, kind = "delete")] | 힌트: 엔티티 삭제 (() 반환) |
#[command(Name, kind = "custom")] | 힌트: 커스텀 작업 |
자세한 내용은 [[커맨드|커맨드]]를 참조하세요.
전체 예제
#[derive(Entity)]
#[entity(
table = "posts",
schema = "blog",
sql = "full",
dialect = "postgres",
uuid = "v7",
soft_delete,
returning = "full",
events,
hooks,
commands
)]
#[has_many(Comment)]
#[projection(Summary: id, title, author_id, created_at)]
#[command(Publish)]
#[command(Archive, requires_id)]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[filter(like)]
pub title: String,
#[field(create, update, response)]
pub content: String,
#[field(create, response)]
#[belongs_to(User)]
#[filter]
pub author_id: Uuid,
#[field(update, response)]
pub published: bool,
#[field(response)]
#[filter(range)]
pub view_count: i64,
#[field(skip)]
pub moderation_notes: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
#[auto]
#[field(response)]
#[filter(range)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
결정 매트릭스
| 하고 싶은 것… | 속성 |
|---|---|
| 기본 키 자동 생성 | #[id] |
| 랜덤 UUID 사용 | 엔티티에 uuid = "v4" |
| 시간순 UUID 사용 | uuid = "v7" (기본값) |
| POST 본문에서 받기 | #[field(create)] |
| PATCH 본문에서 받기 | #[field(update)] |
| API 응답에서 반환 | #[field(response)] |
| 받고 반환 | #[field(create, update, response)] |
| 모든 API에서 숨기기 | #[field(skip)] |
| timestamp 자동 생성 | #[auto] + #[field(response)] |
| 읽기 전용 (DB 관리) | #[field(response)]만 |
| 쓰기 전용 (반환 없음) | #[field(create)]만 |
| 커스텀 SQL 쿼리 | sql = "trait" |
| DTO만, DB 없음 | sql = "none" |
| 레코드 소프트 삭제 | 엔티티에 soft_delete |
| 커스텀 에러 타입 | 엔티티에 error = "MyError" |
| 정확한 값으로 필터 | 필드에 #[filter] |
| 패턴으로 필터 | 필드에 #[filter(like)] |
| 범위로 필터 | 필드에 #[filter(range)] |
| 엔티티 변경 추적 | 엔티티에 events |
| 생명주기에서 코드 실행 | 엔티티에 hooks |
| 도메인 커맨드 사용 | 엔티티에 commands + #[command(...)] |
| 관계 정의 | #[belongs_to(Entity)] 또는 #[has_many(Entity)] |
| 부분 엔티티 뷰 | #[projection(Name: fields)] |
Update DTO: PATCH 시맨틱
생성된 업데이트는 진정한 부분 패치입니다. SET 절은 실제로 존재하는 필드로 런타임에 구성되며 생략된 필드는 변경되지 않습니다. Nullable 컬럼은 entity_core::serde_helpers::double_option을 통한 이중 Option을 사용합니다(None = 유지, Some(None) = NULL 설정, Some(Some(v)) = v 설정).
// {} → nothing changes
// {"nickname": null} → nickname = NULL
// {"nickname": "neo"} → nickname = 'neo'
let patch: UpdateProfileRequest = serde_json::from_str(body)?;
let profile = pool.update(id, patch).await?;
migrations(...) 옵션
단순 플래그 외에도 migrations는 DDL 옵션을 받습니다: touch_updated_at(공유 plpgsql 함수 + 테이블별 BEFORE UPDATE 트리거로 updated_at 자동 갱신; updated_at 필드 필요, 컴파일 타임 검증), audit(entity_audit_log 테이블 + to_jsonb(OLD/NEW) 디프를 기록하는 트리거), extensions = "pg_trgm, pgcrypto"(멱등 CREATE EXTENSION). 새 상수: MIGRATION_TRIGGERS(MIGRATION_UP 후 실행), MIGRATION_EXTENSIONS(전에 실행).
#[entity(table = "articles", migrations(touch_updated_at, audit, extensions = "pg_trgm"))]
pub struct Article { /* ... */ }
for ddl in Article::MIGRATION_EXTENSIONS { sqlx::query(ddl).execute(&pool).await?; }
sqlx::query(Article::MIGRATION_UP).execute(&pool).await?;
for ddl in Article::MIGRATION_TRIGGERS { sqlx::query(ddl).execute(&pool).await?; }
#[version]
낙관적 잠금. 정수 컬럼(i16/i32/i64)을 표시하면 Update DTO에 필수 expected_version 필드가 추가되고, 생성된 UPDATE는 컬럼을 증가시키며 저장된 버전이 일치할 때만 적용됩니다 — 오래된 쓰기는 최신 데이터를 덮어쓰는 대신 충돌 오류로 실패합니다. DDL 기본값은 INTEGER NOT NULL DEFAULT 0. 일반·스코프·트랜잭션 업데이트 모두에 적용됩니다.
#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
#[id] pub id: Uuid,
#[field(create, update, response)] pub note: String,
#[version] #[field(response)] #[auto] pub version: i32,
}
let patch = UpdateOrderRequest { note: Some("v2".into()), expected_version: order.version };
let updated = pool.update(order.id, patch).await?;
typed_constraints (선택)
매크로는 자신이 만든 모든 제약을 알고 있습니다. 이 플래그를 켜면 생성된 쓰기 메서드가 위반된 제약 이름(unique 컬럼, belongs_to 외래 키, 컬럼 check, unique_index 이름)을 해석해 원시 드라이버 오류 대신 entity_core::ConstraintError { kind, constraint, field }를 반환합니다. From<ConstraintError>를 구현한 커스텀 error 타입이 필요하며, 플래그가 없으면 동작은 그대로입니다.
#[entity(table = "users", typed_constraints, error = "AppError")]
pub struct User {
#[id] pub id: Uuid,
#[field(create, response)] #[column(unique)] pub email: String,
}
match pool.create(dto).await {
Err(AppError::Constraint(v)) if v.field == Some("email") => conflict_409(),
other => other?,
}
#[embed(prefix = "...", fields(...))]
값 객체를 접두사가 붙은 평면 스칼라 컬럼으로 펼칩니다. DDL, Row 구조체, CRUD SQL, 동적 PATCH는 price_amount_cents / price_currency로 동작하고, DTO와 엔티티는 구조체 자체를 갖습니다. 선언된 형태는 컴파일 타임에 실제 구조체와 대조 디스트럭처링됩니다 — 이름 변경/타입 변경/누락/초과 필드는 빌드 실패. Option<T> 부모는 아직 미지원.
pub struct Money { pub amount_cents: i64, pub currency: String }
#[derive(Entity)]
#[entity(table = "products", migrations)]
pub struct Product {
#[id] pub id: Uuid,
#[field(create, update, response)]
#[embed(prefix = "price_", fields(amount_cents: i64, currency: String))]
pub price: Money,
}
garde 피처 (검증 백엔드)
생성된 DTO에 validator의 유지보수되는 대안으로 garde::Validate를 활성화합니다. 필드의 #[validate(...)] 규칙(length, range, email, url, pattern)은 garde 문법으로 변환되고, 제약 없는 필드는 garde(skip)을 받으며, Update DTO의 Option 래퍼는 inner(...)로 내부 값을 검증합니다. 두 피처가 모두 켜져 있으면 validate가 우선합니다.
#[field(create, update, response)]
#[validate(length(min = 3, max = 8))]
pub name: String,
let dto: CreateUserRequest = serde_json::from_str(body)?;
garde::Validate::validate(&dto)?;
constraint(...) (선택, typed_constraints와 함께)
매크로가 유추할 수 없는 제약을 선언합니다 — 자연 키에 대한 외래 키, 커스텀 이름의 CHECK 제약, 수동 마이그레이션의 인덱스 — 위반이 선언된 필드와 함께 ConstraintError로 해석되도록 합니다. 종류: unique, foreign_key, check. 커스텀 항목은 같은 이름의 파생 항목보다 우선합니다. typed_constraints가 필요합니다.
#[entity(
table = "orders",
typed_constraints,
constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"),
constraint(name = "orders_window_check", kind = "check"),
)]
트랜잭션 upsert
transactions와 upsert(...)가 모두 켜져 있으면 {Entity}TransactionRepo 어댑터가 풀 메서드와 동일한 SQL 시맨틱의 upsert를 트랜잭션 핸들에서 제공합니다 — upsert가 인접 문장과 원자성을 공유해야 하는 플로우용입니다.
let mut tx = pool.begin().await?;
sqlx::query("UPDATE users SET username = NULL WHERE ...").execute(&mut *tx).await?;
let user = UserTransactionRepo::new(&mut tx).upsert(dto).await?;
tx.commit().await?;
예제
일반적인 사용 사례를 위한 실용적인 예제입니다.
사용자 관리
인증 필드가 있는 클래식 사용자 엔티티:
use entity_derive::Entity;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Entity)]
#[entity(table = "users", schema = "auth")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub username: String,
#[field(create, update, response)]
pub email: String,
#[field(create, update)] // 받지만 절대 반환하지 않음
pub password_hash: String,
#[field(response)]
pub email_verified: bool,
#[field(update, response)]
pub avatar_url: Option<String>,
#[field(response)]
pub role: String,
#[field(skip)] // 내부 감사 필드
pub last_login_ip: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
사용법:
// 사용자 생성
let request = CreateUserRequest {
username: "john_doe".into(),
email: "john@example.com".into(),
password_hash: hash_password("secret123"),
};
let user = pool.create(request).await?;
// 사용자 업데이트
let update = UpdateUserRequest {
avatar_url: Some(Some("https://cdn.example.com/avatar.jpg".into())),
..Default::default()
};
let user = pool.update(user.id, update).await?;
// Response는 안전함 - password_hash 없음, last_login_ip 없음
let response = UserResponse::from(&user);
블로그 시스템
작성자 관계가 있는 게시물:
#[derive(Entity)]
#[entity(table = "posts", schema = "blog")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(create, update, response)]
pub slug: String,
#[field(create, update, response)]
pub content: String,
#[field(create, update, response)]
pub excerpt: Option<String>,
#[field(create, response)] // 한 번 설정, 작성자 변경 불가
pub author_id: Uuid,
#[field(update, response)]
pub published: bool,
#[field(update, response)]
pub published_at: Option<DateTime<Utc>>,
#[field(response)] // 읽기 전용, 트리거로 관리
pub view_count: i64,
#[field(skip)] // 내부 검수
pub moderation_status: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
다대다 관계의 카테고리:
#[derive(Entity)]
#[entity(table = "categories", schema = "blog")]
pub struct Category {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub slug: String,
#[field(create, update, response)]
pub description: Option<String>,
#[field(response)]
pub post_count: i64, // 계산된 필드
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
E-Commerce
제품 카탈로그:
#[derive(Entity)]
#[entity(table = "products", schema = "catalog")]
pub struct Product {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub sku: String,
#[field(create, update, response)]
pub description: Option<String>,
#[field(create, update, response)]
pub price_cents: i64,
#[field(create, update, response)]
pub currency: String,
#[field(update, response)]
pub stock_quantity: i32,
#[field(update, response)]
pub is_active: bool,
#[field(create, response)]
pub category_id: Uuid,
#[field(skip)] // 내부 원가 추적
pub cost_cents: i64,
#[field(skip)] // 공급업체 정보
pub supplier_id: Option<Uuid>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
상태 추적이 있는 주문:
#[derive(Entity)]
#[entity(table = "orders", schema = "sales")]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, response)]
pub customer_id: Uuid,
#[field(response)]
pub order_number: String, // DB 시퀀스로 생성
#[field(update, response)]
pub status: String,
#[field(create, response)]
pub total_cents: i64,
#[field(create, response)]
pub currency: String,
#[field(create, update, response)]
pub shipping_address: String,
#[field(update, response)]
pub tracking_number: Option<String>,
#[field(skip)] // 결제 프로세서 데이터
pub payment_intent_id: Option<String>,
#[field(skip)] // 내부 메모
pub admin_notes: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
멀티테넌트 SaaS
조직 범위의 엔티티:
#[derive(Entity)]
#[entity(table = "organizations", schema = "tenants")]
pub struct Organization {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, response)]
pub slug: String, // 생성 후 불변
#[field(update, response)]
pub plan: String,
#[field(response)]
pub member_count: i32,
#[field(skip)] // 결제 정보
pub stripe_customer_id: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
#[derive(Entity)]
#[entity(table = "projects", schema = "tenants")]
pub struct Project {
#[id]
pub id: Uuid,
#[field(create, response)] // 한 번 설정
pub organization_id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub description: Option<String>,
#[field(update, response)]
pub archived: bool,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
DTO만 (데이터베이스 없음)
영속성 없는 API 계약용:
#[derive(Entity)]
#[entity(table = "webhooks", sql = "none")]
pub struct WebhookPayload {
#[id]
pub id: Uuid,
#[field(create, response)]
pub event_type: String,
#[field(create, response)]
pub payload: String,
#[field(create, response)]
pub timestamp: DateTime<Utc>,
#[field(response)]
pub signature: String,
}
생성되는 것:
CreateWebhookPayloadRequestWebhookPayloadResponseFrom구현
리포지토리 없음, SQL 없음, Row/Insertable 구조체 없음.
커스텀 쿼리만
표준 CRUD가 충분하지 않을 때:
#[derive(Entity)]
#[entity(table = "analytics_events", schema = "analytics", sql = "trait")]
pub struct AnalyticsEvent {
#[id]
pub id: Uuid,
#[field(create, response)]
pub event_name: String,
#[field(create, response)]
pub user_id: Option<Uuid>,
#[field(create, response)]
pub properties: serde_json::Value,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
// 커스텀 쿼리를 직접 구현:
impl AnalyticsEventRepository for PgPool {
type Error = sqlx::Error;
async fn create(&self, dto: CreateAnalyticsEventRequest) -> Result<AnalyticsEvent, Self::Error> {
// 배치 삽입, 파티션 테이블 등
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<AnalyticsEvent>, Self::Error> {
// 시간 기반 파티셔닝을 사용한 쿼리
}
// CRUD를 넘어선 커스텀 메서드:
async fn aggregate_by_event(&self, start: DateTime<Utc>, end: DateTime<Utc>)
-> Result<Vec<EventAggregate>, Self::Error> {
// 복잡한 집계 쿼리
}
}
대량 작업
모든 리포지토리에 배치 프리미티브가 생성됩니다: find_by_ids(WHERE id = ANY($1), 단일 왕복), 원자적 create_many(하나의 트랜잭션, 한 행이라도 실패하면 전체 롤백), soft delete를 인지하는 delete_many(실제 영향받은 행 수 반환). 이벤트 전달(streams 또는 outbox)이 활성화되면 같은 트랜잭션 안에서 행별 이벤트가 발행됩니다.
let posts: Vec<Post> = pool.find_by_ids(ids).await?;
let created: Vec<Post> = pool.create_many(dtos).await?;
let removed: u64 = pool.delete_many(stale_ids).await?;
필터링
엔티티 필터링을 위한 타입 안전 쿼리 구조체를 생성합니다. 필터링은 컴파일 타임 안전성으로 페이지네이션, 검색, 범위 쿼리를 가능하게 합니다.
빠른 시작
#[derive(Entity)]
#[entity(table = "products")]
pub struct Product {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[filter]
pub name: String,
#[field(create, update, response)]
#[filter(like)]
pub description: String,
#[field(create, update, response)]
#[filter(range)]
pub price: i64,
#[field(create, response)]
#[filter]
pub category_id: Uuid,
#[field(response)]
#[auto]
#[filter(range)]
pub created_at: DateTime<Utc>,
}
생성되는 코드
쿼리 구조체
/// Product 엔티티 필터링을 위한 쿼리 파라미터.
#[derive(Debug, Clone, Default)]
pub struct ProductQuery {
/// name 정확히 일치 필터.
pub name: Option<String>,
/// description 패턴 필터 (ILIKE).
pub description: Option<String>,
/// 최소 가격 필터.
pub price_from: Option<i64>,
/// 최대 가격 필터.
pub price_to: Option<i64>,
/// category_id 정확히 일치 필터.
pub category_id: Option<Uuid>,
/// 최소 created_at 필터.
pub created_at_from: Option<DateTime<Utc>>,
/// 최대 created_at 필터.
pub created_at_to: Option<DateTime<Utc>>,
/// 최대 결과 수.
pub limit: Option<i64>,
/// 건너뛸 결과 수.
pub offset: Option<i64>,
}
리포지토리 메서드
#[async_trait]
pub trait ProductRepository: Send + Sync {
// ... 표준 CRUD 메서드
/// 필터로 제품 쿼리.
async fn query(&self, query: ProductQuery) -> Result<Vec<Product>, Self::Error>;
}
생성되는 SQL
SELECT id, name, description, price, category_id, created_at
FROM products
WHERE ($1 IS NULL OR name = $1)
AND ($2 IS NULL OR description ILIKE $2)
AND ($3 IS NULL OR price >= $3)
AND ($4 IS NULL OR price <= $4)
AND ($5 IS NULL OR category_id = $5)
AND ($6 IS NULL OR created_at >= $6)
AND ($7 IS NULL OR created_at <= $7)
ORDER BY created_at DESC
LIMIT $8 OFFSET $9
필터 타입
정확히 일치 (#[filter] 또는 #[filter(eq)])
필드가 제공된 값과 같은 레코드를 필터링합니다.
#[filter]
pub status: String,
#[filter(eq)] // 위와 동일
pub category_id: Uuid,
생성됨:
pub status: Option<String>,
pub category_id: Option<Uuid>,
SQL:
WHERE status = $1
AND category_id = $2
패턴 일치 (#[filter(like)])
대소문자를 구분하지 않는 패턴 매칭(ILIKE)을 사용하여 필터링합니다.
#[filter(like)]
pub name: String,
#[filter(like)]
pub description: String,
생성됨:
pub name: Option<String>,
pub description: Option<String>,
SQL:
WHERE name ILIKE $1
AND description ILIKE $2
사용법:
let query = ProductQuery {
name: Some("%widget%".into()), // "widget" 포함
description: Some("premium%".into()), // "premium"으로 시작
..Default::default()
};
범위 필터 (#[filter(range)])
범위 내에서 필터링합니다 (경계 포함).
#[filter(range)]
pub price: i64,
#[filter(range)]
pub created_at: DateTime<Utc>,
생성됨:
pub price_from: Option<i64>,
pub price_to: Option<i64>,
pub created_at_from: Option<DateTime<Utc>>,
pub created_at_to: Option<DateTime<Utc>>,
SQL:
WHERE price >= $1 AND price <= $2
AND created_at >= $3 AND created_at <= $4
사용 예제
기본 필터링
// 카테고리별 제품 찾기
let query = ProductQuery {
category_id: Some(electronics_category_id),
..Default::default()
};
let products = repo.query(query).await?;
페이지네이션
// 2페이지 가져오기 (페이지당 20개)
let query = ProductQuery {
limit: Some(20),
offset: Some(20),
..Default::default()
};
let products = repo.query(query).await?;
결합된 필터
// 저렴한 전자제품 검색
let query = ProductQuery {
category_id: Some(electronics_category_id),
price_from: Some(0),
price_to: Some(10000), // $100.00
name: Some("%phone%".into()),
limit: Some(50),
..Default::default()
};
let products = repo.query(query).await?;
날짜 범위
// 이번 달에 생성된 제품 가져오기
let now = Utc::now();
let month_start = now.with_day(1).unwrap().date_naive().and_hms_opt(0, 0, 0).unwrap();
let query = ProductQuery {
created_at_from: Some(month_start.and_utc()),
created_at_to: Some(now),
..Default::default()
};
let products = repo.query(query).await?;
API 엔드포인트 통합
use axum::{extract::Query, Json};
#[derive(Deserialize)]
pub struct ProductQueryParams {
pub name: Option<String>,
pub category_id: Option<Uuid>,
pub min_price: Option<i64>,
pub max_price: Option<i64>,
pub page: Option<i64>,
pub per_page: Option<i64>,
}
async fn list_products(
Query(params): Query<ProductQueryParams>,
pool: Extension<PgPool>,
) -> Result<Json<Vec<ProductResponse>>, AppError> {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(20).min(100);
let query = ProductQuery {
name: params.name.map(|n| format!("%{}%", n)),
category_id: params.category_id,
price_from: params.min_price,
price_to: params.max_price,
limit: Some(per_page),
offset: Some((page - 1) * per_page),
..Default::default()
};
let products = pool.query(query).await?;
let responses: Vec<_> = products.into_iter().map(ProductResponse::from).collect();
Ok(Json(responses))
}
소프트 삭제와 함께 사용
soft_delete가 활성화되면 쿼리가 자동으로 삭제된 레코드를 제외합니다:
#[derive(Entity)]
#[entity(table = "documents", soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
#[filter(like)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
}
생성되는 SQL:
SELECT * FROM documents
WHERE deleted_at IS NULL
AND ($1 IS NULL OR title ILIKE $1)
LIMIT $2 OFFSET $3
삭제된 항목 포함을 위한 추가 메서드:
async fn query_with_deleted(&self, query: DocumentQuery) -> Result<Vec<Document>, Self::Error>;
커스텀 쿼리 확장
복잡한 쿼리의 경우 sql = "trait"를 사용하고 커스텀 필터링을 구현하세요:
#[derive(Entity)]
#[entity(table = "products", sql = "trait")]
pub struct Product { /* ... */ }
pub trait ProductQueryExt {
async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error>;
async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error>;
}
#[async_trait]
impl ProductQueryExt for PgPool {
async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error> {
let rows: Vec<ProductRow> = sqlx::query_as(
r#"
SELECT * FROM products
WHERE to_tsvector('english', name || ' ' || description)
@@ plainto_tsquery('english', $1)
ORDER BY ts_rank(to_tsvector('english', name || ' ' || description),
plainto_tsquery('english', $1)) DESC
LIMIT $2
"#
)
.bind(term)
.bind(limit)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Product::from).collect())
}
async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error> {
let rows: Vec<ProductRow> = sqlx::query_as(
"SELECT * FROM products WHERE tags && $1"
)
.bind(tags)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Product::from).collect())
}
}
모범 사례
- 기본 페이지네이션 — 대량 결과 방지를 위해 항상 합리적인 제한 적용
- 패턴 검증 — SQL 문제 방지를 위해 LIKE 패턴 정화
- 필터 컬럼 인덱싱 — 자주 필터링되는 필드에 데이터베이스 인덱스 생성
- 구체적인 필터 사용 — 가능하면 패턴 일치보다 정확한 일치 선호
- 정렬과 결합 — 쿼리 구조체에 정렬 필드 추가 고려
참고
- [[속성|속성]] — 전체 속성 참조
- [[커스텀-SQL|커스텀 SQL]] — 복잡한 커스텀 쿼리
- [[관계|관계]] — 관계를 사용한 필터링
정렬과 keyset 페이지네이션
정렬 가능한 컬럼에 #[sort]를 표시하세요. Query 구조체는 화이트리스트 셀렉터 {Entity}SortField(컬럼당 Asc/Desc 변형, JSON은 snake_case)를 얻어 사용자 입력이 SQL을 주입할 수 없습니다. 모든 리포지토리에는 list_after keyset 페이지네이션도 생성됩니다. 기본 UUIDv7 id에서는 id 순 순회가 시간순으로 안정적이며 OFFSET과 달리 깊은 페이지에서도 성능이 유지됩니다.
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[sort]
#[filter(like)]
pub title: String,
#[field(create, response)]
#[sort]
pub views: i64,
}
let query = PostQuery {
sort: Some(PostSortField::ViewsDesc),
limit: Some(20),
..Default::default()
};
let top: Vec<Post> = pool.query(query).await?;
let page: Vec<Post> = pool.list_after(None, 20).await?;
let next: Vec<Post> = pool.list_after(page.last().map(|p| p.id), 20).await?;
트라이그램 검색
텍스트 컬럼의 #[filter(search)]는 퍼지 부분 문자열 필터(col ILIKE '%' || $n || '%', 검색어는 바인딩됨)를 추가합니다. migrations가 켜져 있으면 해당 gin_trgm_ops 인덱스가 MIGRATION_UP에 포함되고 pg_trgm이 MIGRATION_EXTENSIONS에 자동 추가됩니다. 컴파일 타임 검사: 필드는 String이어야 합니다.
#[field(create, update, response)]
#[filter(search)]
pub title: String,
let hits = pool.query(ArticleQuery { title: Some("rust".into()), ..Default::default() }).await?;
관계
#[belongs_to]와 #[has_many]를 사용하여 엔티티 간 관계를 정의합니다. 관계는 리포지토리에서 타입 안전 탐색 메서드를 생성합니다.
빠른 시작
// 부모 엔티티
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
}
// 자식 엔티티
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
pub user_id: Uuid,
#[field(create, update, response)]
pub title: String,
}
생성되는 코드
User의 #[has_many(Post)]에 대해
#[async_trait]
impl UserRepository for PgPool {
// ... 표준 CRUD 메서드
/// 이 사용자에게 속한 모든 게시물 찾기.
async fn find_posts(&self, user_id: Uuid) -> Result<Vec<Post>, Self::Error> {
let rows: Vec<PostRow> = sqlx::query_as(
"SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC"
)
.bind(&user_id)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
Post의 #[belongs_to(User)]에 대해
#[async_trait]
impl PostRepository for PgPool {
// ... 표준 CRUD 메서드
/// 이 게시물이 속한 사용자 찾기.
async fn find_user(&self, id: Uuid) -> Result<Option<User>, Self::Error> {
// 먼저 게시물을 가져와서 user_id 찾기
let post = self.find_by_id(id).await?;
if let Some(post) = post {
let row: Option<UserRow> = sqlx::query_as(
"SELECT * FROM users WHERE id = $1"
)
.bind(&post.user_id)
.fetch_optional(self)
.await?;
Ok(row.map(User::from))
} else {
Ok(None)
}
}
}
관계 타입
belongs_to (다대일)
자식 엔티티가 외래 키를 통해 부모를 참조합니다.
#[derive(Entity)]
#[entity(table = "comments")]
pub struct Comment {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(Post)]
pub post_id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
pub author_id: Uuid,
#[field(create, response)]
pub content: String,
}
생성되는 메서드:
find_post(comment_id)→Option<Post>find_author(comment_id)→Option<User>(참고: 메서드 이름은_id없이 필드 이름에서 파생)
has_many (일대다)
부모 엔티티가 여러 자식을 가집니다.
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
#[has_many(Comment)]
pub struct User {
#[id]
pub id: Uuid,
// ...
}
생성되는 메서드:
find_posts(user_id)→Vec<Post>find_comments(user_id)→Vec<Comment>
사용 예제
관련 데이터 로딩
// 사용자와 게시물 가져오기
async fn get_user_with_posts(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<(User, Vec<Post>)>, sqlx::Error> {
let user = pool.find_by_id(user_id).await?;
if let Some(user) = user {
let posts = pool.find_posts(user_id).await?;
Ok(Some((user, posts)))
} else {
Ok(None)
}
}
// 게시물과 작성자 가져오기
async fn get_post_with_author(
pool: &PgPool,
post_id: Uuid,
) -> Result<Option<(Post, User)>, sqlx::Error> {
let post = pool.find_by_id(post_id).await?;
if let Some(post) = post {
let user = pool.find_user(post.id).await?;
if let Some(user) = user {
return Ok(Some((post, user)));
}
}
Ok(None)
}
Response DTO 빌드
#[derive(Serialize)]
pub struct PostWithAuthor {
#[serde(flatten)]
pub post: PostResponse,
pub author: UserResponse,
}
async fn get_posts_with_authors(
pool: &PgPool,
limit: i64,
) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
let posts = pool.list(limit, 0).await?;
let mut results = Vec::with_capacity(posts.len());
for post in posts {
if let Some(user) = pool.find_user(post.id).await? {
results.push(PostWithAuthor {
post: PostResponse::from(&post),
author: UserResponse::from(&user),
});
}
}
Ok(results)
}
API로 중첩 로딩
use axum::{extract::Path, Json};
#[derive(Serialize)]
pub struct UserProfile {
pub user: UserResponse,
pub posts: Vec<PostResponse>,
pub post_count: usize,
}
async fn get_user_profile(
Path(user_id): Path<Uuid>,
pool: Extension<PgPool>,
) -> Result<Json<UserProfile>, AppError> {
let user = pool.find_by_id(user_id).await?
.ok_or(AppError::NotFound)?;
let posts = pool.find_posts(user_id).await?;
Ok(Json(UserProfile {
user: UserResponse::from(&user),
post_count: posts.len(),
posts: posts.into_iter().map(PostResponse::from).collect(),
}))
}
다중 관계
엔티티는 여러 관계를 가질 수 있습니다:
#[derive(Entity)]
#[entity(table = "organizations")]
#[has_many(User)]
#[has_many(Project)]
#[has_many(Team)]
pub struct Organization {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
}
#[derive(Entity)]
#[entity(table = "projects")]
pub struct Project {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(Organization)]
pub organization_id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
pub owner_id: Uuid,
#[field(create, update, response)]
pub name: String,
}
Organization에 대해 생성됨:
find_users(org_id)find_projects(org_id)find_teams(org_id)
Project에 대해 생성됨:
find_organization(project_id)find_owner(project_id)
sql = “trait“로 커스텀 JOIN
즉시 로딩이 있는 복잡한 쿼리의 경우 커스텀 SQL을 사용하세요:
#[derive(Entity)]
#[entity(table = "posts", sql = "trait")]
pub struct Post { /* ... */ }
pub struct PostWithRelations {
pub post: Post,
pub author: User,
pub comments: Vec<Comment>,
}
pub trait PostRepositoryExt {
async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error>;
async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error>;
}
#[async_trait]
impl PostRepositoryExt for PgPool {
async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error> {
// JOIN이 있는 단일 쿼리
let row = sqlx::query_as::<_, (PostRow, UserRow)>(
r#"
SELECT p.*, u.*
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1
"#
)
.bind(&id)
.fetch_optional(self)
.await?;
if let Some((post_row, user_row)) = row {
let comments: Vec<CommentRow> = sqlx::query_as(
"SELECT * FROM comments WHERE post_id = $1 ORDER BY created_at"
)
.bind(&id)
.fetch_all(self)
.await?;
Ok(Some(PostWithRelations {
post: Post::from(post_row),
author: User::from(user_row),
comments: comments.into_iter().map(Comment::from).collect(),
}))
} else {
Ok(None)
}
}
async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error> {
let rows = sqlx::query_as::<_, (PostRow, UserRow)>(
r#"
SELECT p.*, u.*
FROM posts p
JOIN users u ON u.id = p.user_id
ORDER BY p.created_at DESC
LIMIT $1
"#
)
.bind(limit)
.fetch_all(self)
.await?;
Ok(rows.into_iter()
.map(|(p, u)| (Post::from(p), User::from(u)))
.collect())
}
}
필터링과 함께 사용
관계를 쿼리 필터링과 결합하세요:
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
#[filter] // user_id로 필터링 활성화
pub user_id: Uuid,
#[field(create, update, response)]
#[filter(like)]
pub title: String,
#[field(response)]
#[auto]
#[filter(range)]
pub created_at: DateTime<Utc>,
}
사용법:
// 제목 필터로 특정 사용자의 게시물 가져오기
let query = PostQuery {
user_id: Some(user_id),
title: Some("%rust%".into()),
limit: Some(20),
..Default::default()
};
let posts = pool.query(query).await?;
모범 사례
- N+1 쿼리 방지 — 여러 관련 엔티티를 가져올 때 즉시 로딩용 JOIN 사용
- 페이지네이션 사용 — 항상
has_many결과 제한 - 데이터 접근 패턴 고려 — 외래 키 컬럼에 인덱스 추가
- 적절한 경우 캐시 — 자주 접근하는 관련 데이터 캐시
- 프로젝션 사용 — 관련 엔티티에 필요한 필드만 가져오기
참고
- [[필터링|필터링]] — 쿼리 필터링
- [[커스텀-SQL|커스텀 SQL]] — 복잡한 JOIN과 쿼리
- [[모범-사례|모범 사례]] — 성능 팁
has_many through (다대다)
through로 정션 테이블을 선언하면 리포지토리는 JOIN 기반 조회와 연결 관리를 얻습니다. migrations는 MIGRATION_JUNCTIONS(두 외래 키에 대한 복합 기본 키, 양쪽 ON DELETE CASCADE)를 생성합니다.
#[derive(Entity)]
#[entity(table = "teams", migrations)]
#[has_many(User, through = "team_members")]
pub struct Team { /* ... */ }
for ddl in Team::MIGRATION_JUNCTIONS {
sqlx::query(ddl).execute(&pool).await?;
}
pool.add_user(team_id, user_id).await?;
let members: Vec<User> = pool.find_users(team_id).await?;
let linked = pool.has_user(team_id, user_id).await?;
let removed = pool.remove_user(team_id, user_id).await?;
생성 메서드: find_users(INNER JOIN), add_user(멱등, ON CONFLICT DO NOTHING), remove_user(연결이 없으면 false), has_user(SELECT EXISTS).
이벤트
엔티티 생명주기 변경에 대한 도메인 이벤트를 생성합니다. 이벤트는 감사 로깅, 이벤트 소싱, 메시지 큐 통합을 가능하게 합니다.
빠른 시작
#[derive(Entity)]
#[entity(table = "orders", events)]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, response)]
pub customer_id: Uuid,
#[field(create, update, response)]
pub status: String,
#[field(create, response)]
pub total_cents: i64,
#[field(response)]
#[auto]
pub created_at: DateTime<Utc>,
}
생성되는 코드
events 속성은 이벤트 열거형을 생성합니다:
/// entity-derive에 의해 생성됨
#[derive(Debug, Clone)]
pub enum OrderEvent {
/// 엔티티가 생성됨.
Created(Order),
/// 엔티티가 업데이트됨.
Updated {
id: Uuid,
changes: UpdateOrderRequest,
},
/// 엔티티가 삭제됨.
Deleted(Uuid),
}
사용 예제
기본 이벤트 발행
use async_trait::async_trait;
#[async_trait]
pub trait EventBus: Send + Sync {
async fn publish<E: Send + Sync>(&self, event: E);
}
async fn create_order(
repo: &impl OrderRepository,
bus: &impl EventBus,
dto: CreateOrderRequest,
) -> Result<Order, sqlx::Error> {
let order = repo.create(dto).await?;
// 성공적인 생성 후 이벤트 발행
bus.publish(OrderEvent::Created(order.clone())).await;
Ok(order)
}
async fn update_order(
repo: &impl OrderRepository,
bus: &impl EventBus,
id: Uuid,
dto: UpdateOrderRequest,
) -> Result<Order, sqlx::Error> {
let order = repo.update(id, dto.clone()).await?;
bus.publish(OrderEvent::Updated { id, changes: dto }).await;
Ok(order)
}
async fn delete_order(
repo: &impl OrderRepository,
bus: &impl EventBus,
id: Uuid,
) -> Result<bool, sqlx::Error> {
let deleted = repo.delete(id).await?;
if deleted {
bus.publish(OrderEvent::Deleted(id)).await;
}
Ok(deleted)
}
감사 로깅
struct AuditLogger {
pool: PgPool,
}
#[async_trait]
impl EventHandler<OrderEvent> for AuditLogger {
async fn handle(&self, event: OrderEvent) {
let (action, entity_id, details) = match &event {
OrderEvent::Created(order) => (
"created",
order.id,
serde_json::to_string(order).unwrap(),
),
OrderEvent::Updated { id, changes } => (
"updated",
*id,
serde_json::to_string(changes).unwrap(),
),
OrderEvent::Deleted(id) => (
"deleted",
*id,
String::new(),
),
};
sqlx::query(
"INSERT INTO audit_log (entity_type, entity_id, action, details, created_at)
VALUES ('order', $1, $2, $3, NOW())"
)
.bind(entity_id)
.bind(action)
.bind(details)
.execute(&self.pool)
.await
.ok();
}
}
메시지 큐 통합
use rdkafka::producer::FutureProducer;
struct KafkaEventBus {
producer: FutureProducer,
topic: String,
}
#[async_trait]
impl EventBus for KafkaEventBus {
async fn publish<E: Serialize + Send + Sync>(&self, event: E) {
let payload = serde_json::to_vec(&event).unwrap();
self.producer
.send(
FutureRecord::to(&self.topic)
.payload(&payload)
.key(&Uuid::new_v4().to_string()),
Duration::from_secs(5),
)
.await
.ok();
}
}
이벤트 소싱 패턴
struct OrderAggregate {
events: Vec<OrderEvent>,
current_state: Option<Order>,
}
impl OrderAggregate {
fn apply(&mut self, event: OrderEvent) {
match &event {
OrderEvent::Created(order) => {
self.current_state = Some(order.clone());
}
OrderEvent::Updated { changes, .. } => {
if let Some(ref mut order) = self.current_state {
if let Some(status) = &changes.status {
order.status = status.clone();
}
}
}
OrderEvent::Deleted(_) => {
self.current_state = None;
}
}
self.events.push(event);
}
fn replay(events: Vec<OrderEvent>) -> Self {
let mut aggregate = Self {
events: Vec::new(),
current_state: None,
};
for event in events {
aggregate.apply(event);
}
aggregate
}
}
소프트 삭제와 함께 사용
soft_delete가 활성화되면 추가 이벤트가 생성됩니다:
#[derive(Entity)]
#[entity(table = "documents", events, soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
}
생성됨:
pub enum DocumentEvent {
Created(Document),
Updated { id: Uuid, changes: UpdateDocumentRequest },
Deleted(Uuid), // 소프트 삭제
Restored(Uuid), // 소프트 삭제에서 복원
HardDeleted(Uuid), // 영구 삭제
}
모범 사례
- 커밋 후 발행 — 데이터베이스 트랜잭션이 성공한 후에만 이벤트 발행
- 멱등성 핸들러 — 이벤트 핸들러는 at-least-once 전달을 위해 멱등성이어야 함
- 컨텍스트 포함 — 메타데이터 추가 고려 (user_id, timestamp, correlation_id)
- 비동기 처리 — 무거운 이벤트 처리에는 백그라운드 워커 사용
- Dead letter queue — 실패한 이벤트를 우아하게 처리
훅과 결합
이벤트와 훅은 잘 함께 작동합니다:
#[derive(Entity)]
#[entity(table = "orders", events, hooks)]
pub struct Order { /* ... */ }
struct OrderService {
repo: PgPool,
bus: EventBus,
}
#[async_trait]
impl OrderHooks for OrderService {
type Error = AppError;
async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
// 훅에서 이벤트 발행
self.bus.publish(OrderEvent::Created(entity.clone())).await;
Ok(())
}
async fn after_update(&self, entity: &Order) -> Result<(), Self::Error> {
// 여기서도 이벤트를 발행할 수 있음
Ok(())
}
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
self.bus.publish(OrderEvent::Deleted(*id)).await;
Ok(())
}
}
참고
- [[훅|훅]] — 생명주기 이벤트에서 커스텀 로직 실행
- [[커맨드|커맨드]] — 커맨드 이벤트를 포함한 CQRS 패턴
- [[모범-사례|모범 사례]] — 프로덕션 팁
스트림
Postgres LISTEN/NOTIFY를 사용하여 엔티티 변경사항을 실시간으로 구독합니다. 스트림을 통해 실시간 대시보드, 즉시 알림, 캐시 무효화, 이벤트 기반 아키텍처를 구현할 수 있습니다.
빠른 시작
#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "orders", events, streams)]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub status: String,
#[field(create, response)]
pub customer_id: Uuid,
}
요구사항:
- 엔티티는
Serialize와Deserialize를 구현해야 합니다 (JSON 페이로드용) events와streams속성이 모두 필요합니다- Cargo.toml에서
streams기능을 활성화하세요
[dependencies]
entity-derive = { version = "0.3", features = ["postgres", "streams"] }
serde = { version = "1", features = ["derive"] }
생성되는 코드
streams 속성은 다음을 생성합니다:
채널 상수
impl Order {
/// Postgres NOTIFY 채널 이름.
pub const CHANNEL: &'static str = "entity_orders";
}
구독자 구조체
/// 실시간 Order 변경 구독자.
pub struct OrderSubscriber {
listener: PgListener,
}
impl OrderSubscriber {
/// 연결하고 채널을 구독합니다.
pub async fn new(pool: &PgPool) -> Result<Self, sqlx::Error>;
/// 다음 이벤트를 대기합니다 (블로킹).
pub async fn recv(&mut self) -> Result<OrderEvent, StreamError<sqlx::Error>>;
/// 블로킹 없이 이벤트를 확인합니다.
pub async fn try_recv(&mut self) -> Result<Option<OrderEvent>, StreamError<sqlx::Error>>;
}
자동 알림
CRUD 작업은 자동으로 이벤트를 발행합니다:
// 생성된 create() 메서드 내부:
async fn create(&self, dto: CreateOrderRequest) -> Result<Order, Self::Error> {
let order = /* insert */;
// 자동 생성된 알림
let event = OrderEvent::created(order.clone());
let payload = serde_json::to_string(&event)?;
sqlx::query("SELECT pg_notify($1, $2)")
.bind(Order::CHANNEL)
.bind(&payload)
.execute(self)
.await?;
Ok(order)
}
사용 예제
기본 구독
use entity_derive::StreamError;
async fn watch_orders(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
let mut subscriber = OrderSubscriber::new(pool).await?;
loop {
match subscriber.recv().await {
Ok(event) => {
match event {
OrderEvent::Created(order) => {
println!("새 주문: {}", order.id);
}
OrderEvent::Updated { old, new } => {
println!("주문 {} 업데이트: {} -> {}", new.id, old.status, new.status);
}
OrderEvent::HardDeleted { id } => {
println!("주문 {} 삭제됨", id);
}
_ => {}
}
}
Err(StreamError::Database(e)) => {
eprintln!("데이터베이스 오류: {}", e);
break;
}
Err(StreamError::Deserialize(e)) => {
eprintln!("잘못된 이벤트 페이로드: {}", e);
}
}
}
Ok(())
}
실시간 대시보드 (Axum WebSocket)
use axum::{
extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}},
response::IntoResponse,
};
async fn ws_handler(
ws: WebSocketUpgrade,
State(pool): State<PgPool>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, pool))
}
async fn handle_socket(mut socket: WebSocket, pool: PgPool) {
let mut subscriber = match OrderSubscriber::new(&pool).await {
Ok(s) => s,
Err(_) => return,
};
loop {
match subscriber.recv().await {
Ok(event) => {
let json = serde_json::to_string(&event).unwrap();
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
Err(_) => break,
}
}
}
캐시 무효화
struct CacheInvalidator {
cache: Redis,
pool: PgPool,
}
impl CacheInvalidator {
async fn run(&self) -> Result<(), StreamError<sqlx::Error>> {
let mut subscriber = OrderSubscriber::new(&self.pool).await
.map_err(StreamError::Database)?;
loop {
let event = subscriber.recv().await?;
let key = format!("order:{}", event.entity_id());
match event {
OrderEvent::Created(_) | OrderEvent::Updated { .. } => {
self.cache.del(&key).await.ok();
}
OrderEvent::HardDeleted { id } | OrderEvent::SoftDeleted { id } => {
self.cache.del(&format!("order:{}", id)).await.ok();
}
_ => {}
}
}
}
}
Graceful Shutdown이 있는 백그라운드 워커
use tokio::sync::watch;
async fn notification_worker(
pool: PgPool,
mut shutdown: watch::Receiver<bool>,
) {
let mut subscriber = OrderSubscriber::new(&pool).await.unwrap();
loop {
tokio::select! {
result = subscriber.recv() => {
match result {
Ok(event) => process_event(event).await,
Err(e) => {
eprintln!("스트림 오류: {:?}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
_ = shutdown.changed() => {
println!("알림 워커 종료 중");
break;
}
}
}
}
오류 처리
use entity_derive::StreamError;
match subscriber.recv().await {
Ok(event) => { /* 처리 */ }
Err(StreamError::Database(sqlx_error)) => {
// 연결 끊김, 쿼리 실패 등
// 구독자는 다음 recv()에서 자동 재연결됩니다
}
Err(StreamError::Deserialize(message)) => {
// 잘못된 JSON 페이로드
// 로그하고 계속 - 루프를 중단하지 마세요
}
}
아키텍처
CRUD 작업 (create/update/delete)
│
▼
pg_notify(channel, event_json)
│
▼
Postgres NOTIFY
│
┌────┴────┐
▼ ▼
구독자 구독자 (여러 리스너)
│ │
▼ ▼
WebSocket 캐시
대시보드 무효화기
모범 사례
- 재연결 — PgListener는 자동 재연결됩니다; 일시적 실패를 처리하도록 루프를 설계하세요
- 멱등성 — 이벤트가 여러 번 전달될 수 있습니다; 핸들러는 멱등해야 합니다
- 페이로드 크기 — 엔티티를 작게 유지하세요; 큰 페이로드는 Postgres 제한에 걸릴 수 있습니다
- 별도 풀 — 리스너용 전용 연결 풀을 사용하세요
- 모니터링 — 스트림 오류를 로그하고 이벤트 처리 지연을 추적하세요
- Graceful shutdown — 리소스를 정리하기 위해 shutdown 신호와 함께 select!를 사용하세요
소프트 삭제와 함께
soft_delete가 활성화되면 추가 이벤트를 사용할 수 있습니다:
#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "documents", events, streams, soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
}
// 이벤트에 포함:
// - DocumentEvent::SoftDeleted { id }
// - DocumentEvent::Restored { id }
// - DocumentEvent::HardDeleted { id }
참고
- [[이벤트|이벤트]] — 실시간 스트리밍 없는 이벤트 열거형
- [[훅|훅]] — 라이프사이클 이벤트에서 커스텀 로직 실행
- [[모범 사례|모범-사례]] — 프로덕션 팁
훅
엔티티 작업 전후에 커스텀 로직을 실행합니다. 훅은 검증, 정규화, 부수 효과, 권한 부여를 가능하게 합니다.
빠른 시작
#[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>;
}
커맨드와 함께 사용
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(())
}
}
참고
- [[이벤트|이벤트]] — 감사 로깅을 위한 생명주기 이벤트
- [[커맨드|커맨드]] — 커맨드 훅을 포함한 CQRS 패턴
- [[모범-사례|모범 사례]] — 프로덕션 팁
커맨드
일반적인 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?;
참고
- [[훅|훅]] — 커맨드 훅을 포함한 생명주기 훅
- [[이벤트|이벤트]] — 감사 로깅을 위한 이벤트 생성
- [[속성|속성]] — 전체 속성 참조
트랜잭션
자동 commit/rollback이 포함된 타입 안전 다중 엔티티 트랜잭션.
트랜잭션이란?
데이터베이스 트랜잭션은 여러 데이터베이스 작업을 하나의 원자적 단위로 그룹화하는 방법입니다. 이것은 다음을 의미합니다:
- 전부 아니면 전무: 모든 작업이 성공하거나, 아무것도 적용되지 않습니다
- 자동 롤백: 어떤 작업이 실패하면 이전 변경 사항이 자동으로 취소됩니다
- 데이터 일관성: 데이터베이스가 비일관 상태가 되지 않습니다
왜 트랜잭션이 필요한가요?
은행 앱을 만들고 계좌 간 송금이 필요하다고 상상해보세요:
1. 계좌 A에서 100원 차감
2. 계좌 B에 100원 추가
트랜잭션 없이, 1단계가 성공했지만 2단계가 실패하면(네트워크 오류, DB 충돌 등), 100원을 잃어버립니다! A에서 차감되었지만 B에는 추가되지 않았습니다.
트랜잭션과 함께, 2단계가 실패하면 1단계가 자동으로 롤백됩니다. 돈은 아무 일도 없었던 것처럼 계좌 A에 남아있습니다.
트랜잭션 활성화
엔티티에 transactions 속성을 추가하세요:
use entity_derive::Entity;
use uuid::Uuid;
#[derive(Entity)]
#[entity(table = "accounts", transactions)] // <- 이것을 추가
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub user_id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
무엇이 생성되나요?
#[entity(transactions)]가 있는 Account 엔티티의 경우, 매크로가 생성합니다:
1. 트랜잭션 리포지토리 어댑터
pub struct AccountTransactionRepo<'t> {
tx: &'t mut sqlx::Transaction<'static, sqlx::Postgres>,
}
일반 리포지토리와 같지만, 모든 작업이 트랜잭션 내에서 발생합니다.
2. 빌더 확장 트레잇
pub trait TransactionWithAccount<'p> {
fn with_accounts(self) -> Transaction<'p, PgPool, AccountTransactionRepo<'static>>;
}
이것은 트랜잭션 빌더에 with_accounts() 메서드를 추가합니다.
사용 가능한 메서드
트랜잭션 내에서 다음 메서드를 사용할 수 있습니다:
| 메서드 | 시그니처 | 설명 |
|---|---|---|
create | create(dto) -> Result<Entity, Error> | 새 레코드 삽입 |
find_by_id | find_by_id(id) -> Result<Option<Entity>, Error> | 기본 키로 찾기 |
update | update(id, dto) -> Result<Entity, Error> | 기존 레코드 업데이트 |
delete | delete(id) -> Result<bool, Error> | 레코드 삭제 (또는 소프트 삭제) |
list | list(limit, offset) -> Result<Vec<Entity>, Error> | 페이지네이션 목록 |
기본 예제
use entity_core::prelude::*;
async fn create_account(pool: &PgPool, user_id: Uuid) -> Result<Account, AppError> {
Transaction::new(pool) // 1. 트랜잭션 빌더 생성
.with_accounts() // 2. Account 리포지토리 추가
.run(|mut ctx| async move { // 3. 작업 실행
let account = ctx.accounts().create(CreateAccountRequest {
user_id,
balance: 0,
}).await?;
Ok(account) // 4. 결과 반환 (자동 커밋)
})
.await
}
단계별 설명:
Transaction::new(pool)- 데이터베이스 풀로 새 트랜잭션 빌더 생성.with_accounts()- 트랜잭션 컨텍스트에 Account 리포지토리 추가.run(|mut ctx| async move { ... })- 트랜잭션 내에서 작업 실행Ok(account)-Ok반환 시 트랜잭션 커밋.Err반환 시 롤백.
전체 예제: 송금
이 예제는 트랜잭션의 전체 기능을 보여줍니다:
use entity_core::prelude::*;
use uuid::Uuid;
#[derive(Debug)]
pub enum TransferError {
Database(sqlx::Error),
AccountNotFound(Uuid),
InsufficientFunds { available: i64, requested: i64 },
}
impl From<sqlx::Error> for TransferError {
fn from(e: sqlx::Error) -> Self {
TransferError::Database(e)
}
}
impl From<TransactionError<sqlx::Error>> for TransferError {
fn from(e: TransactionError<sqlx::Error>) -> Self {
TransferError::Database(e.into_inner())
}
}
/// 두 계좌 간 원자적 송금.
///
/// 어떤 단계라도 실패하면 모든 변경이 자동으로 롤백됩니다.
pub async fn transfer(
pool: &PgPool,
from_id: Uuid,
to_id: Uuid,
amount: i64,
) -> Result<(), TransferError> {
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
// 1단계: 출금 계좌 가져오기
let from = ctx.accounts()
.find_by_id(from_id)
.await?
.ok_or(TransferError::AccountNotFound(from_id))?;
// 2단계: 잔액 확인
if from.balance < amount {
return Err(TransferError::InsufficientFunds {
available: from.balance,
requested: amount,
});
}
// 3단계: 입금 계좌 가져오기
let to = ctx.accounts()
.find_by_id(to_id)
.await?
.ok_or(TransferError::AccountNotFound(to_id))?;
// 4단계: 출금 계좌에서 차감
// 이것이 성공했지만 5단계가 실패하면, 롤백됩니다
ctx.accounts().update(from_id, UpdateAccountRequest {
balance: Some(from.balance - amount),
user_id: None,
}).await?;
// 5단계: 입금 계좌에 추가
ctx.accounts().update(to_id, UpdateAccountRequest {
balance: Some(to.balance + amount),
user_id: None,
}).await?;
// 모든 작업 성공 - 트랜잭션 커밋
Ok(())
})
.await
}
다양한 시나리오에서 발생하는 일:
| 시나리오 | 결과 |
|---|---|
| 두 업데이트 모두 성공 | 트랜잭션 커밋, 송금 완료 |
| 출금 계좌 없음 | 트랜잭션 롤백 (변경 없음) |
| 잔액 부족 | 트랜잭션 롤백 (변경 없음) |
| 첫 번째 업데이트 성공, 두 번째 실패 | 트랜잭션 롤백 (첫 번째 업데이트 취소!) |
| 트랜잭션 중 네트워크 오류 | 트랜잭션 롤백 (부분 변경 없음) |
하나의 트랜잭션에서 여러 엔티티
여러 엔티티를 원자적으로 처리할 수 있습니다:
#[derive(Entity)]
#[entity(table = "accounts", transactions)]
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
#[derive(Entity)]
#[entity(table = "transfer_logs", transactions)]
pub struct TransferLog {
#[id]
pub id: Uuid,
#[field(create, response)]
pub from_account_id: Uuid,
#[field(create, response)]
pub to_account_id: Uuid,
#[field(create, response)]
pub amount: i64,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
async fn transfer_with_logging(
pool: &PgPool,
from_id: Uuid,
to_id: Uuid,
amount: i64,
) -> Result<TransferLog, AppError> {
Transaction::new(pool)
.with_accounts() // Account 리포지토리 추가
.with_transfer_logs() // TransferLog 리포지토리 추가
.run(|mut ctx| async move {
// 잔액 업데이트
let from = ctx.accounts().find_by_id(from_id).await?
.ok_or(AppError::NotFound)?;
ctx.accounts().update(from_id, UpdateAccountRequest {
balance: Some(from.balance - amount),
}).await?;
let to = ctx.accounts().find_by_id(to_id).await?
.ok_or(AppError::NotFound)?;
ctx.accounts().update(to_id, UpdateAccountRequest {
balance: Some(to.balance + amount),
}).await?;
// 로그 항목 생성 - 모두 같은 트랜잭션 내에서!
let log = ctx.transfer_logs().create(CreateTransferLogRequest {
from_account_id: from_id,
to_account_id: to_id,
amount,
}).await?;
Ok(log)
})
.await
}
로그 생성이 실패하면 두 계좌 업데이트가 모두 롤백됩니다!
오류 처리
자동 롤백
클로저에서 반환된 모든 오류는 롤백을 트리거합니다:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
ctx.accounts().update(id, dto).await?; // 성공
// 유효성 검사 실패
if amount < 0 {
return Err(AppError::InvalidAmount); // <- 롤백 트리거!
}
// 이것은 실행되지 않고, 위의 업데이트도 취소됨
ctx.accounts().update(other_id, other_dto).await?;
Ok(())
})
.await
트랜잭션 오류 유형
TransactionError 열거형이 무엇이 잘못되었는지 알려줍니다:
use entity_core::transaction::TransactionError;
let result = Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move { /* ... */ })
.await;
match result {
Ok(value) => {
println!("성공: {:?}", value);
}
Err(e) => {
if e.is_begin() {
println!("트랜잭션 시작 실패");
} else if e.is_operation() {
println!("작업 실패: {}", e);
} else if e.is_commit() {
println!("커밋 실패");
} else if e.is_rollback() {
println!("롤백 실패");
}
// 내부 데이터베이스 오류 가져오기
let db_error: sqlx::Error = e.into_inner();
}
}
소프트 삭제와 함께
트랜잭션은 soft_delete 속성을 준수합니다:
#[derive(Entity)]
#[entity(table = "documents", transactions, soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>, // soft_delete에 필수
}
async fn archive_document(pool: &PgPool, id: Uuid) -> Result<bool, AppError> {
Transaction::new(pool)
.with_documents()
.run(|mut ctx| async move {
// DELETE 대신 deleted_at = NOW() 설정
let deleted = ctx.documents().delete(id).await?;
Ok(deleted)
})
.await
}
모범 사례
1. 트랜잭션을 짧게 유지
잘못된 예: 긴 트랜잭션
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let account = ctx.accounts().find_by_id(id).await?;
// 하지 마세요: 트랜잭션 내에서 외부 API 호출
let rate = external_api.get_exchange_rate().await?; // <- 느림!
ctx.accounts().update(id, dto).await?;
Ok(())
})
.await
좋은 예: 느린 작업은 외부에서
// 트랜잭션 시작 전에 외부 데이터 가져오기
let rate = external_api.get_exchange_rate().await?;
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
ctx.accounts().update(id, UpdateAccountRequest {
balance: Some(calculate_new_balance(rate)),
}).await?;
Ok(())
})
.await
2. 단일 작업에는 트랜잭션 사용하지 않기
불필요한 예:
Transaction::new(pool)
.with_users()
.run(|mut ctx| async move {
ctx.users().find_by_id(id).await // 단일 작업!
})
.await
더 나은 방법: 일반 리포지토리 사용
pool.find_by_id(id).await // 트랜잭션 불필요
3. 모든 오류를 올바르게 처리
항상 ?로 오류를 전파하세요:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let result = ctx.accounts().update(id, dto).await;
// 하지 마세요: 오류 무시
if let Err(e) = result {
log::error!("업데이트 실패: {}", e);
// 트랜잭션이 제대로 롤백되지 않습니다!
}
// 이렇게 하세요: 오류 전파
ctx.accounts().update(id, dto).await?; // <- ? 사용
Ok(())
})
.await
일반적인 패턴
확인 후 업데이트
Transaction::new(pool)
.with_products()
.run(|mut ctx| async move {
let product = ctx.products().find_by_id(id).await?
.ok_or(AppError::NotFound)?;
if product.stock < quantity {
return Err(AppError::OutOfStock);
}
ctx.products().update(id, UpdateProductRequest {
stock: Some(product.stock - quantity),
..Default::default()
}).await?;
Ok(product)
})
.await
여러 관련 레코드 생성
Transaction::new(pool)
.with_orders()
.with_order_items()
.run(|mut ctx| async move {
// 부모 생성
let order = ctx.orders().create(CreateOrderRequest {
customer_id,
status: "pending".to_string(),
}).await?;
// 자식 생성
for item in items {
ctx.order_items().create(CreateOrderItemRequest {
order_id: order.id,
product_id: item.product_id,
quantity: item.quantity,
}).await?;
}
Ok(order)
})
.await
참고
- [[속성|속성 참조]] - 전체 속성 문서
- [[훅|라이프사이클 훅]] - 작업 전/후 코드 실행
- [[커맨드|커맨드]] - CQRS 커맨드 패턴
- [[이벤트|이벤트]] - 엔티티 변경 추적
커스텀 SQL
자동 생성된 SQL이 충분하지 않을 때 sql = "trait"를 사용하여 완전한 제어를 합니다.
커스텀 SQL을 사용해야 할 때
- JOIN — 단일 쿼리로 관련 엔티티
- CTE — 복잡한 재귀 또는 다단계 쿼리
- 전문 검색 — PostgreSQL
tsvector/tsquery - 집계 —
GROUP BY,HAVING, 윈도우 함수 - 파티션 테이블 — 시간 기반 또는 범위 파티션
- 대량 작업 — 배치 삽입/업데이트
- 소프트 삭제 — 커스텀 삭제 로직
- 낙관적 잠금 — 버전 기반 동시성 제어
기본 설정
#[derive(Entity)]
#[entity(table = "posts", schema = "blog", sql = "trait")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(create, response)]
pub author_id: Uuid,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
생성되는 것:
- 모든 DTO (
CreatePostRequest,UpdatePostRequest,PostResponse) PostRow와InsertablePostPostRepository트레이트- 모든
From구현
하지만 impl PostRepository for PgPool은 아님.
리포지토리 구현
use async_trait::async_trait;
use sqlx::PgPool;
#[async_trait]
impl PostRepository for PgPool {
type Error = sqlx::Error;
async fn create(&self, dto: CreatePostRequest) -> Result<Post, Self::Error> {
let entity = Post::from(dto);
let insertable = InsertablePost::from(&entity);
sqlx::query(
r#"
INSERT INTO blog.posts (id, title, author_id, created_at)
VALUES ($1, $2, $3, $4)
"#
)
.bind(insertable.id)
.bind(&insertable.title)
.bind(insertable.author_id)
.bind(insertable.created_at)
.execute(self)
.await?;
Ok(entity)
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<Post>, Self::Error> {
let row: Option<PostRow> = sqlx::query_as(
"SELECT id, title, author_id, created_at FROM blog.posts WHERE id = $1"
)
.bind(&id)
.fetch_optional(self)
.await?;
Ok(row.map(Post::from))
}
async fn update(&self, id: Uuid, dto: UpdatePostRequest) -> Result<Post, Self::Error> {
// 커스텀 업데이트 로직
todo!()
}
async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
let result = sqlx::query("DELETE FROM blog.posts WHERE id = $1")
.bind(&id)
.execute(self)
.await?;
Ok(result.rows_affected() > 0)
}
async fn list(&self, limit: i64, offset: i64) -> Result<Vec<Post>, Self::Error> {
let rows: Vec<PostRow> = sqlx::query_as(
"SELECT id, title, author_id, created_at FROM blog.posts ORDER BY created_at DESC LIMIT $1 OFFSET $2"
)
.bind(limit)
.bind(offset)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
예제: 작성자 JOIN이 있는 게시물
// 작성자 데이터가 있는 확장 응답
pub struct PostWithAuthor {
pub post: Post,
pub author: User,
}
// 커스텀 리포지토리 확장
pub trait PostRepositoryExt: PostRepository {
async fn find_with_author(&self, id: Uuid) -> Result<Option<PostWithAuthor>, Self::Error>;
async fn list_with_authors(&self, limit: i64, offset: i64) -> Result<Vec<PostWithAuthor>, Self::Error>;
}
#[async_trait]
impl PostRepositoryExt for PgPool {
async fn find_with_author(&self, id: Uuid) -> Result<Option<PostWithAuthor>, Self::Error> {
let row = sqlx::query_as::<_, (PostRow, UserRow)>(
r#"
SELECT
p.id, p.title, p.author_id, p.created_at,
u.id, u.username, u.email, u.created_at
FROM blog.posts p
JOIN auth.users u ON u.id = p.author_id
WHERE p.id = $1
"#
)
.bind(&id)
.fetch_optional(self)
.await?;
Ok(row.map(|(p, u)| PostWithAuthor {
post: Post::from(p),
author: User::from(u),
}))
}
async fn list_with_authors(&self, limit: i64, offset: i64) -> Result<Vec<PostWithAuthor>, Self::Error> {
// 페이지네이션이 있는 유사한 JOIN 쿼리
todo!()
}
}
예제: 전문 검색
pub trait PostSearchRepository {
async fn search(&self, query: &str, limit: i64) -> Result<Vec<Post>, sqlx::Error>;
}
#[async_trait]
impl PostSearchRepository for PgPool {
async fn search(&self, query: &str, limit: i64) -> Result<Vec<Post>, sqlx::Error> {
let rows: Vec<PostRow> = sqlx::query_as(
r#"
SELECT id, title, author_id, created_at
FROM blog.posts
WHERE to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', $1)) DESC
LIMIT $2
"#
)
.bind(query)
.bind(limit)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
예제: 소프트 삭제
#[derive(Entity)]
#[entity(table = "posts", sql = "trait")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(response)]
pub deleted_at: Option<DateTime<Utc>>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
#[async_trait]
impl PostRepository for PgPool {
// ... 다른 메서드
async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
// 하드 삭제 대신 소프트 삭제
let result = sqlx::query(
"UPDATE blog.posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
)
.bind(&id)
.execute(self)
.await?;
Ok(result.rows_affected() > 0)
}
async fn list(&self, limit: i64, offset: i64) -> Result<Vec<Post>, Self::Error> {
// 소프트 삭제된 항목 제외
let rows: Vec<PostRow> = sqlx::query_as(
r#"
SELECT id, title, deleted_at, created_at
FROM blog.posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#
)
.bind(limit)
.bind(offset)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
// 관리자용 추가 메서드
pub trait PostAdminRepository {
async fn restore(&self, id: Uuid) -> Result<bool, sqlx::Error>;
async fn hard_delete(&self, id: Uuid) -> Result<bool, sqlx::Error>;
async fn list_deleted(&self, limit: i64, offset: i64) -> Result<Vec<Post>, sqlx::Error>;
}
예제: 낙관적 잠금
#[derive(Entity)]
#[entity(table = "documents", sql = "trait")]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub content: String,
#[field(response)]
pub version: i64,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
#[derive(Debug)]
pub enum DocumentError {
Sqlx(sqlx::Error),
ConcurrentModification,
}
#[async_trait]
impl DocumentRepository for PgPool {
type Error = DocumentError;
async fn update(&self, id: Uuid, dto: UpdateDocumentRequest) -> Result<Document, Self::Error> {
// 낙관적 잠금을 위해 현재 버전 필요
let expected_version = dto.version.ok_or(DocumentError::ConcurrentModification)?;
let row: Option<DocumentRow> = sqlx::query_as(
r#"
UPDATE documents
SET content = COALESCE($1, content),
version = version + 1,
updated_at = NOW()
WHERE id = $2 AND version = $3
RETURNING id, content, version, updated_at
"#
)
.bind(&dto.content)
.bind(&id)
.bind(expected_version)
.fetch_optional(self)
.await
.map_err(DocumentError::Sqlx)?;
row.map(Document::from)
.ok_or(DocumentError::ConcurrentModification)
}
// ... 다른 메서드
}
커스텀 SQL 모범 사례
- Row 구조체로
query_as사용 — 타입 안전 매핑 - 모든 파라미터 바인딩 — 문자열 보간 절대 금지
- Row 반환, Entity로 변환 — 생성된
From구현 사용 - 대체가 아닌 확장 —
Repository와 함께 커스텀 트레이트 추가 - 실제 DB로 테스트 — 통합 테스트 필수
웹 프레임워크
entity-derive를 인기 있는 Rust 웹 프레임워크와 함께 사용하는 방법입니다.
Axum
프로젝트 구조
src/
├── main.rs
├── entities/
│ ├── mod.rs
│ └── user.rs
├── handlers/
│ ├── mod.rs
│ └── users.rs
└── routes.rs
엔티티 정의
// src/entities/user.rs
use entity_derive::Entity;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Entity, Clone)]
#[entity(table = "users", schema = "auth")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub username: String,
#[field(create, update, response)]
pub email: String,
#[field(skip)]
pub password_hash: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
핸들러
// src/handlers/users.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sqlx::PgPool;
use uuid::Uuid;
use crate::entities::user::*;
pub async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), StatusCode> {
let user = pool
.create(payload)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(UserResponse::from(&user))))
}
pub async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, StatusCode> {
let user = pool
.find_by_id(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(UserResponse::from(&user)))
}
pub async fn update_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, StatusCode> {
let user = pool
.update(id, payload)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(UserResponse::from(&user)))
}
pub async fn delete_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let deleted = pool
.delete(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if deleted {
Ok(StatusCode::NO_CONTENT)
} else {
Err(StatusCode::NOT_FOUND)
}
}
pub async fn list_users(
State(pool): State<PgPool>,
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
let users = pool
.list(100, 0)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
Ok(Json(responses))
}
라우트
// src/routes.rs
use axum::{
routing::{get, post, put, delete},
Router,
};
use sqlx::PgPool;
use crate::handlers::users;
pub fn create_router(pool: PgPool) -> Router {
Router::new()
.route("/users", post(users::create_user))
.route("/users", get(users::list_users))
.route("/users/:id", get(users::get_user))
.route("/users/:id", put(users::update_user))
.route("/users/:id", delete(users::delete_user))
.with_state(pool)
}
Main
// src/main.rs
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
mod entities;
mod handlers;
mod routes;
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create pool");
let app = routes::create_router(pool);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("Listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Actix Web
핸들러
// src/handlers/users.rs
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;
use crate::entities::user::*;
pub async fn create_user(
pool: web::Data<PgPool>,
payload: web::Json<CreateUserRequest>,
) -> impl Responder {
match pool.create(payload.into_inner()).await {
Ok(user) => HttpResponse::Created().json(UserResponse::from(&user)),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn get_user(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> impl Responder {
let id = path.into_inner();
match pool.find_by_id(id).await {
Ok(Some(user)) => HttpResponse::Ok().json(UserResponse::from(&user)),
Ok(None) => HttpResponse::NotFound().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn update_user(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
payload: web::Json<UpdateUserRequest>,
) -> impl Responder {
let id = path.into_inner();
match pool.update(id, payload.into_inner()).await {
Ok(user) => HttpResponse::Ok().json(UserResponse::from(&user)),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn delete_user(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> impl Responder {
let id = path.into_inner();
match pool.delete(id).await {
Ok(true) => HttpResponse::NoContent().finish(),
Ok(false) => HttpResponse::NotFound().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn list_users(pool: web::Data<PgPool>) -> impl Responder {
match pool.list(100, 0).await {
Ok(users) => {
let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
HttpResponse::Ok().json(responses)
}
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
라우트
// src/routes.rs
use actix_web::web;
use crate::handlers::users;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/users")
.route("", web::post().to(users::create_user))
.route("", web::get().to(users::list_users))
.route("/{id}", web::get().to(users::get_user))
.route("/{id}", web::put().to(users::update_user))
.route("/{id}", web::delete().to(users::delete_user)),
);
}
Main
// src/main.rs
use actix_web::{App, HttpServer, web};
use sqlx::postgres::PgPoolOptions;
mod entities;
mod handlers;
mod routes;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create pool");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(routes::configure)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
에러 처리
커스텀 에러 타입으로 더 나은 에러 처리:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub enum AppError {
NotFound,
Database(sqlx::Error),
Validation(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "리소스를 찾을 수 없습니다"),
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "데이터베이스 오류"),
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Database(err)
}
}
// 핸들러에서 사용:
pub async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, AppError> {
let user = pool
.find_by_id(id)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(UserResponse::from(&user)))
}
검증
validator 크레이트로 검증 추가:
use validator::Validate;
// 생성된 구조체에 Validate derive를 수동으로 추가하거나
// 리포지토리 메서드 호출 전에 검증
pub async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
// 수동 검증
if payload.username.len() < 3 {
return Err(AppError::Validation("사용자 이름이 너무 짧습니다".into()));
}
if !payload.email.contains('@') {
return Err(AppError::Validation("잘못된 이메일".into()));
}
let user = pool.create(payload).await?;
Ok((StatusCode::CREATED, Json(UserResponse::from(&user))))
}
페이지네이션
페이지네이션 헬퍼 예제:
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Pagination {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
20
}
pub async fn list_users(
State(pool): State<PgPool>,
Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<UserResponse>>, AppError> {
let users = pool
.list(pagination.limit.min(100), pagination.offset)
.await?;
let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
Ok(Json(responses))
}
모범 사례
프로덕션에서 entity-derive를 효과적으로 사용하기 위한 가이드라인입니다.
엔티티 설계
엔티티를 집중적으로 유지
데이터베이스 테이블당 하나의 엔티티. 단일 엔티티에서 복잡한 관계를 모델링하려고 하지 마세요.
// 좋음: 별도의 엔티티
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
}
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, response)]
pub author_id: Uuid, // 참조, 임베딩 아님
#[field(create, update, response)]
pub title: String,
}
// 나쁨: 관계를 임베딩하려는 시도
pub struct User {
pub id: Uuid,
pub posts: Vec<Post>, // 이렇게 하지 마세요
}
의미 있는 필드 속성 사용
각 필드의 목적에 대해 명시적으로 하세요:
// 좋음: 명확한 의도
#[field(create, response)] // 한 번 설정, 항상 표시
pub email: String,
#[field(update, response)] // 변경 가능, 항상 표시
pub display_name: Option<String>,
#[field(response)] // 읽기 전용, 다른 곳에서 계산/관리
pub post_count: i64,
#[field(skip)] // 절대 노출되지 않음
pub password_hash: String,
// 나쁨: 모든 곳에 전부
#[field(create, update, response)] // 정말 전부에 필요한가?
pub internal_id: String,
Nullable 필드에 Option 선호
데이터베이스 스키마와 일치시키세요:
// 데이터베이스: email VARCHAR NOT NULL
#[field(create, update, response)]
pub email: String,
// 데이터베이스: bio TEXT NULL
#[field(update, response)]
pub bio: Option<String>,
보안
민감한 데이터에는 항상 #[field(skip)] 사용
// 비밀번호
#[field(skip)]
pub password_hash: String,
// API 키
#[field(skip)]
pub api_key: String,
// 내부 토큰
#[field(skip)]
pub refresh_token: Option<String>,
// 응답에 포함되면 안 되는 개인정보
#[field(skip)]
pub ssn: String,
// 내부 감사 데이터
#[field(skip)]
pub created_by_ip: String,
내부와 외부 엔티티 분리
관리자 전용 데이터의 경우 별도의 엔티티를 고려하세요:
// 공개 엔티티
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(skip)]
pub admin_notes: Option<String>,
}
// 관리자 전용 엔티티 (같은 테이블, 다른 뷰)
#[derive(Entity)]
#[entity(table = "users", sql = "trait")]
pub struct AdminUser {
#[id]
pub id: Uuid,
#[field(response)]
pub name: String,
#[field(update, response)] // 이제 표시되고 편집 가능
pub admin_notes: Option<String>,
#[field(response)]
pub last_login_ip: Option<String>,
}
성능
복잡한 쿼리에는 sql = "trait" 사용
생성된 SQL과 싸우지 마세요. JOIN이나 복잡한 로직이 필요하면 직접 구현하세요:
// 단순 CRUD - 전체 생성 사용
#[entity(table = "categories", sql = "full")]
// 복잡한 쿼리 필요 - 직접 구현
#[entity(table = "posts", sql = "trait")]
배치 작업
대량 삽입의 경우 커스텀 메서드를 구현하세요:
#[entity(table = "events", sql = "trait")]
pub struct Event { /* ... */ }
pub trait EventBatchRepository {
async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error>;
}
#[async_trait]
impl EventBatchRepository for PgPool {
async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error> {
let mut tx = self.begin().await?;
for event in events {
let entity = Event::from(event);
let insertable = InsertableEvent::from(&entity);
// 트랜잭션 내에서 삽입
}
tx.commit().await?;
Ok(())
}
}
N+1 쿼리 방지
관련 엔티티를 하나씩 로딩하는 대신 JOIN을 사용하세요:
// 나쁨: N+1 쿼리
let posts = pool.list(100, 0).await?;
for post in &posts {
let author = pool.find_user_by_id(post.author_id).await?; // N개의 쿼리!
}
// 좋음: JOIN을 사용한 단일 쿼리
let posts_with_authors = pool.list_with_authors(100, 0).await?; // 1개의 쿼리
테스트
별도의 테스트 데이터베이스 사용
#[cfg(test)]
mod tests {
use sqlx::PgPool;
async fn setup_test_db() -> PgPool {
let url = std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set");
let pool = PgPool::connect(&url).await.unwrap();
// 마이그레이션 실행
sqlx::migrate!("./migrations")
.run(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn test_create_user() {
let pool = setup_test_db().await;
let request = CreateUserRequest {
username: "test_user".into(),
email: "test@example.com".into(),
};
let user = pool.create(request).await.unwrap();
assert_eq!(user.username, "test_user");
}
}
DTO 별도 테스트
#[test]
fn test_user_response_excludes_password() {
let user = User {
id: Uuid::new_v4(),
username: "test".into(),
email: "test@example.com".into(),
password_hash: "secret_hash".into(),
created_at: Utc::now(),
};
let response = UserResponse::from(&user);
// password_hash는 UserResponse에 없음
assert_eq!(response.username, "test");
// response를 통해 password_hash에 접근할 방법 없음
}
#[test]
fn test_update_request_is_partial() {
let update = UpdateUserRequest {
username: Some("new_name".into()),
email: None, // 이메일 업데이트 안 함
};
assert!(update.username.is_some());
assert!(update.email.is_none());
}
프로젝트 구성
권장 구조
src/
├── entities/ # 엔티티 정의
│ ├── mod.rs
│ ├── user.rs
│ ├── post.rs
│ └── comment.rs
├── repositories/ # 커스텀 리포지토리 확장
│ ├── mod.rs
│ └── post_search.rs
├── handlers/ # HTTP 핸들러
│ ├── mod.rs
│ ├── users.rs
│ └── posts.rs
├── services/ # 비즈니스 로직
│ ├── mod.rs
│ └── auth.rs
└── main.rs
생성된 타입 재내보내기
// src/entities/mod.rs
mod user;
mod post;
pub use user::*;
pub use post::*;
관련 엔티티 그룹화
// src/entities/auth/mod.rs
mod user;
mod session;
mod api_key;
pub use user::*;
pub use session::*;
pub use api_key::*;
흔한 실수
1. 민감한 필드에 #[field(skip)] 잊음
// 잘못됨: password_hash가 Response에 포함됨!
pub struct User {
pub password_hash: String,
}
// 올바름
#[field(skip)]
pub password_hash: String,
2. JOIN이 필요할 때 sql = "full" 사용
관련 데이터가 필요하면 sql = "trait"를 사용하고 직접 구현하세요.
3. 옵셔널 업데이트 제대로 처리 안 함
기억하세요: UpdateRequest 필드는 Option<T>입니다. 적용 전에 확인하세요:
// 생성된 UpdateUserRequest는 name에 대해 Option<String>을 가짐
// 업데이트 로직은 None(변경 없음) vs Some(변경)을 처리해야 함
4. 비즈니스 로직 중복
검증과 비즈니스 규칙은 서비스 레이어에 두세요, 핸들러가 아니라:
// 좋음: 서비스 레이어
impl UserService {
pub async fn create_user(&self, request: CreateUserRequest) -> Result<User, AppError> {
self.validate_email(&request.email)?;
self.check_username_available(&request.username).await?;
self.pool.create(request).await.map_err(Into::into)
}
}
// 나쁨: 핸들러에 로직 분산
pub async fn create_user(pool: State<PgPool>, request: Json<CreateUserRequest>) -> ... {
// 검증 여기
// 비즈니스 규칙 여기
// 리포지토리 호출 여기
// 전부 섞여있음
}
체크리스트
배포 전:
- 모든 민감한 필드에
#[field(skip)]있음 - DTO가 API 계약 기대치와 일치
- 복잡한 쿼리는
sql = "trait"사용 - 통합 테스트가 리포지토리 메서드 커버
- 에러 처리가 일관됨
- list 엔드포인트에 페이지네이션 구현됨
- 쿼리 패턴에 대한 데이터베이스 인덱스 존재