Rust 면접 준비

Rust 백엔드 개발자 면접 질문

시니어리티 수준별로 분류한 Rust 백엔드 면접 질문 15개입니다. 기본기, 실무 트레이드오프, 시니어 수준의 프로덕션 판단을 복습하는 데 활용하세요.

Rust AI 면접 시작하기신용카드가 필요하지 않습니다. 무료 세션 1회 제공.
영어 기술 면접 연습비원어민이 기술 면접 통과를 연습할 수 있는 모드입니다.

주니어 질문

1Rust의 소유권 규칙을 설명하고, 이것이 복제(cloning), 빌림(borrowing), 애플리케이션 상태 공유와 같은 실무 백엔드 설계 선택에 어떤 영향을 미치는지 설명해 주세요.

Rust에서는 각 값마다 단 하나의 소유자가 존재합니다. `Copy`를 구현하지 않은 값을 이동(move)시키면 소유권이 이전되며, 소유자가 스코프를 벗어나면 해당 값은 drop됩니다. 코드는 공유 참조를 통해 데이터를 불변으로 빌리거나, 독점 가변 참조를 통해 가변으로 빌릴 수 있습니다. 백엔드 코드에서 이는 함수가 소유권을 가져갈지, 임시로 빌릴지, 독립적인 소유 값을 얻기 위해 복제할지, 또는 공유된 변경이 필요할 때 동기화나 기타 동시성 기본 요소를 활용하여 `Arc`와 같은 핸들을 통해 수명이 긴 상태를 공유할지 결정하는 데 영향을 줍니다.

use std::sync::Arc;

struct AppState {
    service_name: String,
}

async fn handler(state: Arc<AppState>, request_id: String) -> String {
    // Borrow: no ownership transfer of the String inside state.
    let name: &str = &state.service_name;

    // Clone only if an owned independent value is needed.
    let owned_name = name.to_owned();

    format!("{owned_name}:{request_id}")
}

fn configure(state: Arc<AppState>) {
    // Cloning Arc increments the reference count; it does not clone AppState.
    let state_for_route = Arc::clone(&state);
    let _ = state_for_route;
}
AI 코치와 함께 이 질문에 답해 보세요

2Rust에서 &T와 &mut T를 사용한 빌림(borrowing)의 차이점은 무엇이며, 이러한 규칙이 API(Application Programming Interface) 및 핸들러 시그니처에 어떤 영향을 미치나요?

`&T`는 공유 참조(shared reference)입니다. 읽기 전용 접근을 허용하며, 동일한 값에 대해 여러 개의 공유 참조가 동시에 활성화될 수 있습니다. `&mut T`는 배타적 가변 참조(exclusive mutable reference)입니다. 값의 수정을 허용하지만, 활성화되어 있는 동안에는 동일한 값에 대한 다른 공유 참조나 가변 참조가 절대 존재할 수 없습니다. 이러한 규칙은 API 설계에 직접적인 영향을 미칩니다. 읽기 전용 함수는 `&T` 또는 `&str`과 같은 더 구체적인 빌림 타입을 매개변수로 받고, 데이터를 변경하는 함수는 `&mut T`를 받거나 소유권을 가져오거나 내부 가변성(interior mutability) 및 동기화 기법을 사용하도록 유도합니다. 백엔드 핸들러는 동시 다발적인 요청 간에 공유되는 애플리케이션 상태에 대해 일반 `&mut` 직접 접근을 피하고, 공유 핸들과 동기화 도구 또는 다른 소유권 패턴을 주로 사용합니다.

struct User {
    name: String,
    login_count: u64,
}

fn display_name(user: &User) -> &str {
    &user.name
}

fn record_login(user: &mut User) {
    user.login_count += 1;
}

fn main() {
    let mut user = User { name: "Ada".into(), login_count: 0 };

    let name = display_name(&user); // shared borrow for reading
    println!("{name}");

    record_login(&mut user); // exclusive mutable borrow for mutation
}
AI 코치와 함께 이 질문에 답해 보세요

3Box는 어떻게 동작하며, Box를 통한 힙 할당은 어떤 문제를 해결하나요?

`Box<T>`는 `T` 타입의 데이터를 힙에 저장하고, `Box` 핸들 자체는 고정 크기의 포인터형 값으로 유지하는 소유권을 가진 스마트 포인터(owning smart pointer)입니다. 단일 소유권을 가지며, 스코프를 벗어날 때 힙 할당을 해제(drop)합니다. `Box`는 간접 참조(indirection)를 제공하여 크기가 큰 값, 고정된 크기 정의가 필요한 재귀적 타입, 동적 크기 타입(dynamically sized values), `Box<dyn Trait>`와 같은 트레이트 객체를 다룰 때 발생하는 문제를 해결합니다. `Box`를 이동(move)시키면 힙에 저장된 값 자체를 이동하거나 복사하지 않고도 해당 할당에 대한 소유권을 이전할 수 있습니다.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
AI 코치와 함께 이 질문에 답해 보세요

4Result와 Option에 대해 설명하고, 값의 부재 및 실패할 수 있는 연산을 다루는 대표적인 관용적 방법들을 설명해 주세요.

`Option<T>`는 `Some(T)` 또는 `None`을 나타내며, 값이 존재하지 않을 수 있는 예상된 상황에 사용됩니다. `Result<T, E>`는 `Ok(T)` 또는 `Err(E)`를 나타내며, 오류 정보가 중요한 실패 가능한 연산에 사용됩니다. 관용적인 처리 방식으로는 `match`, `if let`/`let else`, 그리고 `map`, `and_then`, `ok_or`, `unwrap_or`, `unwrap_or_else`와 같은 콤비네이터가 있으며, 호환 가능한 `Option` 또는 `Result` 타입을 반환하는 함수에서 조기 전파를 수행하기 위한 `?` 연산자가 있습니다. `unwrap`과 `expect`는 불변식(invariant), 테스트, 프로토타입 또는 복구 불가능한 상황에 제한적으로 사용하는 것이 좋으며, 복구 가능한 일반적인 백엔드 에러 처리에는 권장되지 않습니다.

fn parse_port(s: Option<&str>) -> u16 {
    s.and_then(|v| v.parse::<u16>().ok())
     .unwrap_or(8080)
}

assert_eq!(parse_port(Some("3000")), 3000);
assert_eq!(parse_port(None), 8080);
AI 코치와 함께 이 질문에 답해 보세요

5`?` 연산자는 어떻게 동작하며, 서로 다른 에러 타입 간의 변환은 무엇을 통해 이루어지나요?

`?` 연산자는 실패를 전파하기 위한 축약 문법입니다. `Result`의 경우 `expr?`는 `Ok` 값을 꺼내 실행을 계속하거나, `Err`를 반환하며 현재 함수에서 조기 복귀합니다. `Option`의 경우 `Some` 값을 꺼내거나 `None`을 반환합니다. 이를 감싸는 함수는 반드시 호환 가능한 타입을 반환해야 합니다. `Result`에서는 원본 에러가 `From`/`Into` 방식의 변환을 통해 함수의 에러 타입으로 변환되므로 서로 다른 에러 타입을 조합할 수 있으며, 이러한 변환은 직접 구현하거나 `thiserror`와 같은 도우미 크레이트로 파생(derive)하여 주로 사용합니다.

use std::{fs, io, num::ParseIntError};

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}

fn read_number(path: &str) -> Result<i32, AppError> {
    let s = fs::read_to_string(path)?; // io::Error -> AppError
    let n = s.trim().parse::<i32>()?;  // ParseIntError -> AppError
    Ok(n)
}
AI 코치와 함께 이 질문에 답해 보세요

6열거형(enum)에 대한 패턴 매칭과 완결적 매칭(exhaustive matching)이 도메인 및 API의 정확성을 어떻게 향상시키는지 설명해 주세요.

열거형(enum)은 고정된 배타적 변형(variant) 집합 중 하나의 값을 모델링하며, 각 변형은 데이터를 가질 수 있습니다. `match`는 변형에 따라 분기하며 내부 데이터를 구조 분해할 수 있습니다. Rust는 와일드카드나 모든 경우를 포괄하는(catch-all) 패턴을 사용하지 않는 한, 열거형 매칭이 모든 경우를 빠짐없이 다루는지(exhaustive) 검사합니다. 이는 새로운 상태나 응답 변형이 추가되었을 때, 컴파일러가 이를 조용히 무시하는 대신 해당 변형을 매칭하는 코드에서 새로운 경우를 어떻게 처리할지 반드시 결정하도록 강제하므로 도메인 및 API의 정확성을 높여 줍니다.

enum PaymentStatus {
    Pending,
    Paid { receipt_id: String },
    Failed(String),
}

fn message(status: PaymentStatus) -> String {
    match status {
        PaymentStatus::Pending => "Payment is pending".to_string(),
        PaymentStatus::Paid { receipt_id } => format!("Paid, receipt {receipt_id}"),
        PaymentStatus::Failed(reason) => format!("Payment failed: {reason}"),
    }
}
AI 코치와 함께 이 질문에 답해 보세요

7뉴타입(newtype) 래퍼란 무엇이며, ID (Identifier), 금액, 토큰 및 비밀값(secret)에 대한 타입 안전성을 어떻게 향상시키나요?

뉴타입 래퍼는 `struct UserId(Uuid);`나 `struct AccessToken(String);`처럼 기존 표현형을 감싸는 별도의 Rust 타입으로, 주로 단일 필드를 가진 튜플 구조체 형태로 정의됩니다. 이는 `UserId`와 `OrderId`, 센트와 달러, 원시 문자열과 검증된 토큰처럼 기본 내부 타입은 같지만 의미론적으로 다른 값을 컴파일러가 혼동하지 않도록 방지하여 타입 안전성을 높여 줍니다. 또한 뉴타입은 인스턴스 생성 및 유효성 검증 규칙을 중앙에서 일관되게 관리할 수 있게 해주며, `Display`, `Debug`, `Serialize` 같은 트레이트의 구현이나 비밀값에 대한 마스킹 처리 동작을 안전하게 제어할 수 있도록 돕습니다.

use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);

struct SecretToken(String);

impl std::fmt::Debug for SecretToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SecretToken(**redacted**)")
    }
}

fn load_user(id: UserId) {
    // query by user id
}

fn example(user_id: UserId, order_id: OrderId) {
    load_user(user_id);
    // load_user(order_id); // does not compile: expected UserId, found OrderId
}
AI 코치와 함께 이 질문에 답해 보세요

미들 질문

8드롭 순서, RAII (Resource Acquisition Is Initialization) 정리 작업, 비동기 정리와 관련된 제약 사항을 포함하여 Drop 트레이트는 어떻게 동작합니까?

`Drop`은 Rust의 결정론적 정리 훅(cleanup hook)입니다. 소유된 값이 스코프를 벗어나는 등의 이유로 파괴될 때, 해당 값이 `Drop`을 구현하고 있다면 Rust는 자동으로 `drop(&mut self)` 메서드를 호출한 뒤 필드들을 드롭합니다. 이는 RAII를 지원합니다. 파일, 소켓, 락, 트랜잭션, 허가권(permit), 버퍼와 같은 리소스가 소유 값을 갖는 객체나 가드에 바인딩되어 있다가 해당 값이 드롭될 때 해제됩니다. 지역 변수는 생성된 역순으로 드롭됩니다. `Drop`을 구현한 구조체의 경우 필드가 드롭되기 전에 구조체의 `drop` 메서드가 먼저 실행되며, 필드는 선언된 순서대로 드롭됩니다. `Drop::drop`은 동기 함수이며 `async`로 지정하거나 `await`할 수 없습니다. 따라서 안전한 비동기 정리를 수행하려면 보통 명시적인 비동기 close/shutdown/flush 메서드를 두거나 다른 구조 설계를 사용해야 합니다. `Drop`은 동기적인 정리나 최선형(best-effort) 정리 작업만 수행할 수 있습니다.

struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Pair {
    first: Guard,
    second: Guard,
}

impl Drop for Pair {
    fn drop(&mut self) {
        println!("drop Pair");
    }
}

fn main() {
    let _a = Guard("a");
    let _pair = Pair {
        first: Guard("first"),
        second: Guard("second"),
    };
    let _b = Guard("b");
}
AI 코치와 함께 이 질문에 답해 보세요

9Rust API에서 수명(lifetime)이란 무엇이며, 명시적인 수명 표기가 필요한 이유는 무엇인지 설명해 주세요.

수명(lifetime)은 참조가 유효하게 유지되는 기간을 나타내며, 컴파일러가 댕글링 참조(dangling reference)를 방지할 수 있도록 돕습니다. 수명 표기(lifetime annotation)는 참조들 사이의 관계를 표현하는 컴파일 시점의 제약 조건일 뿐이며, 원본 데이터의 실제 수명을 늘리거나 런타임 동작을 추가하지는 않습니다. 단순한 함수 시그니처의 대다수는 수명 생략 규칙(lifetime elision)을 통해 컴파일러가 자동으로 추론하지만, 입력 참조와 출력 참조 간의 관계를 명확히 추론할 수 없을 때, 구조체 등의 타입에 참조를 저장할 때, 또는 API에서 제네릭 대여(borrowing) 제약 조건을 명시해야 할 때는 명시적인 수명 표기가 필요합니다.

fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() { left } else { right }
}

struct UserView<'a> {
    name: &'a str,
}

fn main() {
    let a = String::from("short");
    let b = String::from("longer");
    let result = longest(&a, &b);
    let view = UserView { name: result };
    println!("{}", view.name);
}
AI 코치와 함께 이 질문에 답해 보세요

10'static 수명은 무엇을 의미하며, 생성된 태스크 및 백엔드 API 설계와 어떻게 상호작용하나요?

`'static`이 참조자에 붙으면 문자열 리터럴이나 `static` 아이템처럼 참조되는 데이터가 프로그램 전체 수명 동안 유효함을 의미합니다. 반면 `T: 'static`과 같은 바운드는 타입 `T`의 값이 비-`'static` 빌림 참조를 포함하지 않음을 의미하므로, 임의의 수명 동안 보관될 수 있습니다. 이는 값 자체가 반드시 영원히 존속해야 하거나 드롭될 수 없음을 의미하는 것은 아닙니다. 비동기 런타임에서 생성된 태스크는 호출한 스택 프레임이 반환된 후에도 실행기가 계속 실행할 수 있어야 하므로 종종 `'static` 퓨처를 요구합니다. 백엔드 코드에서 이는 로컬 변수를 빌려오는 대신 `async move`를 사용해 소유권이 있는 데이터를 태스크로 이동시키거나, 소유된 타입을 사용하거나, `Arc`와 같은 공유 핸들을 클론하는 것을 의미합니다. API는 저장되는 콜백, 백그라운드 작업, 수명이 긴 상태를 다룰 때 `'static` 바운드를 사용해야 하지만, 단명하는 빌림 연산에 불필요하게 `'static` 바운드를 적용하는 것은 피해야 합니다.

use std::sync::Arc;

struct AppState {
    name: String,
}

fn spawn_background(state: Arc<AppState>, user_id: String) {
    tokio::spawn(async move {
        // The task owns an Arc handle and a String, so it does not borrow this function's stack.
        println!("{}:{user_id}", state.name);
    });
}
AI 코치와 함께 이 질문에 답해 보세요

11Rust가 다형성을 위해 트레이트(trait)를 어떻게 사용하는지 설명하고, impl Trait, 제네릭(generics), dyn Trait을 비교해 주세요.

Rust는 공통된 동작을 표현하기 위해 트레이트를 사용합니다. `fn f<T: Trait>(x: T)`와 같은 제네릭 바운드(generic bounds)는 일반적으로 단형성화(monomorphization)를 통한 정적 디스패치(static dispatch)를 사용합니다. 매개변수 위치의 `impl Trait`는 주로 익명 제네릭 매개변수의 축약 표기법입니다. 반환 위치의 `impl Trait`는 함수가 선택한 하나의 구체적인 반환 타입을 숨깁니다. `dyn Trait`는 `&dyn Trait` 또는 `Box<dyn Trait>`와 같이 포인터 뒤에서 사용되는 트레이트 객체(trait object)입니다. vtable을 통한 동적 디스패치(dynamic dispatch)를 사용하며, 객체 안전성(object safety) 규칙을 따르는 조건하에 런타임 타입 소거 및 서로 다른 타입의 값(heterogeneous values) 처리를 지원합니다.

trait Render { fn render(&self) -> String; }

struct Html;
struct Json;

impl Render for Html { fn render(&self) -> String { "html".into() } }
impl Render for Json { fn render(&self) -> String { "json".into() } }

fn render_generic<T: Render>(x: &T) -> String { x.render() } // static dispatch
fn render_impl_arg(x: &impl Render) -> String { x.render() } // anonymous generic
fn make_renderer() -> impl Render { Html } // one hidden concrete type
fn render_dyn(x: &dyn Render) -> String { x.render() } // dynamic dispatch

fn main() {
    let items: Vec<Box<dyn Render>> = vec![Box::new(Html), Box::new(Json)];
    for item in items { println!("{}", render_dyn(item.as_ref())); }
}
AI 코치와 함께 이 질문에 답해 보세요

12트레이트(trait)의 연관 타입(associated types)이란 무엇이며, 언제 제네릭 타입 매개변수보다 선호되나요?

연관 타입은 트레이트 내부에서 선언되고 각 구현체에서 구체화되는 이름이 지정된 타입 자리표시자(placeholder)이며, `Iterator::Item`이 대표적인 예입니다. 연관 타입은 해당 타입이 트레이트 규약의 일부이고 각 구현체마다 자연스러운 선택지가 단 하나만 존재할 때 선호됩니다. 반면 호출자가 직접 타입을 선택해야 하거나 동일한 구현체가 다양한 타입 선택에 대해 여러 구현을 제공해야 하는 경우에는 제네릭 타입 매개변수가 더 적합합니다.

trait Repository {
    type Entity;
    fn get(&self, id: u64) -> Option<Self::Entity>;
}

struct User;
struct UserRepo;

impl Repository for UserRepo {
    type Entity = User;
    fn get(&self, _id: u64) -> Option<Self::Entity> { Some(User) }
}

fn load<R: Repository>(repo: &R) -> Option<R::Entity> {
    repo.get(1)
}
AI 코치와 함께 이 질문에 답해 보세요

시니어 질문

13Rust 서비스에서 트랜잭셔널 아웃박스 패턴(transactional outbox pattern)을 어떻게 구현하시겠습니까?

Rust 서비스에서 트랜잭셔널 아웃박스 패턴을 구현하려면 도메인 데이터 업데이트와 발행할 이벤트 페이로드를 단일 데이터베이스 트랜잭션 내에서 원자적으로 기록합니다(예: `sqlx::Transaction` 사용). `outbox` 테이블에는 대상 토픽, 페이로드, 상태 또는 생성 타임스탬프를 저장합니다. 이후 백그라운드 비동기 워커(또는 Debezium과 같은 변경 데이터 캡처(CDC) 프로세스)가 대기 중인 이벤트를 폴링하거나 스트리밍하여 메시지 브로커(예: Kafka, RabbitMQ, NATS)로 발행합니다. Rust에서 폴링 기반 워커(예: `tokio::spawn` 루프)를 사용할 때 `SELECT ... FOR UPDATE SKIP LOCKED`를 사용하면 여러 서비스 인스턴스가 락 경합 없이 아웃박스 행을 동시 인출 및 디스패치할 수 있습니다. 브로커로부터 전송 확인(ack)을 받으면 워커는 아웃박스 레코드의 상태를 갱신하거나 해당 행을 삭제합니다. 네트워크 재시도로 인해 최소 한 번 전송(at-least-once delivery)이 발생할 수 있으므로, 다운스트림 컨슈머는 메시지를 멱등하게 처리하도록 설계해야 합니다.

use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;

pub async fn create_user_with_outbox(
    mut tx: Transaction<'_, Postgres>,
    username: &str,
    email: &str,
) -> Result<(), sqlx::Error> {
    let user_id = Uuid::new_v4();
    sqlx::query!(
        "INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
        user_id, username, email
    )
    .execute(&mut *tx)
    .await?;

    let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
    sqlx::query!(
        "INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
        Uuid::new_v4(),
        "user_events",
        payload
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

pub async fn run_outbox_relay(pool: PgPool) {
    loop {
        let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
        let rows = sqlx::query!(
            "SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
        )
        .fetch_all(&mut *tx)
        .await;

        if let Ok(events) = rows {
            for event in events {
                // publish_to_broker(&event.topic, &event.payload).await;
                let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
                    .execute(&mut *tx)
                    .await;
            }
            let _ = tx.commit().await;
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    }
}
AI 코치와 함께 이 질문에 답해 보세요

14데이터베이스를 업데이트하고 외부 API를 호출하는 다단계 작업을 어떻게 설계하시겠습니까?

데이터베이스 쓰기와 외부 API 호출에 걸쳐 있는 다단계 작업을 설계할 때는 2단계 커밋(2PC)에 의존하지 않고 부분 실패 및 네트워크 파티션을 처리할 수 있어야 합니다. 1. **상태 및 의도 우선 영속화**: 로컬 데이터베이스 트랜잭션 내부에서 초기 상태(예: `PENDING` 또는 `INITIATED`)와 필요한 페이로드를 먼저 기록합니다. 느린 네트워크 I/O 동안 데이터베이스 락과 커넥션 풀 리소스를 점유하지 않도록 외부 네트워크 호출을 시작하기 전에 이 트랜잭션을 커밋합니다. 2. **멱등성을 갖춘 외부 요청**: 작업의 UUID 등에서 파생된 결정론적 멱등성 키(idempotency key)를 사용하여 외부 API를 호출합니다. 이를 통해 재시도가 발생하더라도 중복 결제와 같은 현실 세계의 부수 효과가 발생하지 않도록 보장합니다. 3. **상태 전이 및 타임아웃 처리**: API 호출이 성공 응답을 반환하면 데이터베이스 레코드를 `COMPLETED`로 업데이트합니다. 영구적인 실패가 발생하면 상태를 `FAILED`로 기록하고 보상 트랜잭션(compensating action)을 실행합니다. 호출이 타임아웃되거나 네트워크 에러가 발생하면 레코드를 `INDETERMINATE` 또는 `REQUIRES_RECONCILIATION`으로 표시하고, 백그라운드 워커가 제공업체의 상태 확인 엔드포인트를 조회하거나 동일한 멱등성 키로 재시도하여 상태를 조정(reconciliation)하도록 합니다.

use uuid::Uuid;

pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
    let payment_id = Uuid::new_v4();
    let idempotency_key = format!("pay_{}", payment_id);

    // 1. Commit intent locally before making external network calls
    sqlx::query!(
        "INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
        payment_id, user_id, amount, idempotency_key
    )
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    // 2. Call external API with timeout and idempotency key
    let client = reqwest::Client::new();
    let api_result = client.post("https://api.payment.com/v1/charges")
        .header("Idempotency-Key", &idempotency_key)
        .json(&serde_json::json!({ "amount": amount }))
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await;

    // 3. Update state based on outcome; handle timeouts via reconciliation
    match api_result {
        Ok(resp) if resp.status().is_success() => {
            sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Ok(_) => {
            sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Err(_) => {
            sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}
AI 코치와 함께 이 질문에 답해 보세요

15엄격한 데이터 격리와 동적 라우팅을 갖춘 멀티테넌트(multi-tenant) Rust 백엔드를 어떻게 설계하시겠습니까?

멀티테넌트 Rust 백엔드 설계는 테넌트 식별, 데이터 격리 전략, 동적 라우팅이라는 세 가지 핵심 영역으로 나뉩니다. 1. **테넌트 추출 및 컨텍스트 전파**: Axum이나 Actix-web 같은 프레임워크의 HTTP 미들웨어를 통해 JWT 클레임, 서브도메인 또는 헤더에서 테넌트 식별자를 확인합니다. 이후 요청 확장(request extensions)에 타입 안정성을 갖춘 `TenantContext`를 생성하여 저장합니다. 핸들러는 Rust의 타입 추출기(extractor)를 사용하여 유효한 테넌트 컨텍스트 없이는 비즈니스 로직이 실행되지 않도록 강제합니다. 2. **데이터 격리 전략**: - *컬럼/행 수준 보안(RLS, Row-Level Security)을 적용한 공유 데이터베이스*: 모든 테넌트가 `tenant_id` 컬럼을 가진 테이블을 공유합니다. 격리는 연결 수준 세션 변수(예: `SET LOCAL app.tenant_id = $1`)를 사용하는 PostgreSQL RLS나 쿼리 빌더 래퍼를 통해 강제됩니다. - *테넌트별 스키마(Schema-per-Tenant)*: 테넌트들이 단일 데이터베이스 인스턴스를 공유하되 스키마는 분리합니다. 라우팅 시 연결별로 search path를 조정합니다. - *테넌트별 데이터베이스(Database-per-Tenant)*: 테넌트마다 독립된 데이터베이스를 갖습니다. 서비스는 커넥션 풀을 동적으로 확인하기 위해 풀 레지스트리(예: `Arc<DashMap<TenantId, PgPool>>` 또는 LRU 풀 캐시)를 유지합니다. 3. **데이터 누출 방지**: 비동기 Tokio 애플리케이션에서 테넌트 간 데이터 오염을 방지하려면, 전역 상태나 스레드 로컬 스토리지(`thread_local!`)에 테넌트 컨텍스트를 저장하지 말고 작업(task) 경계를 넘어 명시적으로 전달해야 합니다. 스레드 로컬 스토리지는 비동기 워커 스레드 간 작업 이전 시 컨텍스트가 유지되지 않아 문제가 발생할 수 있습니다.

use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);

#[derive(Clone)]
pub struct AppState {
    pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}

#[derive(Clone)]
pub struct TenantContext {
    pub tenant_id: TenantId,
    pub pool: PgPool,
}

#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
    type Rejection = (axum::http::StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
        let tenant_header = parts.headers.get("X-Tenant-ID")
            .and_then(|v| v.to_str().ok())
            .ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
        
        let tenant_id = TenantId(tenant_header.to_string());
        
        let pool = match state.pool_registry.get(&tenant_id) {
            Some(p) => p.value().clone(),
            None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
        };

        Ok(TenantContext { tenant_id, pool })
    }
}
AI 코치와 함께 이 질문에 답해 보세요