Tech Wiki

[Rust 실전 로드맵 05] Rust Vec·String·HashMap 실전 사용법

엔드포인트 상태 파일 세 줄을 읽어 상태별 개수를 세어 확인해 보겠습니다. 이 작은 작업 안에서 Vec, String, HashMap의 역할이 선명하게 갈립니다. Vec은 파싱한 레코드의 순서를 유지합니다. String은 레코드가 소유할 텍스트를 담고 HashMap은 상태와 개수를 연결합니다.

이번 예제는 표준 라이브러리만 씁니다. 잘못된 줄과 없는 값은 건너뛰거나 Option으로 처리합니다. 한글 이름은 바이트 인덱스로 자르지 않습니다. 아래의 독립 크레이트 전체 코드는 이전 편에 의존하지 않으며 포맷, Clippy, 테스트 명령을 포함합니다.

1. 세 컬렉션의 역할

Vec<T>는 같은 타입의 값을 연속된 메모리에 저장하는 가변 길이 컬렉션입니다. 입력 순서가 중요하고 레코드 수가 실행 전에는 정해지지 않은 이번 작업에 잘 맞습니다. 인덱싱과 get의 실패 방식은 다릅니다. items[10]은 범위를 벗어나면 panic합니다. 반면 items.get(10)Option<&T>를 돌려줍니다.

String은 소유한 UTF-8 문자열입니다. 파싱 함수가 입력 &str보다 오래 살아남는 레코드를 만들려면 각 필드를 String으로 복사해 소유하게 만드는 편이 간단합니다. 반대로 잠깐 읽기만 하는 함수 인자는 &str로 두면 됩니다. String은 바이트 벡터로 구현되지만 유효한 UTF-8을 보장합니다.

HashMap<K, V>는 키를 값에 매핑합니다. 여기서는 ok, error 같은 상태가 키이고 출현 횟수가 값입니다. 키가 이미 있는지 매번 분기하는 대신 entry(...).or_insert(0)으로 기본값을 넣은 뒤 증가시킬 수 있습니다. 읽을 때 get을 쓰면 없는 상태도 panic 없이 처리됩니다. 자세한 동작은 HashMap 문서에서 확인할 수 있습니다.

2. 잘못된 줄 거부하기

입력 형식은 한 줄에 이름|URL|상태입니다. 먼저 구분자로 나눈 조각을 Vec<&str>로 모읍니다. 슬라이스 패턴으로 정확히 세 필드인지 검사합니다.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointRecord {
    pub name: String,
    pub url: String,
    pub status: String,
}

pub fn parse_endpoints(input: &str) -> Vec<EndpointRecord> {
    input.lines().filter_map(parse_line).collect()
}

fn parse_line(line: &str) -> Option<EndpointRecord> {
    let fields: Vec<&str> = line.split('|').map(str::trim).collect();
    let [name, url, status] = fields.as_slice() else {
        return None;
    };

    if name.is_empty() || url.is_empty() || status.is_empty() {
        return None;
    }

    Some(EndpointRecord {
        name: (*name).to_owned(),
        url: (*url).to_owned(),
        status: (*status).to_owned(),
    })
}

filter_mapSome(record)만 결과 벡터에 넣습니다. 필드 수가 다르거나 빈 필드가 있는 줄은 None이므로 제외됩니다. 운영 도구라면 조용히 버리기보다 줄 번호와 오류를 반환하는 편이 낫습니다. 지금은 컬렉션 API에 집중하려고 정책을 단순하게 잡았습니다.

슬라이스 패턴 let [name, url, status] = fields.as_slice()fields[0], fields[1], fields[2]를 연달아 읽는 코드보다 의도가 분명합니다. 세 조각이 아니면 레코드를 만들지 않습니다. 특정 위치를 선택적으로 읽어야 한다면 fields.get(index)가 같은 안전장치를 제공합니다.

3. entry로 세고 get으로 읽는다

요약 단계에서는 상태별 카운트를 만듭니다. 첫 엔드포인트 미리보기도 함께 계산하되 빈 입력을 정상적인 경우로 둡니다.

use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointSummary {
    pub total: usize,
    pub first_name_preview: Option<String>,
    pub status_counts: HashMap<String, usize>,
}

pub fn summarize(endpoints: &[EndpointRecord]) -> EndpointSummary {
    let mut status_counts = HashMap::new();
    for endpoint in endpoints {
        *status_counts.entry(endpoint.status.clone()).or_insert(0) += 1;
    }

    let first_name_preview = endpoints
        .first()
        .map(|endpoint| endpoint.name.chars().take(4).collect());

    EndpointSummary {
        total: endpoints.len(),
        first_name_preview,
        status_counts,
    }
}

impl EndpointSummary {
    pub fn count_for(&self, status: &str) -> usize {
        self.status_counts.get(status).copied().unwrap_or(0)
    }
}

entry는 조회와 삽입을 한 번의 흐름으로 묶습니다. 처음 본 상태라면 0을 넣습니다. 이미 있으면 기존 값을 가리키는 가변 참조를 돌려줍니다. *... += 1은 그 참조가 가리키는 카운트를 올립니다. 같은 entry 패턴은 단어별 빈도 계산에도 쓸 수 있습니다.

읽기 쪽의 getOption<&usize>를 반환합니다. copied()Option<usize>로 바꾸고 unwrap_or(0)을 붙였기 때문에, 한 번도 나타나지 않은 상태의 개수는 0입니다. unwrap()과 달리 누락을 예상 가능한 입력으로 다룹니다.

첫 항목도 endpoints.first()로 읽었습니다. endpoints[0]은 빈 벡터에서 panic합니다. first()get(0)을 쓰면 빈 입력을 None으로 표현할 수 있습니다. 두 접근 방식은 서로 다른 실패 계약에 맞습니다.

4. UTF-8과 바이트 인덱싱

한글이 섞이면 String::len()을 문자 수로 오해하기 쉽습니다. len()은 UTF-8 바이트 수를 반환합니다. 또한 Rust는 name[0] 같은 정수 문자열 인덱싱을 허용하지 않습니다. 임의의 바이트 위치가 UTF-8 코드 포인트 경계가 아닐 수 있기 때문입니다. 문자열 슬라이스의 바이트 인덱스는 UTF-8 경계에 있어야 합니다. 잘못된 경계로 슬라이싱하면 panic합니다.

예제는 다음처럼 문자 이터레이터를 사용합니다.

let preview: String = "결제 API".chars().take(4).collect();
assert_eq!(preview, "결제 A");

이 코드는 유효한 UTF-8 경계를 지킵니다. 다만 chars()의 항목은 사용자가 화면에서 인식하는 완성 글자 단위가 아니라 Unicode scalar value입니다. 결합 문자나 여러 코드 포인트로 이루어진 이모지까지 화면 글자 단위로 잘라야 한다면 Unicode grapheme segmentation을 구현한 별도 크레이트가 필요합니다. 표준 라이브러리만 쓰는 이 예제는 그 범위까지 약속하지 않습니다.

5. 독립 크레이트 실행

Rust 2024 에디션을 지원하는 Rust 도구체인만 있으면 됩니다. 이 예제에는 외부 의존성이 없으며 시리즈의 이전 편에서 만든 파일도 사용하지 않습니다. 프로젝트를 만들 상위 디렉터리에서 다음 명령부터 실행합니다.

mkdir -p rust-collections-demo/src
cd rust-collections-demo

두 번째 명령을 실행한 뒤의 rust-collections-demo가 아래 모든 명령의 시작 디렉터리입니다. 다음 세 파일을 표시된 내용 그대로 만듭니다.

Cargo.toml:

[package]
name = "article-05-vec-string-hashmap"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]

src/lib.rs:

use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointRecord {
    pub name: String,
    pub url: String,
    pub status: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointSummary {
    pub total: usize,
    pub first_name_preview: Option<String>,
    pub status_counts: HashMap<String, usize>,
}

#[must_use]
pub fn parse_endpoints(input: &str) -> Vec<EndpointRecord> {
    input.lines().filter_map(parse_line).collect()
}

fn parse_line(line: &str) -> Option<EndpointRecord> {
    let fields: Vec<&str> = line.split('|').map(str::trim).collect();
    let [name, url, status] = fields.as_slice() else {
        return None;
    };

    if name.is_empty() || url.is_empty() || status.is_empty() {
        return None;
    }

    Some(EndpointRecord {
        name: (*name).to_owned(),
        url: (*url).to_owned(),
        status: (*status).to_owned(),
    })
}

#[must_use]
pub fn summarize(endpoints: &[EndpointRecord]) -> EndpointSummary {
    let mut status_counts = HashMap::new();
    for endpoint in endpoints {
        *status_counts.entry(endpoint.status.clone()).or_insert(0) += 1;
    }

    let first_name_preview = endpoints
        .first()
        .map(|endpoint| endpoint.name.chars().take(4).collect());

    EndpointSummary {
        total: endpoints.len(),
        first_name_preview,
        status_counts,
    }
}

impl EndpointSummary {
    #[must_use]
    pub fn count_for(&self, status: &str) -> usize {
        self.status_counts.get(status).copied().unwrap_or(0)
    }
}

#[cfg(test)]
mod tests {
    use super::{parse_endpoints, summarize};

    const INPUT: &str = "결제 API|https://pay.example.com/health|ok\n\
                         검색 🔎|https://search.example.com/health|error\n\
                         malformed line\n\
                         문서 API|https://docs.example.com/health|ok";

    #[test]
    fn parses_only_complete_records() {
        assert_eq!(parse_endpoints(INPUT).len(), 3);
    }

    #[test]
    fn rejects_extra_fields() {
        assert!(parse_endpoints("name|https://example.com|ok|extra").is_empty());
    }

    #[test]
    fn rejects_empty_fields() {
        assert!(parse_endpoints("name||ok").is_empty());
    }

    #[test]
    fn preserves_input_order() {
        let endpoints = parse_endpoints(INPUT);
        assert_eq!(
            endpoints.first().map(|item| item.name.as_str()),
            Some("결제 API")
        );
        assert_eq!(
            endpoints.get(1).map(|item| item.name.as_str()),
            Some("검색 🔎")
        );
    }

    #[test]
    fn stores_owned_strings() {
        let endpoints = {
            let input = String::from("docs|https://docs.example.com|ok");
            parse_endpoints(&input)
        };
        assert_eq!(endpoints[0].name, "docs");
    }

    #[test]
    fn counts_each_status() {
        let summary = summarize(&parse_endpoints(INPUT));
        assert_eq!(summary.total, 3);
        assert_eq!(summary.count_for("ok"), 2);
        assert_eq!(summary.count_for("error"), 1);
    }

    #[test]
    fn missing_status_has_zero_count() {
        assert_eq!(summarize(&parse_endpoints(INPUT)).count_for("unknown"), 0);
    }

    #[test]
    fn empty_input_has_no_first_endpoint() {
        let summary = summarize(&parse_endpoints(""));
        assert_eq!(summary.total, 0);
        assert_eq!(summary.first_name_preview, None);
    }

    #[test]
    fn preview_uses_unicode_scalar_boundaries() {
        let summary = summarize(&parse_endpoints(INPUT));
        assert_eq!(summary.first_name_preview.as_deref(), Some("결제 A"));
    }
}

src/main.rs:

use article_05_vec_string_hashmap::{parse_endpoints, summarize};

fn main() {
    let input = "결제 API|https://pay.example.com/health|ok\n\
                 검색 🔎|https://search.example.com/health|error\n\
                 문서 API|https://docs.example.com/health|ok";

    let summary = summarize(&parse_endpoints(input));

    println!("endpoints: {}", summary.total);
    println!("ok: {}", summary.count_for("ok"));
    println!("error: {}", summary.count_for("error"));
    println!(
        "first preview: {}",
        summary.first_name_preview.as_deref().unwrap_or("없음")
    );
}

계속 rust-collections-demo 디렉터리에서 포맷, 린트, 테스트, 실행 명령을 차례로 수행합니다.

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

프로그램 출력은 다음과 같았습니다.

endpoints: 3
ok: 2
error: 1
first preview: 결제 A

테스트 9개는 불완전한 필드와 초과 필드, 빈 필드, 입력 순서, 소유 문자열, 상태별 개수, 없는 상태, 빈 입력, 한글 미리보기를 각각 확인합니다. Rust 1.98.1에서 포맷, Clippy, 테스트, 실행 명령은 경고 없이 통과해야 합니다.

이 예제에서 기억할 규칙은 짧습니다. 순서 있는 여러 값은 Vec, 소유해야 하는 UTF-8 텍스트는 String, 키로 집계할 값은 HashMap에 둡니다. 외부 입력을 읽을 때는 인덱싱보다 패턴, first, get을 먼저 고려하고 문자열의 위치를 셀 때 바이트와 문자를 구분하면 됩니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 05 프로젝트에서 확인할 수 있습니다.

출처


답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.