무엇인가요?
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 통합 |
| [[모범 사례|모범-사례]] | 프로덕션 가이드라인 |
이것은 삶을 지시하는 프레임워크가 아닙니다. 이것은 루틴을 제거하고 올바르게 구축할 수 있게 해주는 도구입니다.