콘텐츠로 이동

Rust 면접 질문

여기 있는 질문들은 주니어부터 시니어 포지션까지, Rust 면접에서 몇 번이고 다시 나오는 것들이에요. 답변은 모두 소리 내어 말할 수 있을 만큼 짧게 정리했고, 주제를 깊이 설명하는 강좌로 가는 링크도 붙여 두었어요.

답을 글자 그대로 외우지는 마세요. 면접관은 금방 알아채고, 보통 정말 이해했는지 보려고 꼬리 질문을 던지거든요. 이 페이지로 내가 무엇을 알고 있는지 점검하고, 조금이라도 불안한 부분은 링크된 강좌로 가서 다시 공부하세요. 답을 자기 말로 설명하고 작은 예제를 쓸 수 있다면 준비가 된 거예요.

Cargo는 Rust의 공식 빌드 도구이자 패키지 매니저예요. Rust를 설치하면 함께 설치돼요. 도구 하나로 이런 일을 할 수 있어요.

  • 프로젝트 만들기(cargo new),
  • 빌드하고 실행하기(cargo build, cargo run),
  • 테스트 실행하기(cargo test),
  • crates.io에서 크레이트(crate)라고 부르는 라이브러리 추가하기(cargo add serde).

프로젝트 설정과 의존성은 Cargo.toml에 있어요. 정확한 버전은 Cargo.lock에 고정되기 때문에, 언제 빌드해도 같은 결과가 나와요. 덧붙이면 좋은 점: 다른 언어는 이런 일에 도구가 여러 개 필요한 경우가 많지만, Rust에서는 모두가 Cargo를 쓰기 때문에 어떤 프로젝트를 열어도 익숙해요.

더 알아보기: Cargo와 크레이트, Rust 시작하기.

Q: 섀도잉과 mut는 어떻게 다른가요?

섹션 제목: “Q: 섀도잉과 mut는 어떻게 다른가요?”

mut는 같은 변수의 값을 바꿀 수 있게 해 줘요. 타입은 그대로여야 해요.

섀도잉(shadowing)은 같은 이름으로 let을 다시 쓰는 거예요. 이전 변수를 가리는 새 변수가 만들어져요. 새 변수는 타입이 달라도 돼요.

fn main() {
let spaces = " ";
let spaces = spaces.len(); // 섀도잉: 새 변수이고, 이제는 숫자예요
println!("{spaces}");
let mut count = 1;
count += 1; // mut: 같은 변수에 새 값이 들어가요
println!("{count}");
}

실행 결과:

3
2
▶ 직접 실행해 보기

섀도잉은 값을 단계별로 바꿀 때(예를 들어 텍스트를 숫자로 파싱할 때) input_str, input_num 같은 이름을 따로 지어낼 필요가 없어서 편리해요. 그리고 마지막 변수는 여전히 불변이에요.

더 알아보기: Rust 변수.

Q: 매크로와 함수는 어떻게 다른가요?

섹션 제목: “Q: 매크로와 함수는 어떻게 다른가요?”

함수는 런타임에 정해진 개수의, 타입이 정해진 인자를 받아 호출돼요. 매크로는 컴파일 타임에 실행돼요. 코드를 받아서, 컴파일러가 검사하기 전에 코드를 더 만들어 내요. 매크로는 println!, vec!, format!처럼 !가 붙어 있어서 금방 알아볼 수 있어요.

매크로는 함수가 못 하는 일을 할 수 있어요. 인자 개수가 정해지지 않은 호출(println!("{} {}", a, b))을 받거나, 컴파일 타임에 포맷 문자열을 검사하는 것처럼요. 게다가 Rust 매크로는 단순한 텍스트가 아니라 코드의 구조를 다루기 때문에, C 매크로의 고전적인 버그를 피할 수 있어요.

macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
// C의 텍스트 기반 매크로라면 2 + 1 * 2 + 1 = 5가 나와요.
println!("{}", square!(2 + 1));
}

실행 결과:

9
▶ 직접 실행해 보기

솔직한 단점도 있어요. 매크로는 읽기도, 쓰기도, 디버깅하기도 더 어려워요. 함수로 할 수 있는 일이라면 함수를 쓰세요.

더 알아보기: 매크로.

Q: “비용 없는 추상화(zero-cost abstractions)“란 무슨 뜻인가요?

섹션 제목: “Q: “비용 없는 추상화(zero-cost abstractions)“란 무슨 뜻인가요?”

고수준 기능을 써도, 직접 손으로 쓴 저수준 코드보다 프로그램이 느려지면 안 된다는 뜻이에요. “쓰지 않는 것에는 비용을 내지 않고, 쓰는 것은 직접 짜도 그보다 더 잘 짤 수 없다.”

예를 들어 .iter().filter(...).map(...).sum() 같은 반복자 체인은 보통 손으로 쓴 반복문과 똑같은 기계어로 컴파일돼요. 제네릭도 좋은 예예요. 컴파일러가 사용하는 타입마다 특화된 사본을 만들어서(단형성화, monomorphization), 런타임에 무언가를 찾아볼 필요가 없어요.

“비용” 부분은 정확하게 말하세요. 비용이 0인 건 런타임 비용이에요. 컴파일 시간은 여전히 들고, 제네릭 때문에 프로그램 파일이 커질 수도 있어요.

더 알아보기: 반복자, 제네릭.

Q: Rust의 반복자는 게으른가요(lazy)?

섹션 제목: “Q: Rust의 반복자는 게으른가요(lazy)?”

네. .map()이나 .filter() 같은 메서드는 그것만으로는 아무 일도 하지 않아요. 할 일의 설명만 만들어 둬요. .collect(), .sum(), for 반복문처럼 무언가가 반복자를 소비하기 전까지는 아무 일도 일어나지 않아요.

fn main() {
let numbers = vec![1, 2, 3];
let doubled = numbers.iter().map(|n| {
println!("두 배로 만드는 중: {n}");
n * 2
});
println!("아직 아무것도 실행되지 않았어요.");
let result: Vec<i32> = doubled.collect();
println!("{:?}", result);
}

실행 결과:

아직 아무것도 실행되지 않았어요.
두 배로 만드는 중: 1
두 배로 만드는 중: 2
두 배로 만드는 중: 3
[2, 4, 6]
▶ 직접 실행해 보기

그래서 numbers.iter().map(...);라고 쓰고 결과를 쓰지 않으면 컴파일러가 경고해 줘요. 아무 일도 하지 않는 코드니까요.

더 알아보기: 반복자.

세 가지예요.

  1. Rust의 모든 값에는 소유자(owner)가 하나 있어요(변수예요).
  2. 소유자는 한 번에 하나만 있을 수 있어요.
  3. 소유자가 스코프를 벗어나면 값은 해제(drop)돼요(메모리가 반환돼요).

이 규칙 덕분에 Rust는 가비지 컬렉터 없이도, 그리고 개발자가 직접 free를 호출하지 않아도 딱 맞는 시점에 메모리를 해제할 수 있어요. 컴파일러가 규칙을 검사하기 때문에, 안전한(safe) 코드에서는 “해제 후 사용(use after free)“이나 “이중 해제(double free)” 같은 실수가 일어날 수 없어요.

더 알아보기: 소유권, 스코프.

Q: 이동(move), 복사(copy), 복제(clone)는 어떻게 다른가요?

섹션 제목: “Q: 이동(move), 복사(copy), 복제(clone)는 어떻게 다른가요?”
  • 이동(move): 값을 대입하거나 함수에 넘기면 소유권이 옮겨 가요. 이전 변수는 더 이상 쓸 수 없어요. String이나 Vec 같은 타입은 기본적으로 이렇게 동작해요. 작은 포인터, 길이, 용량 말고는 아무것도 복사되지 않아요.
  • 복사(copy): i32, f64, bool, char처럼 작고 단순한 타입은 대입할 때 비트 단위로 값싸게 복사되고, 두 변수를 모두 쓸 수 있어요.
  • 복제(clone): .clone()은 명시적인 깊은 복사예요. String이라면 텍스트 전체를 새 메모리에 복사해요. 비용이 클 수 있어서 Rust는 직접 쓰게 해요.
fn main() {
let x = 5;
let y = x; // Copy: x를 여전히 쓸 수 있어요
println!("{x} {y}");
let a = String::from("안녕");
let b = a.clone(); // Clone: 텍스트 전체를 복사해요
let c = a; // Move: 이 줄 다음부터는 `a`를 쓸 수 없어요
println!("{b} {c}");
}

실행 결과:

5 5
안녕 안녕
▶ 직접 실행해 보기

더 알아보기: 소유권.

Q: Copy 트레이트는 무엇이고, 어떤 타입이 가지고 있나요?

섹션 제목: “Q: Copy 트레이트는 무엇이고, 어떤 타입이 가지고 있나요?”

Copy는 “이 타입은 비트만 복사해도 복제할 수 있다”는 걸 나타내는 마커 트레이트(marker trait)예요. Copy인 타입은 대입할 때 이동하지 않고 복사돼요.

정수, 부동소수점 수, bool, char, 공유 참조자(&T), 그리고 Copy 타입으로만 이루어진 튜플과 배열은 모두 Copy예요. String, Vec, Box는 Copy가 아니에요. 다른 곳에 있는 메모리를 소유하고 있어서, 포인터만 복사하면 소유자가 둘이 되기 때문이에요.

직접 만든 타입에도 derive할 수 있지만, 모든 필드가 Copy일 때만 가능해요.

#[derive(Clone, Copy)]
struct User {
name: String,
age: u32,
}
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct User {
| ^^^^
3 | name: String,
| ------------ this field does not implement `Copy`

“이 타입에는 Copy 트레이트를 구현할 수 없다”는 에러예요. name 필드(String)가 Copy를 구현하지 않기 때문이에요.

Copy에는 항상 Clone도 필요해요. 그리고 Drop을 구현한 타입은 Copy가 될 수 없어요.

더 알아보기: 자주 쓰는 트레이트, 소유권.

Q: String과 &str는 어떻게 다른가요?

섹션 제목: “Q: String과 &str는 어떻게 다른가요?”
  • String은 힙에 저장되는, 크기를 늘릴 수 있는 소유된 텍스트예요. 예를 들어 push_str로 내용을 바꿀 수 있어요.
  • &str(“문자열 슬라이스”)는 어떤 텍스트를 빌려서 보는 창이에요. 포인터와 길이가 전부예요. "hello" 같은 문자열 리터럴은 &str예요.

좋은 규칙: 텍스트를 읽기만 하는 함수는 &str를 받게 하세요. &String은 자동으로 &str로 바뀌기 때문에, 그러면 둘 다 받을 수 있어요.

fn greet(name: &str) {
println!("안녕하세요, {name}님!");
}
fn main() {
let owned: String = String::from("Ana");
let literal: &str = "Ben";
greet(&owned); // &str가 필요한 곳에 &String을 쓸 수 있어요
greet(literal);
let mut full = owned;
full.push_str(" Smith"); // 크기를 늘릴 수 있는 건 String뿐이에요
greet(&full);
}

실행 결과:

안녕하세요, Ana님!
안녕하세요, Ben님!
안녕하세요, Ana Smith님!
▶ 직접 실행해 보기

더 알아보기: 문자열, 슬라이스.

어느 순간이든, 하나의 값에 대해 다음 중 하나만 가질 수 있어요.

  • 공유 참조자(&T) 여러 개, 또는
  • 가변 참조자(&mut T) 딱 하나.

둘을 동시에 가질 수는 없어요. 그리고 모든 참조자는 항상 유효한 데이터를 가리켜야 해요.

왜 그럴까요? 누군가 데이터를 읽는 동안 다른 누군가가 그걸 바꿀 수 있다면, 벡터 안을 가리키는 포인터를 쥐고 있는 사이에 벡터가 메모리를 옮겨 버리는 것 같은 버그가 생겨요. 스레드에서는 이게 바로 데이터 경쟁(data race)이에요. Rust는 이걸 컴파일 타임에 막아요. 2018 에디션부터 컴파일러는 대여가 언제 끝나는지 똑똑하게 판단해요(이걸 non-lexical lifetimes라고 해요). 대여는 블록 끝까지가 아니라 마지막으로 쓰이는 곳까지만 이어져요.

더 알아보기: 대여.

라이프타임(lifetime)은 “참조자가 얼마나 오래 유효한지”를 가리키는 컴파일러의 용어예요. 모든 참조자에는 라이프타임이 있어요. 대부분은 컴파일러가 스스로 알아내기 때문에(이걸 라이프타임 생략, lifetime elision이라고 해요) 눈에 보이지 않아요.

반환하는 참조자가 입력과 어떤 관계인지 컴파일러가 알 수 없을 때만 직접 써요. 빌려 온 두 문자열 중 하나를 돌려주는 함수가 그런 예예요.

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let first = String::from("borrow checker");
let second = String::from("lifetimes");
println!("{}", longest(&first, &second));
}

실행 결과:

borrow checker
▶ 직접 실행해 보기

이 예제는 문자열의 길이(len(), 바이트 수)를 비교하기 때문에 문자열을 영어로 두었어요. 한글은 한 글자가 3바이트라서 결과가 글자 수와 달라질 수 있거든요.

면접에서 꼭 말해야 할 중요한 점: 라이프타임 표기는 무언가가 살아 있는 기간을 바꾸지 않아요. 관계를 설명할 뿐이고, 덕분에 컴파일러가 어떤 참조자도 가리키는 데이터보다 오래 살지 않는지 확인할 수 있어요.

더 알아보기: 라이프타임.

Q: Rust에는 null이 없어요. 대신 무엇을 쓰나요?

섹션 제목: “Q: Rust에는 null이 없어요. 대신 무엇을 쓰나요?”

Option<T>를 써요. Some(value)나 None, 두 가지 배리언트(variant)를 가진 열거형이에요. T와는 다른 타입이기 때문에, 컴파일러는 값을 쓰기 전에 None인 경우를 처리하도록 강제해요. 코드가 컴파일되지 않으니, null 검사를 깜빡할 수가 없어요.

fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("Ana"),
2 => Some("Ben"),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_user(id) {
Some(name) => println!("찾았어요: {name}"),
None => println!("ID가 {id}인 사용자는 없어요"),
}
}
}

실행 결과:

찾았어요: Ana
ID가 3인 사용자는 없어요
▶ 직접 실행해 보기

덧붙이면 좋은 점: Option<&T>와 Option<Box<T>>는 일반 포인터와 비교해 메모리를 하나도 더 쓰지 않아요. Rust는 내부적으로 “null” 비트 패턴을 None을 뜻하는 데 쓰기 때문에, 여기서는 안전성에 드는 비용이 전혀 없어요.

더 알아보기: Option, if let / while let.

Q: 트레이트란 무엇인가요? 인터페이스와 같은 건가요?

섹션 제목: “Q: 트레이트란 무엇인가요? 인터페이스와 같은 건가요?”

트레이트는 타입이 구현할 수 있는 메서드들의 묶음을 정의해요. Java, C#, Go의 인터페이스와 비슷하고, 답변을 그렇게 시작해도 좋아요. 그다음 차이점을 이야기하세요.

  • 트레이트에는 기본 메서드 구현을 넣을 수 있어요.
  • 타입을 작성한 뒤에도 그 타입에 트레이트를 구현할 수 있어요. 직접 만들지 않은 타입이라도요. 예를 들어 직접 만든 트레이트를 i32에 구현할 수 있어요. (“고아 규칙(orphan rule)“에 따라, 트레이트와 타입 중 적어도 하나는 내 것이어야 해요.)
  • 트레이트는 컴파일 타임에 결정되는 제네릭의 제약(T: Display)으로도 쓰이고, 런타임에 쓰이는 트레이트 객체(dyn Display)로도 쓰여요.
  • Rust에는 클래스 상속이 없어요. 트레이트와 조합(composition)이 그 역할을 대신해요.

더 알아보기: 트레이트, 자주 쓰는 트레이트.

Q: 제네릭과 트레이트 객체는 어떻게 다른가요?

섹션 제목: “Q: 제네릭과 트레이트 객체는 어떻게 다른가요?”

둘 다 함수 하나가 여러 타입에서 동작하게 해 줘요. 차이는 타입이 언제 결정되느냐예요.

  • 제네릭(fn f<T: Speak>(x: &T))은 정적 디스패치(static dispatch)를 써요. 컴파일러가 타입마다 함수의 사본을 따로 만들어요. 호출은 일반 함수 호출만큼 빠르지만, 프로그램이 커질 수 있어요.
  • 트레이트 객체(&dyn Speak, Box<dyn Speak>)는 동적 디스패치(dynamic dispatch)를 써요. 함수 사본은 하나뿐이고, 알맞은 메서드는 런타임에 테이블(“vtable”)을 통해 찾아요. 아주 조금 느리지만, 서로 다른 타입을 하나의 컬렉션에 섞어 담을 수 있어요.
trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Speak for Dog {
fn speak(&self) -> String {
String::from("멍멍")
}
}
impl Speak for Cat {
fn speak(&self) -> String {
String::from("야옹")
}
}
fn speak_static<T: Speak>(animal: &T) -> String {
animal.speak()
}
fn speak_dynamic(animal: &dyn Speak) -> String {
animal.speak()
}
fn main() {
println!("{}", speak_static(&Dog));
println!("{}", speak_dynamic(&Cat));
// 하나의 Vec에 여러 타입을 섞을 수 있는 건 트레이트 객체뿐이에요:
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
println!("{}", animal.speak());
}
}

실행 결과:

멍멍
야옹
멍멍
야옹
▶ 직접 실행해 보기

경험칙: 제네릭으로 시작하세요. 서로 다른 타입을 담은 컬렉션이 필요하거나, 컴파일 시간과 바이너리 크기를 줄이고 싶을 때 트레이트 객체를 쓰세요.

더 알아보기: 제네릭, 트레이트 객체.

Q: Box<dyn Trait>와 impl Trait는 어떻게 다른가요?

섹션 제목: “Q: Box<dyn Trait>와 impl Trait는 어떻게 다른가요?”

둘 다 타입 이름을 쓰지 않고 “이 트레이트를 구현한 어떤 타입”이라고 말할 수 있게 해 줘요. 차이는 구체적인 타입이 몇 개까지 허용되느냐예요.

  • impl Trait: 컴파일러는 알지만 직접 쓸 필요는 없는, 특정한 타입 하나를 뜻해요. 정적 디스패치라서 힙 할당도 vtable도 없고, 타입 이름을 직접 쓴 것만큼 빨라요. 인자 자리(fn f(x: impl Display))에서는 제네릭을 짧게 쓰는 방법일 뿐이에요. 반환 자리에서는 클로저나 반복자 체인처럼 타입 이름이 길거나 아예 이름을 쓸 수 없는 값을 돌려줄 때 써요.
  • Box<dyn Trait>: 힙에 있는 트레이트 객체예요. 실제 타입은 런타임에 결정되고 매번 달라질 수 있으며, 메서드는 vtable을 통해 찾아요. 할당과 간접 호출 비용이 들지만, 여러 타입 중 하나를 돌려주거나 하나의 Vec에 담는 방법은 이것뿐이에요.
use std::fmt::Display;
// impl Trait: 컴파일 타임에 정해지는 숨겨진 타입 하나예요.
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
// Box<dyn Trait>: 실제 타입이 런타임에 달라질 수 있어요.
fn label(as_number: bool) -> Box<dyn Display> {
if as_number {
Box::new(42)
} else {
Box::new("사십이")
}
}
fn main() {
let v: Vec<u32> = evens(10).collect();
println!("{v:?}");
println!("{}", label(true));
println!("{}", label(false));
}

실행 결과:

[0, 2, 4, 6, 8]
42
사십이
▶ 직접 실행해 보기

말해 두면 좋은 점: label이 impl Display를 돌려주도록 바꾸고 Box를 없애면 컴파일되지 않아요. if는 i32를, else는 &str를 돌려주는데, impl Trait는 단 하나의 타입이어야 하거든요. 경험칙: 기본으로는 impl Trait를 쓰고, 런타임에 정말로 타입을 골라야 할 때 Box<dyn Trait>로 바꾸세요.

더 알아보기: 트레이트 객체, 제네릭.

Q: match는 왜 모든 경우를 다뤄야 하나요?

섹션 제목: “Q: match는 왜 모든 경우를 다뤄야 하나요?”

match는 가능한 모든 값을 처리해야 하고, 그렇지 않으면 코드가 컴파일되지 않아요(이걸 철저성, exhaustiveness라고 해요). 덕분에 열거형에 새 배리언트를 추가하면, 컴파일러가 고쳐야 할 곳을 전부 보여 줘요. 다른 언어에서는 switch에서 빠뜨린 case가 조용히 아무 일도 하지 않죠.

_를 나머지 전부를 받는 갈래로 쓸 수 있지만 조심하세요. 이런 갈래는 나중에 추가한 새 배리언트까지 숨겨 버려서, 그 안전망을 잃게 돼요.

더 알아보기: Rust match, 패턴 매칭, Rust 열거형.

Q: Rust는 예외 없이 어떻게 에러를 처리하나요?

섹션 제목: “Q: Rust는 예외 없이 어떻게 에러를 처리하나요?”

복구할 수 있는 에러는 Result<T, E> 타입의 평범한 값이에요. Ok(value) 아니면 Err(error)예요. 실패할 수 있는 함수는 반환 타입으로 그 사실을 드러내고, 호출하는 쪽은 반드시 그걸 처리해야 해요. 예외처럼 눈에 보이지 않게 스택을 거슬러 올라가는 일은 없어요.

장점: 함수 시그니처만 봐도 실패할 수 있다는 걸 알 수 있고, Result를 무시하면 컴파일러가 경고해 줘요. 비용은 코드를 조금 더 써야 한다는 건데, ? 연산자가 그 대부분을 없애 줘요.

더 알아보기: Rust Result.

Q: Result를 돌려주는 대신 panic!을 써야 할 때는 언제인가요?

섹션 제목: “Q: Result를 돌려주는 대신 panic!을 써야 할 때는 언제인가요?”
  • 실패가 예상되고, 호출하는 쪽에서 충분히 처리할 수 있다면 Result를 돌려주세요. 파일이 없을 때, 사용자 입력이 잘못됐을 때, 네트워크가 시간 초과됐을 때가 그래요.
  • 버그일 때, 즉 프로그램이 “일어날 수 없어야 하는” 상태에 도달해서 계속 진행하면 잘못될 때는 패닉을 일으키세요. 벡터의 끝을 넘어서 인덱싱하면 패닉이 나는 것도 이 때문이에요.

테스트, 예제, 빠른 프로토타입에서도 패닉은 괜찮아요. 라이브러리에서는 호출하는 쪽이 넘긴 입력 때문에 패닉을 일으키지 마세요. 에러를 돌려주고 판단은 호출하는 쪽에 맡기세요.

더 알아보기: Rust panic, Rust Result.

Q: unwrap, expect, ?는 어떻게 다른가요?

섹션 제목: “Q: unwrap, expect, ?는 어떻게 다른가요?”
  • unwrap(): Ok/Some 안의 값을 꺼내 주거나, 일반적인 메시지와 함께 패닉을 일으켜요.
  • expect("msg"): unwrap과 똑같지만, 내가 쓴 메시지로 패닉을 일으켜요. 왜 실패할 리 없다고 생각하는지 설명하는 데 쓰세요.
  • ?: 패닉을 일으키지 않아요. Err/None이면 현재 함수에서 에러를 돌려줘서, 호출하는 쪽이 처리할 수 있게 해요. 그 함수 자체도 Result나 Option을 돌려줘야 해요.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> {
let n: i32 = text.parse()?; // 에러가 나면 호출한 쪽으로 돌려줘요
Ok(n * 2)
}
fn main() {
println!("{:?}", double("21"));
println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("코드에 직접 쓴 숫자는 항상 파싱돼야 해요");
println!("{n}");
}

실행 결과:

Ok(42)
Err(ParseIntError { kind: InvalidDigit })
7
▶ 직접 실행해 보기

실제 서비스 코드에서는 ?를 먼저 고려하세요. 실패가 곧 버그라면 expect를 쓰고, unwrap은 테스트와 간단한 실험에만 쓰세요.

더 알아보기: Rust Result, Rust Option.

Q: 나만의 에러 타입은 어떻게 만드나요?

섹션 제목: “Q: 나만의 에러 타입은 어떻게 만드나요?”

보통은 코드가 실패할 수 있는 경우들을 나열한 열거형을 만들고, std::fmt::Display와 std::error::Error를 구현해요. 다른 에러 타입에 대해 From을 구현하면 ?가 자동으로 변환해 줘요.

실제로는 많은 프로젝트가 도우미 크레이트를 써요. 라이브러리에서 에러 타입을 정의할 때는 thiserror(영어)를, 에러에 맥락을 붙여 위로 전달하기만 하면 되는 애플리케이션에서는 anyhow(영어)를 써요. 간단한 프로그램이라면 Box<dyn Error>에 어떤 에러든 담을 수 있어요.

더 알아보기: 사용자 정의 에러.

Q: anyhow와 thiserror는 각각 언제 쓰나요?

섹션 제목: “Q: anyhow와 thiserror는 각각 언제 쓰나요?”

둘은 같은 문제의 서로 다른 절반을 해결해요. 그래서 솔직한 답은 “둘 다 쓰는 경우가 많다”예요.

  • thiserror: 라이브러리용이에요. 또는 호출하는 쪽이 특정 에러에 따라 다르게 대응해야 하는 코드에 써요. 에러 열거형을 직접 정의하면 #[derive(Error)]가 Display와 Error의 반복적인 코드를 채워 줘요. 호출하는 쪽은 ConfigError::MissingField처럼 match로 구분할 수 있는 진짜 타입을 받아요.
  • anyhow: 애플리케이션용이에요. 대부분의 에러를 하나하나 처리하기보다 보고만 하면 되는 경우예요. 모든 함수가 anyhow::Result<T>를 돌려주고, ?는 거의 모든 에러 타입을 받아들이며, .context("설정을 읽는 중")으로 사람이 읽기 쉬운 설명을 덧붙일 수 있어요. 단점은 호출하는 쪽에서 에러를 match로 구분하기가 어려워진다는 거예요.

흔한 구성은 라이브러리 크레이트 안에서는 thiserror를, main과 바이너리의 나머지 부분에서는 anyhow를 쓰는 거예요. 어느 쪽도 마법은 아니에요. 둘 다 표준 std::error::Error 트레이트 위에서 반복적인 코드를 줄여 줄 뿐이라서, 언제든 같은 코드를 직접 쓸 수 있어요.

더 알아보기: anyhow와 thiserror, 사용자 정의 에러.

Q: Rust에는 가비지 컬렉터가 있나요?

섹션 제목: “Q: Rust에는 가비지 컬렉터가 있나요?”

없어요. 메모리는 소유자가 스코프를 벗어날 때, 컴파일러가 미리 알고 있는 시점에 해제돼요(이걸 RAII라고 하고, 정리하는 메서드는 Drop이에요). 덕분에 GC로 인한 멈춤 없이 예측 가능한 성능을 얻을 수 있어요.

정말로 소유권을 공유해야 할 때는 참조 카운팅(Rc나 Arc)을 직접 선택해서 써요. 마지막 소유자가 사라지면 값이 해제돼요.

더 알아보기: 소유권, 스마트 포인터.

Q: Box, Rc, Arc, RefCell은 각각 언제 쓰나요?

섹션 제목: “Q: Box, Rc, Arc, RefCell은 각각 언제 쓰나요?”
  • Box<T>: 소유자 하나, 값은 힙에 있어요. 재귀 타입(트리나 연결 리스트 같은), 큰 값, 트레이트 객체(Box<dyn Trait>)에 써요.
  • Rc<T>: 한 스레드 안에서 소유자 여럿. 참조 개수를 세다가 0이 되면 값을 해제해요. 데이터는 읽기 전용이에요.
  • Arc<T>: Rc와 비슷하지만 카운트가 원자적(atomic)이라서 스레드 사이에서 공유해도 안전해요. Rc보다 조금 느려요.
  • RefCell<T>: 공유 참조자를 통해서도 데이터를 바꿀 수 있게 해 줘요(“내부 가변성”, interior mutability). 대여 규칙을 컴파일 타임이 아니라 런타임에 검사하고, 규칙을 어기면 패닉이 나요.

이것들은 조합해서 써요. Rc<RefCell<T>>는 “한 스레드 안에서 공유하면서 바꿀 수 있음”이고, Arc<Mutex<T>>는 그 스레드 안전 버전이에요.

use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2]));
let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow());
println!("소유자 수: {}", Rc::strong_count(&shared));
}

실행 결과:

[1, 2, 3]
소유자 수: 2
▶ 직접 실행해 보기

더 알아보기: 스마트 포인터.

Q: 안전한 Rust에서도 메모리 누수가 생길 수 있나요?

섹션 제목: “Q: 안전한 Rust에서도 메모리 누수가 생길 수 있나요?”

네, 그리고 그렇다고 솔직하게 말하는 게 좋아요. Rust는 메모리 안전성(해제 후 사용 없음, 댕글링 포인터 없음, 데이터 경쟁 없음)을 보장하지만, 메모리 누수는 안전하지 않은 동작으로 보지 않아요. 흔한 경우가 두 가지 있어요.

  • Rc 순환: Rc로 서로를 가리키는 두 값은 카운트가 절대 0이 되지 않아요. 한쪽 방향을 Weak 참조로 만들면 해결돼요.
  • Box::leak나 std::mem::forget처럼 일부러 누수를 일으키는 함수.

일상적인 코드에서는 누수가 드물어요. 소유권이 거의 모든 것을 자동으로 해제해 주니까요.

더 알아보기: 스마트 포인터.

Q: unsafe란 무엇이고, 언제 쓰나요?

섹션 제목: “Q: unsafe란 무엇이고, 언제 쓰나요?”

unsafe는 컴파일러가 검사할 수 없는 다섯 가지 추가 작업을 할 수 있는 블록(또는 함수)을 표시해요. 원시 포인터(raw pointer) 역참조, (FFI를 통한 C 코드를 포함한) unsafe 함수 호출, 가변 정적 변수 접근 같은 것들이에요. 그 안에서는 규칙이 지켜진다는 걸 개발자가 약속해야 해요.

중요한 점: unsafe는 대여 검사기(borrow checker)나 다른 검사를 끄지 않아요. 그 몇 가지 추가 작업만 허용할 뿐이에요.

C 라이브러리와 연동할 때, 저수준 자료 구조를 작성할 때, 그리고 컴파일러가 안전하다고 증명할 수 없는 곳에서 성능을 최대한 끌어낼 때 써요. 표준 라이브러리도 Vec 같은 안전한 API 안에서 unsafe를 써요. 좋은 습관: unsafe 블록은 작게 유지하고, 안전한 API로 감싸고, 왜 올바른지 설명하는 // SAFETY: 주석을 쓰세요. 대부분의 애플리케이션 코드에서는 전혀 필요 없어요.

더 알아보기: unsafe Rust.

컴파일러가 스레드 안전성을 지키는 데 쓰는 마커 트레이트예요.

  • Send: 이 타입의 값을 다른 스레드로 옮길(move) 수 있어요.
  • Sync: 이 타입의 값을 여러 스레드가 참조로 공유할 수 있어요(&T가 Send라는 뜻이에요).

대부분의 타입은 둘 다이고, 컴파일러가 자동으로 구현해 줘요. 유명한 예외는 Rc예요. 카운트가 스레드 안전하지 않거든요. 스레드로 보내려고 하면 컴파일되지 않아요.

use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || {
println!("{data}");
});
handle.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
...
= help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`

“Rc<i32>는 스레드 사이에서 안전하게 보낼 수 없다”는 에러예요. 클로저 안의 Rc<i32>가 Send 트레이트를 구현하지 않았다고 알려 줘요.

Rc를 Arc로 바꾸면 동작해요. 사람들이 말하는 Rust의 “겁 없는 동시성(fearless concurrency)“의 큰 부분이 바로 이거예요. 스레드 안전성 실수가 컴파일 에러가 돼요.

더 알아보기: 동시성.

Q: 스레드 사이에서 변경 가능한 데이터는 어떻게 공유하나요?

섹션 제목: “Q: 스레드 사이에서 변경 가능한 데이터는 어떻게 공유하나요?”

전형적인 답은 Arc<Mutex<T>>예요. Arc는 여러 스레드가 값을 소유할 수 있게 해 주고, Mutex는 한 번에 한 스레드만 값을 바꿀 수 있게 해 줘요. .lock()을 호출하면 가드(guard)를 받는데, 가드가 스코프를 벗어나면 자동으로 잠금이 풀려요.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("합계: {}", *counter.lock().unwrap());
}

실행 결과:

합계: 4000
▶ 직접 실행해 보기

대안도 함께 말해 주세요. 메모리를 공유하는 대신 채널(std::sync::mpsc)로 스레드 사이에 메시지를 보낼 수도 있어요. 그리고 단순한 카운터라면 AtomicUsize 같은 원자적 타입으로 잠금 없이 처리할 수 있어요.

더 알아보기: 동시성.

Q: 채널과 Mutex를 쓴 공유 상태는 어떻게 다른가요?

섹션 제목: “Q: 채널과 Mutex를 쓴 공유 상태는 어떻게 다른가요?”

스레드 사이에서 일을 나누는 두 가지 방식이에요.

  • 채널(std::sync::mpsc)은 메시지를 전달해요. 값이 채널 안으로 이동해서 반대편으로 나오기 때문에, 한 번에 한 스레드만 그 값을 소유해요. 파이프라인이나 작업자 풀(“작업자들이 결과를 만들고, 한 스레드가 모은다”)에 잘 맞아요. 두 스레드가 같은 데이터를 건드리지 않으니 보통 이해하기가 더 쉬워요.
  • 공유 상태(Arc<Mutex<T>>)는 여러 스레드가 같은 데이터를 잠금을 통해 번갈아 쓰는 방식이에요. 캐시나 카운터 테이블처럼 많은 스레드가 하나의 구조를 읽고 갱신해야 할 때 잘 맞아요. 위험 요소는 교착 상태(deadlock), 그리고 스레드들이 잠금을 계속 기다리면서 생기는 속도 저하예요.

아래는 앞 답변의 카운터를 채널로 다시 쓴 거예요. 각 작업자는 잠금 없이 혼자 세고, 결과를 보내요.

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
let mut count = 0;
for _ in 0..1000 {
count += 1;
}
tx.send(count).unwrap();
});
}
drop(tx); // 원래 송신자를 버려요. 안 그러면 rx.iter()가 끝나지 않아요
let total: i32 = rx.iter().sum();
println!("합계: {total}");
}

실행 결과:

합계: 4000
▶ 직접 실행해 보기

drop(tx) 줄은 언급하기 좋은 디테일이에요. rx.iter()는 모든 송신자가 사라질 때까지 계속 기다리기 때문에, 이 줄을 빠뜨리면 프로그램이 멈춰 버려요. 어느 방식이든 Rust는 컴파일 타임에 스레드 안전성을 검사해요. 어떤 설계가 내 문제에 더 명확한지의 선택일 뿐이고, 많은 프로그램이 둘 다 써요.

더 알아보기: 채널, 동시성.

Q: Rust는 모든 동시성 버그를 막아 주나요?

섹션 제목: “Q: Rust는 모든 동시성 버그를 막아 주나요?”

아니요. 그리고 그렇게 말할 수 있다는 건 제대로 이해하고 있다는 뜻이에요. 안전한 Rust는 데이터 경쟁(data race, 두 스레드가 동시에 같은 메모리를 건드리고, 그중 적어도 하나가 쓰는 것)을 막아요. 하지만 이런 건 막아 주지 않아요.

  • 교착 상태(deadlock): 예를 들어 두 스레드가 서로 상대가 쥐고 있는 잠금을 기다리는 경우.
  • 로직의 경쟁 조건(race condition): 예를 들어 값을 확인한 뒤 그 값을 바탕으로 행동하는 사이에, 다른 스레드가 값을 바꿔 버리는 경우.

그러니까 Rust는 까다로운 버그의 한 부류를 통째로 없애 주지만, 잠금 설계는 여전히 신중하게 해야 해요.

더 알아보기: 동시성.

Q: async와 스레드는 어떻게 다른가요?

섹션 제목: “Q: async와 스레드는 어떻게 다른가요?”
  • 스레드는 운영체제가 실행해요. 스레드마다 자기 스택이 있고, OS가 스레드 사이를 전환해요. CPU를 많이 쓰는 작업에 아주 좋아요. 하지만 스레드가 수천 개가 되면 메모리를 많이 써요.
  • async 태스크는 훨씬 가벼워요. async fn은 Future를 돌려주고, 런타임(보통 Tokio(영어))이 적은 수의 스레드 위에서 많은 퓨처를 실행해요. 태스크가 무언가를(네트워크, 타이머, 파일을) 기다리는 동안 .await가 다른 태스크를 실행하게 해 줘요. 수천 개의 연결을 처리하는 웹 서버처럼 기다리는 일이 많은 프로그램에 딱 맞아요.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // 네트워크를 기다리는 척해요
format!("{name} 완료")
}
#[tokio::main]
async fn main() {
// 둘이 동시에 실행되니까 300ms가 아니라 200ms 정도 걸려요.
let (a, b) = tokio::join!(fetch("첫 번째", 200), fetch("두 번째", 100));
println!("{a}");
println!("{b}");
}

실행 결과:

첫 번째 완료
두 번째 완료
▶ 직접 실행해 보기

면접관이 좋아하는 솔직한 디테일이 두 가지 있어요. Rust의 퓨처는 게을러서 await하기 전까지는 아무것도 하지 않아요. 그리고 표준 라이브러리에는 런타임이 들어 있지 않아서 직접 골라야 해요. 또 async 태스크 안에서 블로킹 코드(오래 걸리는 계산이나 std::thread::sleep 같은)를 실행하면, 같은 스레드에 있는 다른 태스크까지 멈춰 버려요.

더 알아보기: async/await, 동시성.

  • 간단하게 시작해서 깊이 들어가세요. 먼저 한 문장으로 답하세요(“Rc는 한 스레드 안에서 소유권을 공유하기 위한, 참조 카운트를 세는 포인터예요”). 면접관이 관심을 보이면 그때 디테일을 덧붙이세요.
  • 이유를 설명하세요. Rust의 규칙은 특정한 버그를 막으려고 존재해요. 규칙을 줄줄 읊는 것보다 “대여 규칙은 데이터 경쟁과 댕글링 포인터를 막아 줘요”라고 말하는 게 훨씬 설득력 있어요.
  • 작은 예제를 쓰세요. 코드 세 줄을 쓰거나 말로 설명하는 게 긴 연설보다 더 많은 걸 전달할 때가 많아요.
  • 트레이드오프에 솔직하세요. Rust에도 비용이 있어요. 느린 컴파일, 학습 곡선, 에러 처리에 드는 추가 코드 같은 것들이요. 이걸 인정하는 건 약점이 아니라 경험을 보여 줘요.
  • 모르는 건 “모르겠어요”라고 말하고, 소리 내어 추론해 보세요. “Pin을 직접 써 본 적은 없지만, 메모리에서 움직이면 안 되는 퓨처와 관련된 거라고 알고 있어요”는 아주 훌륭한 답이에요.
  • 경험담을 준비하세요. 면접관은 “대여 검사기 때문에 막혔던 경험을 이야기해 주세요”라고 자주 물어봐요. 내 코드에서 실제로 겪은 예 하나와, 거기서 배운 점을 생각해 두세요.
  • 코드를 쓰면서 연습하세요. Rust 연습 문제 페이지, 그리고 숫자 맞히기 게임과 할 일 목록 프로젝트로 몸을 풀어 보세요.

이해도 점검

1. 스레드 사이에서 읽기 전용 데이터를 공유하려면 어떤 스마트 포인터를 쓸까요?

2. ? 연산자는 Err를 만나면 무엇을 하나요?

3. 다음 중 안전한 Rust가 컴파일 타임에 막아 주는 것은 무엇일까요?

4. 라이프타임 표기는 값이 살아 있는 기간을 바꾸나요?

점수: 0 / 4

  • The Rust Programming Language 한국어판: 공식 책의 한국어 번역이에요. 이 페이지의 답변 대부분은 소유권, 트레이트, 에러, 동시성을 다루는 장에서 바로 나왔어요.
  • Rust by Example(영어): 짧고 실행 가능한 예제 모음이에요. 면접 직전에 문법을 다시 떠올리기에 좋아요.
  • The Rust Reference(영어): 언어의 정확한 규칙이에요. 면접관이 “그런데 정확히 언제 그런 일이 일어나죠?“라고 물을 때 도움이 돼요.
  • The Rustonomicon(영어): unsafe Rust 가이드예요. 시니어급 질문에 대비해 Send, Sync, 메모리 레이아웃에 관한 더 깊은 배경지식을 다뤄요.
  • Asynchronous Programming in Rust(영어): Rust 프로젝트의 공식 async 책이에요. 퓨처, async/.await, 런타임이 어떻게 맞물리는지 설명해요.