ワンクリックで
repository-pattern
Rust Repository 패턴 — async trait 기반 DB 추상화, Service-Repository 의존성 역전, In-Memory Mock, 에러 변환, Hexagonal Architecture
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Rust Repository 패턴 — async trait 기반 DB 추상화, Service-Repository 의존성 역전, In-Memory Mock, 에러 변환, Hexagonal Architecture
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Spring Security 5.5.x + jjwt 0.10.7 레거시 JWT 인증 - WebSecurityConfigurerAdapter, OncePerRequestFilter, javax.servlet 환경
Spring Boot 3.x + Spring Security 6.x + jjwt 0.12.x 기반 모던 JWT 인증 패턴. SecurityFilterChain Bean, 람다 DSL, jakarta.servlet, Virtual Threads 적용
Unity 6 LTS 2D 모바일 게임용 uGUI 시스템 전문 스킬. Canvas/RectTransform/TextMeshPro, 모바일 UI 패턴(팝업·무한 스크롤·광고·IAP), 성능 최적화, UI Toolkit과의 선택 기준 포함.
아크라시아(akrasia, 자제력 없음) 학술 논쟁의 핵심 구도와 주요 연구자·문헌을 빠르게 파악할 수 있는 도메인 지식 스킬. 도덕윤리교육 전공 대학원생(석/박사)이 학위논문·KCI 투고·세미나 준비 시 고대–현대–한국 학계–도덕심리학 흐름을 한 번에 짚도록 구성. <example>사용자: "아리스토텔레스의 propeteia와 astheneia 구분을 인용하려는데 출처를 알려줘"</example> <example>사용자: "데이비슨이 의지박약을 어떻게 가능하다고 봤는지 핵심 논증을 정리해줘"</example> <example>사용자: "한국 도덕교육 학계에서 아크라시아 다룬 논문 있어?"</example>
아리스토텔레스 『니코마코스 윤리학』에서 akrasia(자제력없음)와 akolasia(무절제)의 5축 차이를 정밀하게 정리한 학위논문 자료 스킬. NE VII.4 1147b20-1148b14, VII.8 1150b29-1151a28, III.10-12 1117b23-1119b18 절별 분해와 표준 학자 해석(Bostock, Broadie-Rowe, Pakaluk, Hursthouse, Charles 등)을 포함. 도덕교육 적용을 위한 두 상태 차이의 함의 및 한국어 번역어 처리 권장안 제공. <example>사용자: "akrates와 akolastos를 prohairesis 측면에서 어떻게 구분해야 하나요?"</example> <example>사용자: "NE VII.4의 ἁπλῶς akrasia가 akolasia와 어떻게 갈라지는지 절별 분해해주세요"</example> <example>사용자: "Hursthouse의 연속체 모델을 도덕교육 적용 절에서 어떻게 활용할 수 있나요?"</example>
한국 위기 대응 자원(자살·자해·정신건강·여성·청소년·노인·다문화) 핫라인과 앱·챗봇 안전 가드 응답 패턴 종합. 꿈 해몽·정신건강 앱 등 자가 진단/감정 콘텐츠 도메인에서 위험 신호 포착 시 안전한 자원 안내 문구를 작성할 때 참조. <example>사용자: "꿈 해몽 앱에 위기 안내 문구를 어떻게 넣을까?"</example> <example>사용자: "한국에서 자살예방 핫라인 번호가 어떻게 바뀌었지?"</example> <example>사용자: "정신건강 챗봇 안전 가드 응답 템플릿을 짜줘"</example>
| name | repository-pattern |
| description | Rust Repository 패턴 — async trait 기반 DB 추상화, Service-Repository 의존성 역전, In-Memory Mock, 에러 변환, Hexagonal Architecture |
소스: https://doc.rust-lang.org/reference/items/traits.html | https://doc.rust-lang.org/std/error/trait.Error.html | https://docs.rs/thiserror/latest/thiserror/ | https://docs.rs/tokio/latest/tokio/ 검증일: 2026-06-20
주의: 이 문서는 아키텍처 패턴 가이드이며, 특정 크레이트의 API 문서가 아닙니다. Rust 1.75+ 네이티브 async fn in trait, thiserror 2.x 기준으로 작성되었습니다.
┌─────────────────────────────────┐
Inbound │ Application Core │ Outbound
Adapters │ │ Adapters
│ ┌───────────┐ ┌────────────┐ │
┌──────────┐ │ │ Service │──▶│ Repository │ │ ┌──────────────┐
│ Axum │───▶│ │ (impl) │ │ (trait) │◀─│────│ PostgresRepo │
│ Handler │ │ └───────────┘ └────────────┘ │ └──────────────┘
└──────────┘ │ │ ┌──────────────┐
│ ┌───────────┐ ┌────────────┐ │ │ InMemoryRepo │
│ │ Domain │ │ Domain │ │ │ (테스트용) │
│ │ Entity │ │ Error │ │ └──────────────┘
│ └───────────┘ └────────────┘ │
└─────────────────────────────────┘
핵심 원칙:
src/
├── domain/
│ ├── mod.rs
│ ├── entity.rs # Domain Entity (비즈니스 객체)
│ └── error.rs # Domain Error (thiserror)
├── port/
│ ├── mod.rs
│ └── repository.rs # Repository trait 정의
├── service/
│ ├── mod.rs
│ └── user_service.rs # Service 구현 (trait에만 의존)
├── adapter/
│ ├── mod.rs
│ ├── postgres_repo.rs # PostgreSQL 구현
│ └── db_model.rs # DB Row 구조체
└── main.rs
도메인 엔티티는 DB 스키마와 무관한 순수 비즈니스 객체다.
// domain/entity.rs
#[derive(Debug, Clone)]
pub struct User {
pub id: UserId,
pub email: String,
pub name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(pub i64);
#[derive(Debug, Clone)]
pub struct CreateUser {
pub email: String,
pub name: String,
}
규칙:
Serialize/Deserialize는 도메인 엔티티에 붙이지 않는다 (필요하면 DTO로 분리)// domain/error.rs
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DomainError {
#[error("not found: {resource} with id {id}")]
NotFound {
resource: &'static str,
id: String,
},
#[error("already exists: {resource} with {field} = {value}")]
Conflict {
resource: &'static str,
field: &'static str,
value: String,
},
#[error("validation error: {0}")]
Validation(String),
#[error("infrastructure error: {0}")]
Infrastructure(String),
}
impl DomainError {
pub fn not_found(resource: &'static str, id: impl Into<String>) -> Self {
Self::NotFound { resource, id: id.into() }
}
pub fn conflict(resource: &'static str, field: &'static str, value: impl Into<String>) -> Self {
Self::Conflict { resource, field, value: value.into() }
}
}
핵심: DomainError는 sqlx::Error, redis::RedisError 등 인프라 에러에 대한 #[from]을 직접 갖지 않는다. 인프라 에러는 Adapter 계층에서 DomainError로 명시적으로 변환한다.
Rust 1.75+에서는 별도 크레이트 없이 trait에 async fn을 직접 정의할 수 있다.
// port/repository.rs
use crate::domain::entity::{CreateUser, User, UserId};
use crate::domain::error::DomainError;
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &str) -> Result<Option<User>, DomainError>;
async fn find_all(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
async fn create(&self, input: &CreateUser) -> Result<User, DomainError>;
async fn update(&self, user: &User) -> Result<User, DomainError>;
async fn delete(&self, id: &UserId) -> Result<(), DomainError>;
}
주의: Rust 1.75의 네이티브 async fn in trait은 반환 Future가 자동으로
Send를 보장하지 않습니다. tokio 멀티스레드 런타임에서dyn UserRepository를 사용하려면Send바운드가 필요한데, 네이티브 방식으로는 이를 표현하기 어렵습니다. 제네릭 파라미터<R: UserRepository>로 사용하면 문제가 없습니다. trait object(dyn)가 필요한 경우 아래 대안을 참고하세요.
trait object로 사용해야 할 때는 trait_variant 크레이트(Rust 공식 팀 제공)를 활용한다.
[dependencies]
trait-variant = "0.1"
#[trait_variant::make(SendUserRepository: Send)]
pub trait UserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn create(&self, input: &CreateUser) -> Result<User, DomainError>;
// ...
}
// Send 바운드가 필요한 곳에서는 SendUserRepository를 사용
async fn some_function(repo: &dyn SendUserRepository) {
// ...
}
주의:
trait_variant는 Rust 공식 팀(rust-lang)이 관리하는 크레이트입니다(https://github.com/rust-lang/impl-trait-utils). 단, 아직 0.1.x이므로 API 변경 가능성이 있습니다.
// service/user_service.rs
use crate::domain::entity::{CreateUser, User, UserId};
use crate::domain::error::DomainError;
use crate::port::repository::UserRepository;
pub struct UserService<R: UserRepository> {
repo: R,
}
impl<R: UserRepository> UserService<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
pub async fn get_user(&self, id: UserId) -> Result<User, DomainError> {
self.repo
.find_by_id(&id)
.await?
.ok_or_else(|| DomainError::not_found("user", id.0.to_string()))
}
pub async fn create_user(&self, input: CreateUser) -> Result<User, DomainError> {
// 비즈니스 규칙: 이메일 중복 검사
if let Some(_existing) = self.repo.find_by_email(&input.email).await? {
return Err(DomainError::conflict("user", "email", &input.email));
}
self.repo.create(&input).await
}
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError> {
self.repo.find_all(limit, offset).await
}
pub async fn delete_user(&self, id: UserId) -> Result<(), DomainError> {
// 존재 여부 확인 후 삭제
let _ = self.get_user(id.clone()).await?;
self.repo.delete(&id).await
}
}
핵심: Service는 UserRepository trait의 제네릭 파라미터 R에만 의존한다. sqlx::PgPool, redis::Client 등 구체 타입을 알지 못한다.
// adapter/db_model.rs
use sqlx::FromRow;
use crate::domain::entity::{User, UserId};
#[derive(Debug, FromRow)]
pub struct UserRow {
pub id: i64,
pub email: String,
pub name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>, // DB 전용 컬럼
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>, // soft delete
}
// DB Row -> Domain Entity 변환
impl From<UserRow> for User {
fn from(row: UserRow) -> Self {
User {
id: UserId(row.id),
email: row.email,
name: row.name,
created_at: row.created_at,
}
}
}
DB Row vs Domain Entity 분리 이유:
updated_at, deleted_at) 격리#[sqlx(rename)])이 도메인을 오염하지 않음// adapter/postgres_repo.rs
use sqlx::PgPool;
use crate::domain::entity::{CreateUser, User, UserId};
use crate::domain::error::DomainError;
use crate::port::repository::UserRepository;
use super::db_model::UserRow;
pub struct PostgresUserRepository {
pool: PgPool,
}
impl PostgresUserRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(
"SELECT id, email, name, created_at, updated_at, deleted_at
FROM users WHERE id = $1 AND deleted_at IS NULL"
)
.bind(id.0)
.fetch_optional(&self.pool)
.await
.map(|opt| opt.map(User::from))
.map_err(|e| DomainError::Infrastructure(e.to_string()))
}
async fn find_by_email(&self, email: &str) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(
"SELECT id, email, name, created_at, updated_at, deleted_at
FROM users WHERE email = $1 AND deleted_at IS NULL"
)
.bind(email)
.fetch_optional(&self.pool)
.await
.map(|opt| opt.map(User::from))
.map_err(|e| DomainError::Infrastructure(e.to_string()))
}
async fn find_all(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError> {
sqlx::query_as::<_, UserRow>(
"SELECT id, email, name, created_at, updated_at, deleted_at
FROM users WHERE deleted_at IS NULL
ORDER BY id LIMIT $1 OFFSET $2"
)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map(|rows| rows.into_iter().map(User::from).collect())
.map_err(|e| DomainError::Infrastructure(e.to_string()))
}
async fn create(&self, input: &CreateUser) -> Result<User, DomainError> {
sqlx::query_as::<_, UserRow>(
"INSERT INTO users (email, name) VALUES ($1, $2)
RETURNING id, email, name, created_at, updated_at, deleted_at"
)
.bind(&input.email)
.bind(&input.name)
.fetch_one(&self.pool)
.await
.map(User::from)
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
DomainError::conflict("user", "email", &input.email)
}
_ => DomainError::Infrastructure(e.to_string()),
})
}
async fn update(&self, user: &User) -> Result<User, DomainError> {
sqlx::query_as::<_, UserRow>(
"UPDATE users SET email = $1, name = $2, updated_at = NOW()
WHERE id = $3 AND deleted_at IS NULL
RETURNING id, email, name, created_at, updated_at, deleted_at"
)
.bind(&user.email)
.bind(&user.name)
.bind(user.id.0)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Infrastructure(e.to_string()))?
.map(User::from)
.ok_or_else(|| DomainError::not_found("user", user.id.0.to_string()))
}
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
let result = sqlx::query(
"UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
)
.bind(id.0)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Infrastructure(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("user", id.0.to_string()));
}
Ok(())
}
}
상세 레퍼런스 (예제·고급 패턴·흔한 실수) →
references/REFERENCE.md