Rust ORM에서의 Active Record와 Data Mapper
Emily Parker
Product Engineer · Leapcell

ORM 아키텍처 이해하기: Sea-ORM vs. Diesel
성능과 메모리 안전성으로 찬사를 받는 Rust 프로그래밍 언어는 백엔드 개발에서 빠르게 인기를 얻고 있습니다. 애플리케이션이 복잡해짐에 따라 데이터베이스와의 효과적인 상호 작용이 매우 중요해집니다. ORM(Object-Relational Mapper)은 객체 지향 프로그래밍 패러다임과 관계형 데이터베이스 간의 간극을 메워 데이터를 보다 쉽게 관리할 수 있는 방법을 제공합니다. 그러나 모든 ORM이 동일한 것은 아니며, 그 근본적인 아키텍처 철학은 개발자가 ORM과 상호 작하는 방식에 큰 영향을 미칠 수 있습니다. 이 글에서는 Rust의 두 가지 주요 ORM인 Sea-ORM과 Diesel을 Active Record 및 Data Mapper 패턴의 렌즈를 통해 비교 분석합니다.
Active Record와 Data Mapper ORM 간의 선택은 단순한 구문적 선호도 이상입니다. 이는 애플리케이션 구조, 테스트 용이성 및 유지 관리성에 영향을 미치는 결정입니다. 각 접근 방식의 핵심 원리를 이해하면 개발자가 특정 프로젝트 요구 사항에 가장 적합한 도구를 선택하는 데 도움이 될 수 있습니다. 이 논의는 Rust 생태계 내에서 이러한 아키텍처 패턴을 명확히 설명하고, 차이점과 강점을 설명하기 위한 실용적인 통찰력과 코드 예제를 제공하는 것을 목표로 합니다.
아키텍처 철학 설명
Sea-ORM과 Diesel을 분석하기 전에 두 가지 기본적인 ORM 아키텍처 패턴인 Active Record와 Data Mapper에 대한 명확한 이해를 확립해 보겠습니다.
Active Record
Martin Fowler가 설명한 Active Record 패턴은 데이터와 동작을 단일 객체로 캡슐화합니다. 각 Active Record 객체는 데이터베이스 테이블의 행에 직접 해당합니다. 이는 영속성(저장, 업데이트, 삭제) 및 검색을 위한 메서드가 일반적으로 엔티티 모델 자체에서 직접 사용할 수 있음을 의미합니다. "도메인 논리"는 종종 이러한 모델 객체 내에 직접 상주합니다.
Active Record의 주요 특징:
- 직접 매핑: 모델 클래스와 데이터베이스 테이블 간의 강력하고 종종 1:1의 대응 관계.
- 자체 포함 엔티티: 모델 객체가 자체 영속성을 담당합니다.
- CRUD 작업의 단순성: 기본적인 데이터 작업에 대한 상용구 코드가 더 적게 발생하는 경우가 많습니다.
- 결합 가능성: 비즈니스 로직과 데이터 액세스 로직이 동일한 클래스 내에서 밀접하게 얽힐 수 있습니다.
Data Mapper
반면에 Data Mapper 패턴은 메모리 내 객체와 데이터베이스 사이에 추상화 계층을 도입합니다. 이 매퍼는 종종 별도의 클래스 또는 함수 집합으로, 객체와 데이터베이스 간, 그리고 그 반대 방향으로 데이터를 전송하는 역할을 담당합니다. 따라서 도메인 객체(엔티티)는 데이터베이스 스키마나 영속 방식에 대한 지식에서 자유롭게 됩니다.
Data Mapper의 주요 특징:
- 관심사 분리: 도메인 객체와 데이터 액세스 로직 간의 명확한 구분.
- 영속성 무시: 도메인 객체에는 데이터베이스 특정 코드가 포함되지 않습니다.
- 유연성: 복잡한 데이터베이스 스키마를 객체 모델에 매핑하고 영속성 메커니즘을 쉽게 교체할 수 있습니다.
- 복잡성 증가: 추가 매핑 계층 때문에 간단한 애플리케이션의 경우 상용구 코드가 더 많이 필요할 수 있습니다.
Sea-ORM: Active Record 접근 방식
Sea-ORM(SeaQuery 및 SeaSchema의 동일한 팀이 개발)은 Rust에서 Active Record 패턴을 구현합니다. 쿼리 빌딩 및 데이터베이스 상호 작용을 위한 유창한 API를 제공하며, Rust 구조에서 데이터베이스 스키마를 파생하는 데 중점을 둡니다.
간단한 Post 예제를 통해 설명해 보겠습니다.
// entities/src/post.rs use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "posts")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, pub content: String, pub created_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {}
Sea-ORM에서 Model 구조체는 데이터 자체를 나타내고, ActiveModel은 레코드를 생성하고 업데이트하는 데 사용되는 변경 가능한 버전입니다. DeriveEntityModel 매크로는 Active Record 작업에 필요한 상용구 코드의 대부분을 생성합니다.
Sea-ORM을 사용한 영속성:
use sea_orm::{ActiveModelTrait, DatabaseConnection, Set}; use super::entities::post; // entities/src/post.rs 가정 async fn create_and_save_post(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> { let new_post = post::ActiveModel { title: Set("My First Post".to_owned()), content: Set("This is the content of my first post.".to_owned()), created_at: Set(chrono::Utc::now()), ..Default::default() // 다른 필드에 대한 기본값 채우기 }; let post_result = new_post.insert(db).await?; println!("Created post: {:?}", post_result); Ok(()) } async fn find_and_update_post(db: &DatabaseConnection, post_id: i32) -> Result<(), sea_orm::DbErr> { let mut post: post::ActiveModel = post::Entity::find_by_id(post_id) .one(db) .await?. ok_or(sea_orm::DbErr::RecordNotFound("Post not found".to_string()))?. into_active_model(); post.title = Set("Updated Title".to_owned()); post.update(db).await?; println!("Updated post with ID {}: {:?}", post_id, post); Ok(()) }
insert 및 update 메서드가 ActiveModel 인스턴스에서 직접 호출되는 것을 주목하십시오. 이는 객체 자체가 영속성을 인지하는 Active Record 원칙을 보여줍니다. Sea-ORM은 이러한 작업을 수행하는 매우 쉬운 방법을 제공하며, 간단한 CRUD의 경우 종종 설정이 덜 필요합니다.
Sea-ORM의 사용 사례:
- 간단한 데이터베이스 스키마와 도메인 모델에 대한 직접적인 매핑을 가진 애플리케이션.
- 기본 작업에 대한 개발 속도가 최우선인 프로토타입 및 애플리케이션.
- 객체 모델의 데이터와 동작 간의 긴밀한 결합이 허용되거나 바람직한 시나리오.
Diesel: Data Mapper 접근 방식
Rust 커뮤니티에서 오랫동안 널리 사용되어 온 ORM인 Diesel은 Data Mapper 패턴을 채택합니다. Rust 구조체(도메인 모델을 나타냄)를 데이터베이스와 상호 작용하는 논리와 분리합니다. Diesel은 강력한 매크로 시스템을 통해 쿼리 빌더를 생성하고 컴파일 타임에 쿼리 정확성을 보장하는 강력한 유형 시스템을 통해 이를 달성합니다.
Diesel에서 동일한 Post 예를 고려해 보겠습니다. 먼저 table! 매크로를 사용하거나 코드 생성 도구(diesel print-schema)를 통해 데이터베이스 스키마를 정의합니다.
// src/schema.rs (diesel print-schema에 의해 생성됨) diesel::table! { posts (id) { id -> Int4, title -> Varchar, content -> Text, created_at -> Timestamptz, } }
다음으로 Post 엔티티를 나타내는 Rust 구조체를 정의합니다. 이 구조체는 "영속성 무시"입니다.
// src/models.rs use diesel::{Queryable, Insertable}; use chrono::NaiveDateTime; use super::schema::posts; #[derive(Queryable, Debug, PartialEq, Eq)] pub struct Post { pub id: i32, pub title: String, pub content: String, pub created_at: NaiveDateTime, } #[derive(Insertable)] #[diesel(table_name = posts)] pub struct NewPost { pub title: String, pub content: String, pub created_at: NaiveDateTime, }
Post 및 NewPost 구조체에는 자신을 저장하거나 업데이트하는 메서드가 포함되어 있지 않다는 점에 유의하십시오. 이러한 작업은 Diesel의 쿼리 빌더에서 처리됩니다.
Diesel을 사용한 영속성:
use diesel::prelude::*; use diesel::PgConnection; // 또는 선택한 데이터베이스 use crate::models::{Post, NewPost}; use crate::schema::posts::dsl::*; // 테이블 DSL 가져오기 use chrono::Utc; fn create_and_save_post(conn: &mut PgConnection) -> Result<Post, diesel::result::Error> { let new_post = NewPost { title: "My First Post".to_owned(), content: "This is the content of my first post.".to_owned(), created_at: Utc::now().naive_utc(), }; diesel::insert_into(posts) .values(&new_post) .get_result(conn) // 쿼리를 실행하고 삽입된 객체를 반환 } fn find_and_update_post(conn: &mut PgConnection, post_id: i32) -> Result<Post, diesel::result::Error> { let target_post = posts.filter(id.eq(post_id)); let updated_post = diesel::update(target_post) .set(title.eq("Updated Title")) .get_result(conn)?; Ok(updated_post) }
Diesel에서는 insert_into 및 update가 데이터베이스 연결을 받고 쿼리를 빌드하는 함수입니다. Post 및 NewPost 구조체는 데이터를 엄격하게 나타내며, diesel::insert_into, diesel::update, filter 함수는 객체와 데이터베이스 간을 중재하는 매퍼입니다. 이러한 명시적인 분리는 더 나은 제어력을 제공하고 복잡한 쿼리 및 매핑을 허용합니다.
Diesel의 사용 사례:
- 도메인 로직과 데이터 영속성 간의 엄격한 관심사 분리가 필요한 애플리케이션.
- 복잡한 쿼리, 사용자 지정 SQL 또는 고도로 최적화된 데이터베이스 상호 작용이 빈번한 프로젝트.
- 쿼리 정확성과 유형 안전성에 대한 강력한 컴파일 타임 보장이 필요한 애플리케이션.
- 테스트 용이성과 모듈성이 중요한 대규모의 유지 관리 가능한 코드 베이스를 구축할 때.
아키텍처 비교
| 특징 | Sea-ORM (Active Record) | Diesel (Data Mapper) |
|---|---|---|
| 철학 | 객체가 스스로 영속하는 방법을 압니다. | 별도의 매퍼가 객체-데이터베이스 번역을 처리합니다. |
| 엔티티 설계 | 상태 및 동작을 위한 Model과 ActiveModel. | 데이터에 대한 순수 구조체(영속성 무시). |
| API 스타일 | 유창하며 엔티티 인스턴스에서 메서드 체인(.insert()). | 쿼리 빌더가 테이블 DSL(diesel::insert_into())에서 작동합니다. |
| 결합 | 객체와 영속성 간의 높은 결합. | |
| 낮은 결합; 도메인 객체는 영속성에서 독립적입니다. | ||
| 상용구 코드 | 엔티티에 대한 매크로 파생 덕분에 기본 CRUD 작업이 적습니다. | 기본 CRUD 작업에 대한 상용구 코드가 더 많지만 세밀한 제어를 허용합니다. |
| 테스트 | 도메인 로직을 격리하여 테스트하기 어려울 수 있습니다. | 도메인 로직을 데이터베이스와 독립적으로 테스트하기 더 쉽습니다. |
| 쿼리 유연성 | 일반적인 쿼리에 좋으며 일반 SQL을 사용할 수 있습니다. | 매우 유연하고 강력한 쿼리 빌더이며 사용자 지정 SQL을 지원합니다. |
| 스키마 정의 | Rust 구조체에서 파생됩니다. | table! 매크로 또는 print-schema(먼저 데이터베이스)로 정의됩니다. |
| 컴파일 타임 검사 | 엔티티 유효성에 중점을 <0x97><0x8B>니다. | |
| 쿼리 정확성 및 유형에 대한 강력한 컴파일 타임 검사. |
결론
Sea-ORM과 Diesel은 각각 다른 선호도와 프로젝트 요구 사항에 최적화된 Rust에서 데이터베이스 상호 작용을 위한 강력한 솔루션을 제공합니다. Active Record 패턴을 사용하는 Sea-ORM은 기본 CRUD 작업을 단순화하여 영속성 로직을 모델에 직접 포함함으로써 신속한 개발 및 간단한 데이터 모델을 가진 애플리케이션에 탁월한 선택입니다. Data Mapper 패턴을 채택한 Diesel은 복잡한 애플리케이션과 데이터베이스 상호 작용에 대한 세밀한 제어, 광범위한 사용자 지정 쿼리 및 엄격한 관심사 분리를 요구하는 데 이상적인 강력하고 유형 안전성이 높으며 분리된 접근 방식을 제공합니다.
궁극적으로 선택은 프로젝트의 규모, 복잡성 및 팀의 아키텍처 선호도에 따라 달라집니다. Active Record 엔티티의 내재된 단순성을 원하든 Data Mapper의 강력한 추상화를 원하든 Rust의 ORM 생태계는 성능이 뛰어나고 안정적인 애플리케이션을 구축할 수 있는 성숙하고 유능한 옵션을 제공합니다.

