Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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