프로덕션에서 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 엔드포인트에 페이지네이션 구현됨
- 쿼리 패턴에 대한 데이터베이스 인덱스 존재