Devin.KR

Rust · 심화

트레이트와 동시성으로 깊어지는 Rust

종합 실습 - 재고 명령 처리기

트레이트 객체 명령 패턴, 사용자 정의 오류, 반복자 집계, 스레드로 주문 병렬 검증, 테스트 모듈

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장들에서 다룬 도구를 하나의 프로그램으로 묶는다. 창고 재고를 다루는 명령 처리기를 만든다. 텍스트 한 줄이 명령 객체가 되고, 명령이 재고를 바꾸고, 실패는 종류별 오류로 돌아오며, 결과는 반복자로 집계된다. 마지막에는 접수된 주문을 스레드로 나눠 검증한다. 코드는 표준 라이브러리만 쓰고 src/main.rs 한 파일에 담는다.

  • 트레이트 객체(trait object)로 서로 다른 명령을 한 목록에서 다루는 구조를 만든다.
  • 오류를 열거형으로 정의하고 Display 와 Error 를 구현한다.
  • 반복자 어댑터로 처리 기록을 집계하고, HashMap 결과는 정렬해서 출력한다.
  • Arc 와 채널로 주문을 병렬 검증하고, 도착 순서와 무관하게 결과를 안정적으로 정렬한다.
  • #[cfg(test)] 모듈로 각 단계를 따로 검증한다.

문제 상황

창고 담당자가 하루 동안의 작업을 텍스트 파일로 남긴다고 하자. 한 줄이 명령 하나다. receive A100 50 은 입고이고, ship A100 30 은 출고이며, discard B200 은 품목을 목록에서 지우는 폐기다. 이 파일을 읽어 재고에 반영하는 프로그램이 필요하다.

현실의 파일에는 오타, 없는 품목, 재고보다 많은 출고 요청이 섞여 있다. 한 줄이 실패해도 나머지 줄은 처리해야 하고, 끝에는 어떤 줄이 왜 실패했는지 요약이 남아야 한다. 명령 종류는 앞으로 늘어난다. 이송이나 실사 같은 명령이 추가될 때마다 기존 match 를 여러 곳에서 고치는 구조는 피하고 싶다.

접수된 주문을 재고와 대조하는 검증도 있다. 주문이 수백 건이면 검증을 나눠 돌리고 싶다. 다만 스레드가 끝나는 순서는 실행마다 달라지므로, 출력이 흔들리지 않게 하는 장치가 필요하다.

명령을 트레이트 객체로 표현하기

명령의 공통 동작은 "재고를 받아 실행하고, 결과 수량 또는 오류를 돌려준다"이다. 이를 트레이트로 정의하고, 명령마다 구조체를 하나씩 둔다. 목록에는 Box<dyn Command> 를 담는다. 어떤 구조체가 들어 있는지는 실행 시점에 vtable 로 결정되는데, 이것이 동적 디스패치(dynamic dispatch)다.

열거형과 트레이트 객체는 둘 다 "여러 종류를 하나로 다루는" 방법이지만 확장 방향이 다르다.

명령을 열거형으로 두는 방식과 트레이트 객체로 두는 방식의 비교
기준열거형 + match트레이트 객체
새 명령 추가열거형과 모든 match 를 수정한다구조체와 impl 하나를 추가한다
새 동작 추가match 한 곳만 추가하면 된다트레이트와 모든 impl 을 수정한다
컴파일러의 누락 검사빠진 variant 를 알려 준다해당 없음
호출 비용직접 분기vtable 간접 호출

이 프로그램에서는 명령 종류가 계속 늘어나고 동작은 execute 하나뿐이므로 트레이트 객체가 맞다. 줄 하나를 처리하는 비용은 파일을 읽는 비용에 비해 무시할 수 있어 간접 호출은 문제가 되지 않는다.

파싱은 한 곳에 모은다. 줄을 공백으로 나눠 슬라이스 패턴으로 맞추면 명령 이름과 인자 개수를 한 번에 검사할 수 있다. 어느 패턴에도 맞지 않으면 해석 오류다.

텍스트 한 줄은 파싱을 거쳐 트레이트 객체가 되고, 실행 결과는 기록으로 쌓여 집계된다.

오류 타입과 반복자 집계

오류는 열거형 하나로 정의한다. 변형이 곧 실패의 종류이므로 호출하는 쪽이 문자열을 뜯지 않고 match 로 분기할 수 있다. 재고 부족처럼 값을 함께 전달해야 하는 경우에는 구조체형 변형을 쓴다. Display 는 사람이 읽는 메시지를, Error 구현은 표준 오류 생태계와의 호환을 맡는다. 테스트에서 비교하려고 PartialEq 도 파생한다.

줄을 처리하는 함수는 enumerate 와 map 으로 줄 번호가 붙은 기록을 만들어 collect 한다. 한 줄이 실패해도 다음 줄로 넘어가야 하므로 ? 로 함수 전체를 끝내지 않는다. 실패는 기록 안의 Result 로 남긴다. 집계는 기록 위에서 filter, filter_map, count 로 한다. 종류별 횟수는 HashMap 에 모으지만 순회 순서가 정해져 있지 않으므로 Vec 으로 옮겨 정렬한 뒤 출력한다.

스레드로 주문 병렬 검증하기

주문 검증은 재고를 읽기만 한다. 읽기 전용 데이터는 Arc 로 감싸 스레드마다 복제본을 쥐게 하면 잠금이 필요 없다. 스레드는 검증 결과를 채널로 보내고, 메인 스레드는 수신 쪽에서 모은다.

채널을 쓸 때는 송신자 처리에 주의해야 한다. 스레드마다 tx.clone() 을 넘기고 원래의 tx 는 반드시 drop 해야 한다. 송신자가 하나라도 남아 있으면 rx.iter() 가 끝나지 않는다.

결과는 도착한 순서대로 오므로 주문 번호로 정렬해서 돌려준다. 이 한 줄이 출력을 결정적으로 만든다.

검증 스레드들은 같은 재고 스냅숏을 읽고 결과를 채널로 보내며, 메인 스레드가 주문 번호로 정렬해 출력을 고정한다.

주의할 점이 하나 있다. 각 스레드는 같은 스냅숏을 읽는다. 주문 하나가 통과해도 그 재고가 다른 주문에서 차감되지 않는다. 이 검증은 "그 주문 단독으로 지금 재고에서 이행 가능한가"에 대한 답이다. 재고를 실제로 예약하는 일은 별개의 문제이고, 연습 문제에서 다시 다룬다.

완성 코드

use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, mpsc};
use std::{fmt, thread};

#[derive(Debug, PartialEq)]
enum InventoryError {
    UnknownSku(String),
    Insufficient { sku: String, have: u32, want: u32 },
    InvalidQuantity,
    Parse(String),
}

impl fmt::Display for InventoryError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnknownSku(sku) => write!(f, "알 수 없는 품목: {sku}"),
            Self::Insufficient { sku, have, want } => {
                write!(f, "재고 부족: {sku} (보유 {have}, 요청 {want})")
            }
            Self::InvalidQuantity => write!(f, "수량은 1 이상이어야 한다"),
            Self::Parse(line) => write!(f, "해석할 수 없는 명령: {line}"),
        }
    }
}

impl std::error::Error for InventoryError {}

#[derive(Default)]
struct Inventory {
    stock: HashMap<String, u32>,
}

impl Inventory {
    fn quantity(&self, sku: &str) -> Option<u32> {
        self.stock.get(sku).copied()
    }

    fn add(&mut self, sku: &str, qty: u32) -> u32 {
        let slot = self.stock.entry(sku.to_string()).or_insert(0);
        *slot += qty;
        *slot
    }

    fn take(&mut self, sku: &str, qty: u32) -> Result<u32, InventoryError> {
        let have = self
            .stock
            .get_mut(sku)
            .ok_or_else(|| InventoryError::UnknownSku(sku.to_string()))?;
        if *have < qty {
            return Err(InventoryError::Insufficient {
                sku: sku.to_string(),
                have: *have,
                want: qty,
            });
        }
        *have -= qty;
        Ok(*have)
    }

    fn remove(&mut self, sku: &str) -> Result<(), InventoryError> {
        self.stock
            .remove(sku)
            .map(|_| ())
            .ok_or_else(|| InventoryError::UnknownSku(sku.to_string()))
    }

    fn sorted(&self) -> Vec<(&str, u32)> {
        let mut items: Vec<(&str, u32)> =
            self.stock.iter().map(|(k, v)| (k.as_str(), *v)).collect();
        items.sort();
        items
    }
}

fn check_qty(qty: u32) -> Result<(), InventoryError> {
    if qty == 0 {
        Err(InventoryError::InvalidQuantity)
    } else {
        Ok(())
    }
}

trait Command {
    fn name(&self) -> &'static str;
    fn execute(&self, inv: &mut Inventory) -> Result<u32, InventoryError>;
}

struct Receive {
    sku: String,
    qty: u32,
}

struct Ship {
    sku: String,
    qty: u32,
}

struct Discard {
    sku: String,
}

impl Command for Receive {
    fn name(&self) -> &'static str {
        "receive"
    }

    fn execute(&self, inv: &mut Inventory) -> Result<u32, InventoryError> {
        check_qty(self.qty)?;
        Ok(inv.add(&self.sku, self.qty))
    }
}

impl Command for Ship {
    fn name(&self) -> &'static str {
        "ship"
    }

    fn execute(&self, inv: &mut Inventory) -> Result<u32, InventoryError> {
        check_qty(self.qty)?;
        inv.take(&self.sku, self.qty)
    }
}

impl Command for Discard {
    fn name(&self) -> &'static str {
        "discard"
    }

    fn execute(&self, inv: &mut Inventory) -> Result<u32, InventoryError> {
        inv.remove(&self.sku)?;
        Ok(0)
    }
}

fn parse_qty(text: &str, line: &str) -> Result<u32, InventoryError> {
    text.parse::<u32>()
        .map_err(|_| InventoryError::Parse(line.to_string()))
}

fn parse_command(line: &str) -> Result<Box<dyn Command>, InventoryError> {
    let parts: Vec<&str> = line.split_whitespace().collect();
    match parts.as_slice() {
        ["receive", sku, qty] => Ok(Box::new(Receive {
            sku: sku.to_string(),
            qty: parse_qty(qty, line)?,
        })),
        ["ship", sku, qty] => Ok(Box::new(Ship {
            sku: sku.to_string(),
            qty: parse_qty(qty, line)?,
        })),
        ["discard", sku] => Ok(Box::new(Discard {
            sku: sku.to_string(),
        })),
        _ => Err(InventoryError::Parse(line.to_string())),
    }
}

struct LogEntry {
    line_no: usize,
    text: String,
    kind: Option<&'static str>,
    outcome: Result<u32, InventoryError>,
}

fn run_commands(inv: &mut Inventory, script: &[&str]) -> Vec<LogEntry> {
    script
        .iter()
        .enumerate()
        .map(|(i, line)| {
            let parsed = parse_command(line);
            let kind = parsed.as_ref().ok().map(|c| c.name());
            let outcome = parsed.and_then(|c| c.execute(inv));
            LogEntry {
                line_no: i + 1,
                text: line.to_string(),
                kind,
                outcome,
            }
        })
        .collect()
}

#[derive(Debug, PartialEq)]
struct Summary {
    ok: usize,
    failed: usize,
    by_kind: Vec<(&'static str, usize)>,
}

fn summarize(log: &[LogEntry]) -> Summary {
    let ok = log.iter().filter(|e| e.outcome.is_ok()).count();
    let mut counts: HashMap<&'static str, usize> = HashMap::new();
    for kind in log
        .iter()
        .filter(|e| e.outcome.is_ok())
        .filter_map(|e| e.kind)
    {
        *counts.entry(kind).or_insert(0) += 1;
    }
    let mut by_kind: Vec<(&'static str, usize)> = counts.into_iter().collect();
    by_kind.sort();
    Summary {
        ok,
        failed: log.len() - ok,
        by_kind,
    }
}

struct Order {
    id: u32,
    lines: Vec<(String, u32)>,
}

fn order(id: u32, lines: &[(&str, u32)]) -> Order {
    Order {
        id,
        lines: lines.iter().map(|(s, q)| (s.to_string(), *q)).collect(),
    }
}

fn validate_order(inv: &Inventory, order: &Order) -> Result<u32, InventoryError> {
    let mut wanted: BTreeMap<&str, u32> = BTreeMap::new();
    for (sku, qty) in &order.lines {
        check_qty(*qty)?;
        *wanted.entry(sku.as_str()).or_insert(0) += qty;
    }
    for (sku, want) in &wanted {
        let have = inv
            .quantity(sku)
            .ok_or_else(|| InventoryError::UnknownSku(sku.to_string()))?;
        if have < *want {
            return Err(InventoryError::Insufficient {
                sku: sku.to_string(),
                have,
                want: *want,
            });
        }
    }
    Ok(wanted.values().sum())
}

fn validate_parallel(
    inv: Arc<Inventory>,
    orders: Vec<Order>,
) -> Vec<(u32, Result<u32, InventoryError>)> {
    let (tx, rx) = mpsc::channel();
    let mut handles = Vec::new();
    for order in orders {
        let tx = tx.clone();
        let inv = Arc::clone(&inv);
        handles.push(thread::spawn(move || {
            let result = validate_order(&inv, &order);
            tx.send((order.id, result)).expect("수신 쪽이 살아 있어야 한다");
        }));
    }
    drop(tx);
    let mut results: Vec<_> = rx.iter().collect();
    for handle in handles {
        handle.join().expect("검증 스레드가 패닉했다");
    }
    results.sort_by_key(|(id, _)| *id);
    results
}

const SCRIPT: &[&str] = &[
    "receive A100 50",
    "receive B200 20",
    "ship A100 30",
    "ship B200 25",
    "ship C300 1",
    "receive A100 0",
    "discard B200",
    "ship B200 1",
    "restock A100 5",
    "receive C300 abc",
    "receive C300 15",
];

fn sample_orders() -> Vec<Order> {
    vec![
        order(1, &[("A100", 5), ("C300", 5)]),
        order(2, &[("A100", 15), ("A100", 10)]),
        order(3, &[("B200", 1)]),
        order(4, &[("C300", 0)]),
        order(5, &[("C300", 15), ("A100", 20)]),
    ]
}

fn main() {
    let mut inv = Inventory::default();
    let log = run_commands(&mut inv, SCRIPT);

    println!("== 명령 처리 ==");
    for e in &log {
        let result = match &e.outcome {
            Ok(qty) => format!("성공, 재고 {qty}"),
            Err(err) => format!("실패: {err}"),
        };
        println!("{:>2}  {:<18} {}", e.line_no, e.text, result);
    }

    let summary = summarize(&log);
    println!("== 요약 ==");
    println!("성공 {}건, 실패 {}건", summary.ok, summary.failed);
    for (kind, n) in &summary.by_kind {
        println!("  {kind}: {n}");
    }

    println!("== 재고 ==");
    for (sku, qty) in inv.sorted() {
        println!("  {sku}: {qty}");
    }

    println!("== 주문 검증 ==");
    let shared = Arc::new(inv);
    for (id, result) in validate_parallel(shared, sample_orders()) {
        match result {
            Ok(units) => println!("주문 #{id}: 통과 ({units}개)"),
            Err(err) => println!("주문 #{id}: 거부 - {err}"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn stocked() -> Inventory {
        let mut inv = Inventory::default();
        inv.add("A100", 10);
        inv
    }

    #[test]
    fn receive_adds_stock() {
        let mut inv = stocked();
        let cmd = parse_command("receive A100 5").unwrap();
        assert_eq!(cmd.execute(&mut inv), Ok(15));
    }

    #[test]
    fn ship_reports_shortage() {
        let mut inv = stocked();
        let cmd = Ship {
            sku: "A100".to_string(),
            qty: 30,
        };
        assert_eq!(
            cmd.execute(&mut inv),
            Err(InventoryError::Insufficient {
                sku: "A100".to_string(),
                have: 10,
                want: 30,
            })
        );
    }

    #[test]
    fn parse_rejects_unknown_verb() {
        let err = parse_command("restock A100 5").err();
        assert_eq!(err, Some(InventoryError::Parse("restock A100 5".to_string())));
    }

    #[test]
    fn summary_counts_successes() {
        let mut inv = Inventory::default();
        let log = run_commands(&mut inv, &["receive A 5", "ship A 9", "bogus"]);
        assert_eq!(
            summarize(&log),
            Summary {
                ok: 1,
                failed: 2,
                by_kind: vec![("receive", 1)],
            }
        );
    }

    #[test]
    fn order_sums_same_sku() {
        let inv = stocked();
        let err = validate_order(&inv, &order(1, &[("A100", 6), ("A100", 5)]));
        assert_eq!(
            err,
            Err(InventoryError::Insufficient {
                sku: "A100".to_string(),
                have: 10,
                want: 11,
            })
        );
    }

    #[test]
    fn parallel_results_sorted_by_id() {
        let orders = vec![
            order(3, &[("A100", 1)]),
            order(1, &[("A100", 99)]),
            order(2, &[("A100", 2)]),
        ];
        let results = validate_parallel(Arc::new(stocked()), orders);
        let ids: Vec<u32> = results.iter().map(|(id, _)| *id).collect();
        assert_eq!(ids, [1, 2, 3]);
        assert!(results[0].1.is_err());
    }
}

줄별 해설

오류와 재고

InventoryError 의 네 변형은 이 프로그램에서 일어나는 실패의 전부다. Insufficient 에 보유량과 요청량을 담은 덕분에 메시지를 만드는 곳에서 재계산하지 않는다. Display 의 match self 에서 필드는 참조로 묶이므로 {sku} 를 그대로 출력할 수 있다.

Inventory::take 는 get_mut 으로 값을 한 번만 찾고, 같은 가변 참조로 비교와 차감을 모두 한다. 조회와 수정을 따로 하면 해시를 두 번 계산하고, 그 사이에 상태가 바뀔 여지도 생긴다. sorted 는 HashMap 순회 순서에 의존하지 않도록 키 기준으로 정렬한 Vec 을 만든다.

명령 계층

Command 트레이트는 name 과 execute 두 메서드만 갖는다. name 이 &'static str 을 돌려주므로, 기록에 이름만 남겨도 명령 객체가 소유한 데이터를 빌리지 않는다. check_qty 는 수량 0 검사를 한곳에 모아 명령 실행과 주문 검증이 같은 규칙을 쓰게 한다.

parse_command 의 parts.as_slice() 는 &[&str] 이고, ["ship", sku, qty] 패턴은 길이와 첫 원소를 함께 검사한다. 각 갈래가 서로 다른 구조체를 Box::new 로 만들지만 반환 타입이 Box<dyn Command> 이므로 같은 Ok 안에서 합쳐진다. 수량 변환 실패에는 원래 줄을 담아 해석 오류로 바꾼다.

기록과 집계

run_commands 의 map 클로저는 inv 를 가변으로 빌려 쓰므로 FnMut 이다. 그 안에서 parsed.as_ref().ok().map(|c| c.name()) 으로 이름을 먼저 뽑는 이유는 다음 줄의 and_then 이 parsed 를 소비하기 때문이다. 파싱에 실패한 줄은 kind 가 None 으로 남는다.

summarize 는 성공한 기록만 걸러 filter_map(|e| e.kind) 로 이름을 꺼낸다. Option<&'static str> 는 Copy 라서 참조 너머에서 값을 복사해 꺼낼 수 있다. 세는 일은 entry 로 하고, 출력용으로 Vec 으로 옮겨 sort 한다. 튜플은 앞 원소부터 비교하므로 이름순이 된다.

병렬 검증

validate_order 는 같은 품목이 여러 줄에 나오는 주문을 처리하려고 BTreeMap 에 품목별 합계를 모은다. BTreeMap 은 키 순서로 순회하므로 오류가 여럿일 때도 가장 앞선 품목의 오류가 결정적으로 보고된다. 같은 품목을 줄마다 따로 검사하면 10개짜리 재고에 6개와 5개를 각각 요청하는 주문이 통과해 버린다. 이 경우를 order_sums_same_sku 테스트가 지킨다.

validate_parallel 에서 스레드 클로저는 move 로 tx, inv, order 의 소유권을 가져간다. &inv 는 &Arc<Inventory> 이지만 자동 역참조로 &Inventory 가 필요한 자리에 들어간다. drop(tx) 다음에 rx.iter().collect() 로 모든 결과를 받고, 그 뒤에 join 으로 패닉 여부를 확인한다. 마지막 sort_by_key 가 도착 순서의 영향을 지운다.

main 과 테스트

main 은 명령 처리가 끝난 뒤에 inv 를 Arc::new 로 감싼다. 이후에는 읽기만 하므로 Mutex 가 없다. 테스트 모듈은 파싱, 실행, 집계, 병렬 검증을 각각 독립적으로 확인한다. cargo run 의 빌드에는 포함되지 않아 경고도 늘리지 않는다.

실행 결과

$ cargo run --quiet
== 명령 처리 ==
 1  receive A100 50    성공, 재고 50
 2  receive B200 20    성공, 재고 20
 3  ship A100 30       성공, 재고 20
 4  ship B200 25       실패: 재고 부족: B200 (보유 20, 요청 25)
 5  ship C300 1        실패: 알 수 없는 품목: C300
 6  receive A100 0     실패: 수량은 1 이상이어야 한다
 7  discard B200       성공, 재고 0
 8  ship B200 1        실패: 알 수 없는 품목: B200
 9  restock A100 5     실패: 해석할 수 없는 명령: restock A100 5
10  receive C300 abc   실패: 해석할 수 없는 명령: receive C300 abc
11  receive C300 15    성공, 재고 15
== 요약 ==
성공 5건, 실패 6건
  discard: 1
  receive: 3
  ship: 1
== 재고 ==
  A100: 20
  C300: 15
== 주문 검증 ==
주문 #1: 통과 (10개)
주문 #2: 거부 - 재고 부족: A100 (보유 20, 요청 25)
주문 #3: 거부 - 알 수 없는 품목: B200
주문 #4: 거부 - 수량은 1 이상이어야 한다
주문 #5: 통과 (35개)

테스트는 cargo test 로 돌린다. 마지막 줄은 test result: ok. 6 passed; 0 failed 로 시작한다. 테스트별 줄의 순서는 보장되지 않는다.

주문 #1 과 #5 는 모두 통과했지만 둘이 합치면 C300 20개가 필요해 재고 15개를 넘는다. 앞에서 말한 스냅숏 검증의 한계다.

실무에서 자주 틀리는 것

빌린 값을 스레드에 그대로 넘긴다

스레드는 호출한 함수보다 오래 살 수 있으므로, 지역 변수를 빌리는 클로저는 넘길 수 없다.

let inv = Arc::new(inv);
for order in orders {
    thread::spawn(|| validate_order(&inv, &order));
}

컴파일러는 E0373 으로 "closure may outlive the current function, but it borrows inv"라고 알리고 move 사용을 권한다. 단순히 move 만 붙이면 inv 가 첫 반복에서 이동하므로, 반복마다 Arc 를 복제해서 넘긴다.

for order in orders {
    let inv = Arc::clone(&inv);
    thread::spawn(move || validate_order(&inv, &order));
}

송신자를 닫지 않아 수신이 끝나지 않는다

let (tx, rx) = mpsc::channel();
for order in orders {
    let tx = tx.clone();
    /* 스레드 생성 */
}
let results: Vec<_> = rx.iter().collect(); // 끝나지 않는다

메인 스레드의 tx 가 살아 있으면 채널이 닫히지 않아 iter 가 새 메시지를 계속 기다린다. 복제를 마친 직후에 원본을 버린다.

drop(tx);
let results: Vec<_> = rx.iter().collect();

HashMap 을 그대로 순회해 출력한다

for (sku, qty) in &inv.stock {
    println!("{sku}: {qty}");
}

실행마다 순서가 달라질 수 있어 출력 비교나 테스트가 불안정해진다. 정렬된 뷰를 거친다.

for (sku, qty) in inv.sorted() {
    println!("{sku}: {qty}");
}

오류를 문자열로 만든다

fn take(&mut self, sku: &str, qty: u32) -> Result<u32, String> {
    /* ... */
    Err(format!("재고 부족: {sku}"))
}

호출하는 쪽이 오류 종류를 가려내려면 메시지를 문자열로 비교해야 하고, 문구를 다듬는 순간 분기가 조용히 깨진다. 변형으로 표현하면 테스트도 값을 직접 비교한다.

Err(InventoryError::Insufficient { sku: sku.to_string(), have: *have, want: qty })

한눈에 보기

이 장의 프로그램에서 부분별로 쓴 도구와 선택 이유
부분도구선택 이유주의점
명령 표현Box<dyn Command>명령 종류가 늘어난다메서드가 늘면 모든 impl 을 고친다
오류열거형 + Display종류별 분기와 값 전달변형마다 메시지를 둔다
기록 집계map, filter, count실패해도 다음 줄을 처리한다HashMap 은 정렬해 출력한다
병렬 검증Arc + mpsc읽기 전용이라 잠금이 없다drop(tx) 를 잊지 않는다
결정적 출력sort_by_key도착 순서와 무관하다스냅숏 검증은 예약이 아니다
테스트가 각각 지키는 동작
테스트대상지키는 것
receive_adds_stock파싱 + 입고입고 후 수량
ship_reports_shortage출고부족 오류의 값
parse_rejects_unknown_verb파싱알 수 없는 명령 거부
summary_counts_successes집계성공·실패 건수
order_sums_same_sku주문 검증같은 품목 합산
parallel_results_sorted_by_id병렬 검증번호순 결과

연습 문제

  1. 품목의 현재 수량을 읽기만 하는 count A100 명령을 추가한다. 어디를 고쳐야 하는지 적는다.
  2. 존재하지 않는 품목에 Discard 를 실행하면 UnknownSku 가 돌아오는지 확인하는 테스트를 작성한다.
  3. validate_parallel 이 Arc 없이 &Inventory 를 받도록 thread::scope 로 바꾼다. 무엇이 단순해지는지 설명한다.
  4. 주문 #1 과 #5 가 둘 다 통과하는 문제를 해결하려면 구조를 어떻게 바꿔야 하는지 방향을 설명한다.

정답과 해설

1. 구조체와 impl 을 하나 추가하고 파서에 갈래를 하나 넣는다. 기존 명령 코드는 건드리지 않는다.

struct Count {
    sku: String,
}

impl Command for Count {
    fn name(&self) -> &'static str {
        "count"
    }

    fn execute(&self, inv: &mut Inventory) -> Result<u32, InventoryError> {
        inv.quantity(&self.sku)
            .ok_or_else(|| InventoryError::UnknownSku(self.sku.clone()))
    }
}

// parse_command 의 match 에 추가
["count", sku] => Ok(Box::new(Count { sku: sku.to_string() })),

트레이트 객체 방식에서 명령 추가가 국소적이라는 점이 확인된다. 이 명령은 재고를 바꾸지 않지만, execute 가 &mut 를 받는 시그니처 때문에 읽기 전용과 변경을 타입으로 구분하지는 못한다는 한계도 보인다.

2. 오류 값을 그대로 비교한다.

#[test]
fn discard_unknown_sku() {
    let mut inv = Inventory::default();
    let cmd = Discard { sku: "Z".to_string() };
    let err = cmd.execute(&mut inv).unwrap_err();
    assert_eq!(err, InventoryError::UnknownSku("Z".to_string()));
}

오류가 열거형이므로 문구가 바뀌어도 테스트는 깨지지 않는다.

3. 스코프 스레드는 블록이 끝나기 전에 모두 합류하므로 지역 참조를 빌려 줄 수 있다.

fn validate_scoped(
    inv: &Inventory,
    orders: &[Order],
) -> Vec<(u32, Result<u32, InventoryError>)> {
    let mut results: Vec<_> = thread::scope(|s| {
        let handles: Vec<_> = orders
            .iter()
            .map(|o| s.spawn(move || (o.id, validate_order(inv, o))))
            .collect();
        handles.into_iter().map(|h| h.join().unwrap()).collect()
    });
    results.sort_by_key(|(id, _)| *id);
    results
}

Arc 복제, 채널, drop(tx) 가 모두 사라지고 반환값으로 결과를 받는다. 대신 호출한 스레드가 모든 검증이 끝날 때까지 기다리는 구조가 된다.

4. 병렬 검증은 읽기 전용 스냅숏이라 주문 사이의 경쟁을 모른다. 이행까지 보장하려면 검증과 차감이 하나의 임계 구역 안에서 일어나야 한다. 재고를 Arc<Mutex<Inventory>> 로 바꾸고 주문마다 잠근 채 "검사 후 차감"을 하는 방법이 있다. 이때 병렬성은 줄어든다. 읽기 전용 사전 검증은 병렬로 돌려 명백한 불량 주문을 거르고, 통과한 주문만 한 스레드에서 순서대로 예약하는 두 단계 구성이 현실적이다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.