Rust로 배우는 실용적인 오류 처리: 나만의 인구 데이터 조회 CLI 만들기
서론: 왜 이 프로젝트를 만드나요?
안녕하세요! Rust의 세계에 오신 것을 환영합니다. 이 튜토리얼에서는 여러분과 함께 간단하지만 아주 실용적인 커맨드 라인(CLI) 프로그램을 만들어 볼 거예요. 바로 전 세계 도시의 인구 데이터를 CSV 파일에서 읽어와 조회하는 프로그램이죠.
이 프로젝트의 진짜 목표는 단순히 프로그램을 완성하는 것이 아닙니다. 파일이 존재하지 않을 때, 데이터 형식이 잘못되었을 때처럼 실제 프로그래밍에서 매일 마주치는 '오류' 상황을 Rust에서는 어떻게 우아하고 안전하게 처리하는지 배우는 것입니다. 초보자도 쉽게 따라 할 수 있도록 단계별로 차근차근 안내해 드릴 테니, 걱정 말고 함께 시작해 봅시다!
--------------------------------------------------------------------------------
학습 목표
이 튜토리얼을 마치면 여러분은 다음 세 가지 핵심 기술을 자신 있게 사용할 수 있게 될 거예요.
- 파일 입출력과 Result 열거형 파일이 없거나 읽기 권한이 없는 등 실제 발생할 수 있는 문제를 Result 타입을 통해 어떻게 안전하게 처리하는지 배웁니다. 이를 통해 프로그램이 예기치 않게 종료되는 것을 막고, 문제가 발생했음을 명확하게 인지할 수 있게 됩니다.
- CSV 데이터 파싱 단순한 문자열 덩어리인 CSV 데이터를 프로그램이 이해할 수 있는 구조화된 데이터(struct)로 변환하는 방법을 익힙니다. 이 과정에서 데이터의 형식이 잘못되었거나 값이 누락되는 등의 잠재적 오류를 다루는 방법을 학습합니다.
- 간결한 오류 전파 (? 연산자와 Box<dyn Error>) 여러 단계의 함수 호출에서 발생하는 다양한 종류의 오류를 ? 연산자를 사용하여 단 한 줄의 코드로 간결하게 상위 함수로 전달하는 방법을 배웁니다. 이는 Rust의 관용적인 오류 처리 방식으로, 코드를 훨씬 깔끔하고 읽기 쉽게 만들어 줍니다.
튜토리얼 개요
이 프로젝트는 총 4단계로 구성되어 있습니다.
- 프로젝트 설정: Cargo를 이용해 새 프로젝트를 만들고 샘플 데이터 파일을 준비합니다.
- 파일 읽기: 데이터 파일을 읽어오면서 Rust의 가장 기본적인 오류 처리 타입인 Result를 처음 만나봅니다.
- 데이터 파싱: 읽어온 텍스트 데이터를 구조체로 변환하고, 이 과정에서 발생할 수 있는 다양한 오류를 유연하게 처리하는 방법을 배웁니다.
- 코드 완성: ? 연산자를 사용해 여러 단계의 오류 처리를 하나로 통합하고, 훨씬 간결하고 세련된 코드로 완성합니다.
--------------------------------------------------------------------------------
1단계: 프로젝트 설정 및 준비
모든 멋진 여정은 첫걸음부터 시작되죠. 먼저 우리 프로젝트를 위한 작업 공간을 만들고 필요한 데이터 파일을 준비해 보겠습니다.
1. Cargo로 새 프로젝트 시작하기
터미널을 열고 다음 명령어를 입력하여 city-pop-cli라는 이름의 새로운 Rust 프로젝트를 생성하세요.
cargo new city-pop-cli
cd city-pop-cli
cargo new 명령어는 Rust 프로젝트의 기본 구조를 자동으로 만들어주는 아주 편리한 도구입니다. 생성된 디렉터리 구조는 다음과 같습니다.
city-pop-cli/
├── Cargo.toml
└── src/
└── main.rs
- Cargo.toml: 프로젝트의 설정 파일입니다. 프로젝트 이름, 버전, 의존성(라이브러리) 등을 관리합니다.
- src/main.rs: 우리 프로그램의 소스 코드가 들어갈 파일입니다.
2. 샘플 데이터 파일 만들기
프로젝트의 루트 디렉터리(즉, cargo new를 실행했던 city-pop-cli 디렉터리)에 data.csv라는 이름의 파일을 만들고 아래 내용을 복사하여 붙여넣으세요. 이 파일이 우리 프로그램이 읽어 들일 인구 데이터입니다.
city,country,population
Tokyo,Japan,37435191
Delhi,India,29399141
Shanghai,China,26317104
Sao Paulo,Brazil,21846507
각 줄은 쉼표(,)로 구분되며, 순서대로 도시, 국가, 인구 수를 나타냅니다.
3. 기본 코드 구조 작성하기
이제 src/main.rs 파일을 열어보면 Cargo가 만들어준 기본 코드가 있을 겁니다. 일단 그대로 두고, 잘 작동하는지 확인해 봅시다.
// src/main.rs
fn main() {
println!("Hello, world!");
}
터미널에서 cargo run 명령어를 실행하면 코드가 컴파일되고 실행될 겁니다. 화면에 Hello, world!가 출력된다면 성공입니다!
자, 이제 프로젝트 설정이 모두 끝났습니다. 다음 단계에서는 이 CSV 파일을 실제로 읽어 들이는 코드를 작성하면서 Rust의 핵심 오류 처리 방식인 Result를 처음으로 만나보겠습니다.
--------------------------------------------------------------------------------
2단계: 데이터 파일 읽고 첫 번째 오류 처리하기
이제 본격적으로 파일에 접근해 보겠습니다. 파일을 열고 그 내용을 읽는 것은 생각보다 실패할 확률이 높은 작업입니다. 파일이 존재하지 않거나, 읽기 권한이 없을 수도 있으니까요. Rust는 이런 '실패 가능성'을 어떻게 다루는지 살펴봅시다.
1. 파일 읽기
src/main.rs 파일을 수정하여 파일을 여는 코드를 추가해 봅시다. Rust 표준 라이브러리의 std::fs::File과 std::io::Read를 사용합니다.
// src/main.rs
use std::fs::File;
use std::io::{self, Read};
fn main() {
let file_result = File::open("data.csv"); // 파일을 엽니다.
}
여기서 가장 중요한 점은 File::open("data.csv")이 File 타입을 직접 반환하지 않는다는 것입니다. 대신 Result<File, io::Error>라는 타입을 반환합니다. Result는 성공 아니면 실패, 둘 중 하나의 결과를 담는 특별한 열거형(enum)입니다.
- 성공 시: Ok(File) variants에 파일 핸들(File)을 담아 반환합니다.
- 실패 시: Err(io::Error) variants에 오류 정보(io::Error)를 담아 반환합니다.
2. Result와 match로 오류 처리하기
그럼 이 Result를 어떻게 다룰까요? Rust의 철학은 예외를 '던지는(throw)' 대신, 잠재적인 실패를 값으로 '반환하는(return)' 것입니다. match 표현식은 바로 그 값을 검사하여 우리 프로그램의 정상적인 흐름이 성공 경로를 타야 할지, 실패 경로를 타야 할지 결정하는 방법입니다. match는 Result의 두 가지 경우(Ok와 Err)를 모두 명시적으로 처리하도록 강제하여, 오류를 실수로 무시하는 일을 원천적으로 방지합니다.
// src/main.rs
use std::fs::File;
use std::io::{self, Read};
fn main() {
let file_result = File::open("data.csv");
let file = match file_result {
Ok(file) => file, // 성공 시, Ok 안의 file 값을 추출하여 변수에 할당
Err(error) => {
// 실패 시, Err 안의 error 값을 사용하여 처리
panic!("파일을 열 수 없습니다: {:?}", error);
}
};
}
panic!은 프로그램을 즉시 중단시키고 오류 메시지를 출력하는 매크로입니다. 지금은 간단하게 처리했지만, 실제 애플리케이션에서는 사용자에게 더 친절한 메시지를 보여주고 프로그램을 안전하게 종료하는 것이 좋습니다.
다른 많은 언어들이 '예외(exception)'를 던져 오류를 처리하는 것과 달리, Rust는 Result 열거형을 사용합니다. 이는 "오류는 예외적인 상황이 아니라, 프로그램의 정상적인 흐름의 일부"라는 Rust의 철학을 반영합니다. Result를 사용하면 컴파일러가 오류 처리를 잊지 않도록 강제해주기 때문에, 잠재적인 버그를 컴파일 시점에 발견하고 훨씬 안정적인 프로그램을 만들 수 있습니다.
3. 파일 내용 읽기
파일을 성공적으로 열었다면, 이제 그 내용을 문자열로 읽어올 차례입니다. read_to_string 메서드를 사용하면 됩니다. 이 메서드 또한 Result를 반환한다는 점을 기억하세요!
main 함수를 아래와 같이 전체적으로 수정해 봅시다.
// src/main.rs
use std::fs::File;
use std::io::{self, Read};
fn main() {
let file_result = File::open("data.csv");
let mut file = match file_result {
Ok(file) => file,
Err(error) => {
panic!("파일을 열 수 없습니다: {:?}", error);
}
};
let mut contents = String::new(); // 파일 내용을 담을 빈 문자열 생성
match file.read_to_string(&mut contents) {
Ok(_) => {
// 성공 시, contents 변수에 파일 내용이 채워집니다.
println!("파일 내용:\n{}", contents);
}
Err(error) => {
panic!("파일을 읽을 수 없습니다: {:?}", error);
}
}
}
이제 cargo run을 실행하면 data.csv의 내용이 터미널에 출력되는 것을 볼 수 있습니다. 만약 data.csv 파일의 이름을 일부러 틀리게 바꾸고 실행하면, panic!으로 설정한 오류 메시지가 나타나는 것도 확인해 보세요.
파일 내용을 성공적으로 메모리로 가져왔습니다! 하지만 아직은 단순한 텍스트 덩어리에 불과하죠. 다음 단계에서는 이 텍스트 데이터를 프로그램이 이해할 수 있는 구조체로 변환하는 '파싱' 작업을 진행하고, 이 과정에서 발생할 수 있는 더 다양한 오류들을 다뤄보겠습니다.
--------------------------------------------------------------------------------
3단계: CSV 데이터 파싱과 유연한 오류 처리
CSV 파일의 내용을 문자열로 읽어오는 데 성공했습니다. 이제 이 문자열을 한 줄씩, 한 필드씩 분석하여 의미 있는 데이터 구조로 만들어야 합니다. 이 과정을 '파싱(parsing)'이라고 합니다. 파싱 과정은 오류가 발생하기 아주 좋은 곳입니다. 예를 들어, 열의 개수가 맞지 않거나 숫자가 와야 할 곳에 문자가 있는 경우가 그렇죠.
1. 데이터 구조체(Struct) 정의하기
먼저 CSV의 한 행 데이터를 담을 구조체를 정의합시다. PopulationData라는 이름의 구조체를 만들고, 도시(city), 국가(country), 인구(population) 필드를 추가합니다.
// src/main.rs 상단에 추가
struct PopulationData {
city: String,
country: String,
population: u64,
}
2. 파싱 함수 설계하기
이제 본격적으로 파싱 로직을 담을 함수를 만들어 봅시다. parse_csv라는 함수는 문자열 슬라이스(&str)를 입력받아, 성공하면 PopulationData의 벡터(Vec<PopulationData>)를, 실패하면 오류를 반환하도록 설계할 겁니다.
// main 함수 위에 추가
use std::error::Error;
fn parse_csv(csv_data: &str) -> Result<Vec<PopulationData>, Box<dyn Error>> {
// ... 파싱 로직 구현 ...
todo!(); // '아직 구현되지 않음'을 나타내는 매크로
}
여기서 반환 타입 Result<..., Box<dyn Error>>를 주목해 주세요.
왜 io::Error 같은 특정 오류 타입 대신 Box<dyn Error>를 사용할까요?
파싱 과정에서는 다양한 종류의 오류가 발생할 수 있습니다.
- 사용자 정의 오류: CSV의 열 개수가 맞지 않는 경우 (우리가 직접 만드는 오류)
- 형식 오류: 데이터 형식이 잘못된 경우
- 변환 오류: 인구 수를 숫자로 변환하다 실패하는 경우 (ParseIntError)
이 모든 다른 종류의 오류를 하나의 함수에서 반환하려면 어떻게 해야 할까요? Box<dyn Error>가 바로 그 해답입니다.
- dyn Error: Error라는 '특성(trait)'을 구현하는 모든 타입의 오류를 의미합니다. 일종의 '오류 인터페이스'라고 생각할 수 있습니다.
- Box: 데이터를 힙(heap) 메모리에 저장하는 스마트 포인터입니다. 컴파일러가 컴파일 시점에 크기를 알 수 없는 dyn Error를 다룰 수 있게 해줍니다.
즉, Box<dyn Error>는 어떤 종류의 오류든 담을 수 있는 '만능 오류 상자' 같은 역할을 합니다. 덕분에 우리는 매우 유연하고 간결하게 오류를 처리할 수 있게 됩니다.
3. 파싱 로직 구현 및 오류 처리
이제 parse_csv 함수를 채워봅시다. 이 함수는 다음 작업을 수행하며, 오류가 발생할 수 있는 각 지점을 세심하게 처리합니다.
- 문자열을 줄 단위로 나눕니다.
- 첫 번째 줄(헤더)은 건너뜁니다.
- 각 줄을 쉼표(,)를 기준으로 분리합니다. (오류 가능성 1: 열 개수 불일치)
- 인구 수 필드를 숫자로 변환합니다. (오류 가능성 2: 숫자 변환 실패)
- 분리된 조각들로 PopulationData 인스턴스를 만듭니다.
fn parse_csv(csv_data: &str) -> Result<Vec<PopulationData>, Box<dyn Error>> {
let mut records = Vec::new();
// lines() 이터레이터로 문자열을 한 줄씩 순회합니다.
// skip(1)으로 헤더 줄은 건너뜁니다.
for line in csv_data.lines().skip(1) {
// split(',')으로 각 줄을 쉼표 기준으로 나눕니다.
let fields: Vec<&str> = line.split(',').collect();
// 1. 열 개수 불일치 오류 처리
if fields.len() != 3 {
// 더 유용한 오류 메시지를 만들어 Box에 담아 반환합니다.
return Err(format!("잘못된 레코드: 3개의 필드가 필요하지만 {}개를 찾았습니다. 라인: '{}'", fields.len(), line).into());
}
// 2. 숫자 변환 실패 오류 처리
// 여기서 `?` 연산자를 살짝 맛봅시다!
// `parse()`가 Err(ParseIntError)를 반환하면, `?`는 함수를 즉시 종료하고
// 그 오류를 `Box<dyn Error>` 타입으로 자동 변환하여 반환해줍니다.
let population = fields[2].parse::<u64>()?;
records.push(PopulationData {
city: fields[0].to_string(),
country: fields[1].to_string(),
population,
});
}
// 모든 줄을 성공적으로 파싱했다면, Ok로 감싸서 결과를 반환합니다.
Ok(records)
}
이제 파일 읽기와 데이터 파싱이라는 두 가지 핵심 기능이 모두 준비되었습니다. 마지막 단계에서는 이들을 main 함수 안에서 하나로 합치고, 지금까지 작성한 match 블록들을 ? 연산자를 사용해 훨씬 더 깔끔하게 다듬어 보겠습니다.
--------------------------------------------------------------------------------
4단계: ? 연산자로 코드 완성하기
지금까지 우리는 파일 읽기와 CSV 파싱 기능을 각각 구현했습니다. 이제 이들을 main 함수에서 조합하여 전체 프로그램을 완성할 차례입니다. 이 과정에서 Rust의 강력한 ? 연산자를 통해 오류 처리 코드를 극적으로 단순화하는 방법을 배우게 될 것입니다.
1. main 함수에서 기능 통합하기
먼저, 2단계와 3단계에서 작성한 코드를 main 함수에 그대로 합쳐보면 어떻게 될까요? 아마도 아래와 같이 match 표현식이 중첩된 복잡한 코드가 될 것입니다.
// ? 연산자 적용 전의 복잡한 main 함수 (예시)
fn main() {
let mut contents = String::new();
match File::open("data.csv") {
Ok(mut file) => {
match file.read_to_string(&mut contents) {
Ok(_) => {
match parse_csv(&contents) {
Ok(data) => {
// 성공! 데이터를 사용
println!("{:?}개의 도시 데이터를 읽었습니다.", data.len());
}
Err(e) => {
panic!("파싱 오류: {}", e);
}
}
}
Err(e) => {
panic!("파일 읽기 오류: {}", e);
}
}
}
Err(e) => {
panic!("파일 열기 오류: {}", e);
}
}
}
성공 로직에 도달하기까지 여러 단계의 match를 거쳐야 해서 코드가 깊어지고 가독성이 떨어집니다. 이런 코드를 "파멸의 피라미드(Pyramid of Doom)"라고 부르기도 합니다.
2. ? 연산자 소개
Rust는 이 문제를 해결하기 위해 ? 라는 마법 같은 연산자를 제공합니다.
? 연산자는 Result 타입 뒤에 붙여 사용하는 단축 문법입니다. 하는 일은 아주 간단합니다.
- Result가 Ok(value)이면, Ok를 벗겨내고 그 안의 value 값만 남깁니다.
- Result가 Err(error)이면, 그 즉시 현재 함수를 종료하고 해당 error를 반환합니다.
즉, ? 연산자는 우리가 match를 사용해 수동으로 작성했던 오류 전파 로직을 단 하나의 문자로 압축해주는 역할을 합니다.
3. main 함수와 parse_csv 함수 리팩토링
? 연산자를 사용하려면 한 가지 조건이 있습니다. ?는 오류 발생 시 현재 함수에서 Err를 반환해야 하므로, 현재 함수의 반환 타입 역시 Result여야 합니다. 따라서 main 함수의 시그니처를 다음과 같이 변경해야 합니다.
fn main() -> fn main() -> Result<(), Box<dyn Error>>
- (): "유닛 타입"이라고 부르며, 성공했을 때 반환할 실질적인 값이 없음을 의미합니다.
- Box<dyn Error>: 실패했을 때 어떤 종류의 오류든 반환할 수 있도록 합니다.
이제 ? 연산자를 적용하여 main 함수를 리팩토링해 봅시다. 그리고 이 마법이 단지 main 함수에만 국한되지 않는다는 것을 보여주기 위해, 우리의 parse_csv 함수도 함께 더 깔끔하게 다듬어 보겠습니다.
마지막으로, 우리 PopulationData 구조체를 println!이나 dbg! 매크로로 쉽게 출력하여 디버깅할 수 있도록 #[derive(Debug)] 속성을 추가해 줍시다.
이제 최종적으로 완성된 전체 코드를 살펴봅시다. 코드가 얼마나 간결하고 명확해졌는지 확인해 보세요.
// src/main.rs 전체 코드
use std::error::Error;
use std::fs::File;
use std::io::Read;
// PopulationData 구조체 정의
#[derive(Debug)]
struct PopulationData {
city: String,
country: String,
population: u64,
}
// `?` 연산자로 완전히 정리된 parse_csv 함수
fn parse_csv(csv_data: &str) -> Result<Vec<PopulationData>, Box<dyn Error>> {
let mut records = Vec::new();
for line in csv_data.lines().skip(1) {
let fields: Vec<&str> = line.split(',').collect();
if fields.len() != 3 {
return Err(format!("잘못된 레코드: 3개의 필드가 필요하지만 {}개를 찾았습니다. 라인: '{}'", fields.len(), line).into());
}
// 이 `?` 연산자는 parse()가 반환하는 `ParseIntError`를
// `Box<dyn Error>`로 자동 변환하여 함수 밖으로 전파합니다.
let population = fields[2].parse::<u64>()?;
records.push(PopulationData {
city: fields[0].to_string(),
country: fields[1].to_string(),
population,
});
}
Ok(records)
}
// 최종 main 함수
fn main() -> Result<(), Box<dyn Error>> {
// 1. 파일 열기
let mut file = File::open("data.csv")?;
// 2. 파일 내용 읽기
let mut contents = String::new();
file.read_to_string(&mut contents)?;
// 3. CSV 파싱
let data = parse_csv(&contents)?;
// 4. 성공 결과 출력
for record in data {
println!(
"도시: {}, 국가: {}, 인구: {}",
record.city, record.country, record.population
);
}
// 5. main 함수가 성공적으로 끝났음을 알림
Ok(())
}
보세요! main 함수가 중첩된 match 블록 없이 순차적인 작업 흐름으로 명확하게 표현되었습니다. 각 단계 뒤에 붙은 ?가 "이 작업이 실패하면 즉시 중단하고 오류를 보고해!"라고 말하는 것과 같습니다.
이제 터미널에서 cargo run을 실행해 보세요. 모든 것이 정상이라면 다음과 같은 결과가 출력될 것입니다.
도시: Tokyo, 국가: Japan, 인구: 37435191
도시: Delhi, 국가: India, 인구: 29399141
도시: Shanghai, 국가: China, 인구: 26317104
도시: Sao Paulo, 국가: Brazil, 인구: 21846507
--------------------------------------------------------------------------------
결론: 무엇을 배웠고, 다음은 무엇일까요?
축하합니다! 여러분은 방금 단순한 "Hello, World!"를 넘어 실제 데이터를 다루는 실용적인 프로그램을 완성했습니다. 이 짧은 여정을 통해 우리는 많은 것을 배웠습니다.
성취 요약
- Rust로 실용적인 커맨드 라인 프로그램을 만들었습니다. 단순한 문법 학습을 넘어, 파일을 읽고 데이터를 처리하는 실제적인 문제를 해결했습니다.
- 파일을 읽고 텍스트 데이터를 파싱하는 방법을 익혔습니다. 외부 데이터를 프로그램 내부의 구조화된 데이터로 변환하는 핵심적인 기술을 습득했습니다.
- Result, Box<dyn Error>, ? 연산자를 사용하여 견고하고 관용적인 오류 처리를 구현했습니다. Rust가 어떻게 안정성을 보장하는지, 그리고 어떻게 오류 처리를 간결하게 표현하는지에 대한 깊은 이해를 얻었습니다.
핵심 메시지
Rust의 오류 처리는 처음에는 조금 번거롭게 느껴질 수 있습니다. 모든 실패 가능성을 명시적으로 다뤄야 하니까요. 하지만 이것이 바로 Rust의 가장 큰 장점입니다. 컴파일러는 우리가 잠재적인 버그를 놓치지 않도록 도와주는 든든한 조력자입니다. Result와 ? 연산자에 익숙해지면, 여러분은 런타임에 발생할 수많은 버그로부터 자유로운, 훨씬 안정적인 프로그램을 만들고 있다는 자신감을 얻게 될 것입니다.
다음 단계 제안
이제 여러분의 프로그램을 한 단계 더 발전시켜 볼 시간입니다. 아래 과제들에 도전해 보며 학습을 이어나가 보세요.
- 커맨드 라인 인자 받기: "data.csv"처럼 파일 경로를 코드에 고정하는 대신, cargo run -- <파일경로> 와 같이 커맨드 라인 인자로 파일 경로를 받도록 수정해 보세요. (std::env::args()를 검색해 보세요.)
- 기능 추가하기: 특정 국가의 도시만 필터링해서 보여주거나, 인구 수에 따라 오름차순 또는 내림차순으로 정렬하는 기능을 추가해 보세요.
- 커스텀 오류 타입 정의하기: Box<dyn Error> 대신, 우리 프로그램에서 발생할 수 있는 오류들(예: FileReadError, ParseError)을 담는 자신만의 오류 열거형(enum)을 정의해 보세요. 이를 통해 사용자에게 훨씬 더 구체적이고 친절한 오류 메시지를 보여줄 수 있습니다.
Rust 학습 여정에 오른 것을 다시 한번 축하드립니다. 계속해서 만들고, 실패하고, 배우며 즐거운 코딩 여정을 이어나가시길 바랍니다!
'[프로그래밍] > [Rust 입문]' 카테고리의 다른 글
| Rust 타입 시스템을 활용한 견고한 API 디자인 가이드 (1) | 2025.12.26 |
|---|---|
| Rust의 고급 오류 처리: 안전하고 효율적인 시스템 프로그래밍을 위한 전략 (0) | 2025.12.26 |
| Rust의 핵심: 소유권, 참조, 그리고 대여 이해하기 (0) | 2025.12.26 |





