메모리 안전성만으로는 전체적인 신뢰성을 보장할 수 없다. 이 글은 안전한 Rust에서도 발생하는 정수 오버플로, 숫자 변환, 배열 인덱싱, 직렬화, TOCTOU, 민감 데이터 비교, 무제한 입력, 경로 조인 등 다양한 함정을 예시와 함께 설명하고, 이를 예방하는 실무 팁과 Clippy 린트 설정을 제시한다.
사람들이 Rust를 “안전한 언어”라고 말할 때, 대개 메모리 안전성을 의미한다. 메모리 안전성은 훌륭한 출발점이지만, 견고한 애플리케이션을 만드는 데에는 그것만으로는 부족하다.
메모리 안전성은 전체적인 신뢰성을 위한 필요조건이지, 충분조건이 아니다.
이 글에서는 컴파일러가 잡아내지 못하는 안전한 Rust의 몇 가지 흔한 함정과 이를 피하는 방법을 보여준다.
안전한 Rust 코드에서도 여전히 다양한 위험과 엣지 케이스를 처리해야 한다. 입력 검증, 비즈니스 로직의 정확성 같은 부분을 직접 다뤄야 한다.
Rust가 보호해 주지 않는 버그 범주 몇 가지만 예로 들면 다음과 같다.
unwrap이나 expect 사용으로 인한 패닉build.rs 스크립트자주 만나는 문제부터 피하는 방법을 살펴보자. 팁은 얼마나 자주 마주칠지에 대략 비례하여 정렬했다.
클릭하여 목차를 펼치세요.
as를 피하기split_at 대신 split_at_checked 사용Debug를 안전하게 구현하기Path::join의 놀라운 동작cargo-geiger로 의존성의 unsafe 코드 점검오버플로 오류는 생각보다 쉽게 발생한다:
// 하지 마세요: 검사하지 않는 산술 연산
fn calculate_total(price: u32, quantity: u32) -> u32 {
price * quantity 오버플로될 수 있음!
}
price와 quantity가 충분히 크면 결과가 오버플로된다. Rust는 디버그 모드에서는 패닉하고, 릴리스 모드에서는 조용히 래핑된다.
이 문제를 피하려면 검사되는 산술 연산을 사용하자:
// 이렇게 하세요: 검사되는 산술 연산 사용
fn calculate_total(price: u32, quantity: u32) -> Result<u32, ArithmeticError> {
price.checked_mul(quantity)
.ok_or(ArithmeticError::Overflow)
}
정적 검사는 생성 코드의 성능에 영향을 주지 않으므로 제거되지 않는다. 컴파일러가 컴파일 타임에 문제를 감지할 수 있다면 그렇게 할 것이다:
fn main() {
let x: u8 = 2;
let y: u8 = 128;
let z = x * y; 컴파일 타임 오류!
}
오류 메시지는 다음과 같다:
error: this arithmetic operation will overflow
--> src/main.rs:4:13
|
4 | let z = x * y; // Compile-time error!
| ^^^^^ attempt to compute `2_u8 * 128_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
그 외의 모든 경우에는 checked_add, checked_sub, checked_mul, checked_div을 사용하자. 이들은 언더플로/오버플로 시 래핑하지 않고 None을 반환한다. 1
Rust는 성능과 안전성의 균형을 신중히 잡는다. 성능 저하를 감수할 수 있는 시나리오에서는 메모리 안전성이 우선된다. 1
정수 오버플로는 예상치 못한 결과를 초래할 수 있지만, 그것 자체가 본질적으로 unsafe인 것은 아니다. 게다가 오버플로 검사는 비용이 클 수 있어, Rust는 릴리스 모드에서 이를 비활성화한다. 2
하지만 애플리케이션이 마지막 1% 성능을 포기하고 더 나은 오버플로 감지를 원한다면, 이를 다시 활성화할 수 있다.
Cargo.toml에 다음을 추가하자:
[profile.release]
overflow-checks = true # 릴리스 모드에서 정수 오버플로 검사 활성화
이렇게 하면 릴리스 모드에서도 오버플로 검사가 활성화된다. 그 결과, 오버플로가 발생하면 코드가 패닉한다.
자세한 내용은 문서를 참고하자.
Rust가 안전을 위해 성능 비용을 감수하는 예로는 런타임에서 버퍼 오버플로를 막는 배열 인덱스 검사, 그리고 특정 부동소수 값을 정수로 캐스팅할 때 정의되지 않은 동작을 유발하던 이전 구현을 고치기 위해 부동소수 캐스팅을 수정했던 사례가 있다. ↩
몇몇 벤치마크에 따르면, 일반적인 정수 연산이 많은 작업에서 오버플로 검사는 성능을 몇 퍼센트 정도 저하시킨다. Dan Luu의 분석은 여기를 참고. ↩
as For Numeric Conversions정수 산술 이야기의 연장선으로, 타입 변환에 대해 얘기해 보자. as로 값을 캐스팅하는 것은 편리하지만, 정확히 무슨 일을 하는지 확실히 알지 못한다면 위험하다.
let x: i32 = 42;
let y: i8 = x as i8; // 오버플로 가능!
Rust에서 숫자 타입 간 변환에는 대표적으로 세 가지 방법이 있다:
⚠️ as 키워드 사용: 무손실/손실 변환 모두에 작동한다. 데이터 손실이 발생할 수 있는 경우(예: i64 → i32), 값이 단순히 잘려나간다.
From::from() 사용: 이 방식은 오직 무손실 변환만 허용한다. 예를 들어 모든 32비트 정수는 64비트에 담길 수 있으므로 i32 → i64 변환은 가능하다. 하지만 i64 → i32는 잠재적으로 데이터 손실이 있을 수 있어 불가능하다.
TryFrom 사용: From::from()과 비슷하지만 Result를 반환한다. 잠재적인 데이터 손실을 우아하게 처리하고 싶을 때 유용하다.
의심스럽다면 as보다 From::from()과 TryFrom을 선호하라.
From::from()을 사용한다.TryFrom을 사용한다.as를 사용한다.(출처: delnan의 StackOverflow 답변과 추가 맥락에서 발췌.)
as 연산자는 범위를 줄이는 변환(narrowing conversion)에서는 안전하지 않다. 값을 조용히 잘라내며, 이는 예상치 못한 결과로 이어진다.
“범위를 줄이는 변환”이란 무엇인가? 예컨대 더 큰 타입에서 더 작은 타입으로 변환하는 것, 즉 i32 → i8 같은 경우다.
예를 들어, as가 값의 상위 비트를 잘라내는 모습을 보자:
fn main() {
let a: u16 = 0x1234;
let b: u8 = a as u8;
println!("0x{:04x}, 0x{:02x}", a, b); 0x1234, 0x34
}
따라서 위의 첫 예시로 돌아가면, 다음처럼 쓰는 대신
let x: i32 = 42;
let y: i8 = x as i8; // 오버플로 가능!
TryFrom을 사용해 에러를 우아하게 처리하자:
let y = i8::try_from(x).ok_or("여기서 사용할 수 없을 만큼 숫자가 큽니다")?;
경계를 갖는 타입은 불변식을 표현하고 잘못된 상태를 피하기 쉽게 해 준다. 예를 들어 어떤 숫자 타입에서 0이 절대 올바른 값이 아니라면 std::num::NonZeroUsize를 사용하자.
자신만의 경계 타입을 만들 수도 있다:
// 하지 마세요: 도메인 값에 생짜 숫자 타입 사용
struct Measurement {
distance: f64, 음수가 될 수 있음!
}
// 이렇게 하세요: 경계를 갖는 타입 만들기
#[derive(Debug, Clone, Copy)]
struct Distance(f64);
impl Distance {
pub fn new(value: f64) -> Result<Self, DistanceError> {
if value < 0.0 || !value.is_finite() {
return Err(DistanceError::Invalid);
}
Ok(Distance(value))
}
}
struct Measurement {
distance: Distance,
}
다음과 같은 코드를 보면 소름이 돋는다 😨:
let arr = [1, 2, 3];
let elem = arr[3]; // 어...? 큰일!
흔한 버그의 원인이다. C와는 달리 Rust는 배열 경계를 검사하여 보안 취약점을 막아 주지만, 그래도 런타임에 패닉한다.
대신 get 메서드를 사용하자:
let elem = arr.get(3);
이는 Option을 반환하므로, 이제 우아하게 처리할 수 있다. 자세한 내용은 이 블로그 글을 참고.
split_at_checked Instead Of split_at이 문제는 앞선 내용과 관련 있다. 어떤 슬라이스를 특정 인덱스에서 나누고 싶다고 하자.
let mid = 4;
let arr = [1, 2, 3];
let (left, right) = arr.split_at(mid);
첫 번째 슬라이스에 모든 원소가 들어가고, 두 번째 슬라이스가 빈 슬라이스가 되리라 기대할 수 있다.
⚠️ 하지만 위 코드는 중간 인덱스가 범위를 벗어나기 때문에 패닉한다!
더 우아하게 처리하려면 split_at_checked를 사용하자:
let arr = [1, 2, 3];
// 이는 Option을 반환한다
match arr.split_at_checked(mid) {
Some((left, right)) => {
left와 right로 무언가를 한다
}
None => {
에러를 처리한다
}
}
이는 Option을 반환하므로 에러 케이스를 처리할 수 있다. (Rust Playground)
split_at_checked에 대한 더 많은 정보는 여기.
모든 것에 원시 타입을 쓰고 싶은 유혹이 크다. 특히 Rust 초보자들이 빠지기 쉬운 함정이다.
// 하지 마세요: 사용자명에 원시 타입 사용
fn authenticate_user(username: String) {
날것의 String은 무엇이든 될 수 있음 - 빈 문자열, 너무 긴 문자열, 잘못된 문자 포함 등
}
그런데 정말 “아무” 문자열이나 유효한 사용자명으로 받아들일 건가? 비어 있으면? 이모지나 특수 문자가 포함되면? 아마도 기대와 다를 것이다.
대신 도메인을 위한 커스텀 타입을 만들자:
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Username(String);
impl Username {
pub fn new(name: &str) -> Result<Self, UsernameError> {
if name.is_empty() {
return Err(UsernameError::Empty);
}
if name.len() > 30 {
return Err(UsernameError::TooLong);
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(UsernameError::InvalidCharacters);
}
Ok(Username(name.to_string()))
}
내부 문자열에 대한 참조를 얻을 수 있게 해 준다
pub fn as_str(&self) -> &str {
&self.0
}
}
fn authenticate_user(username: Username) {
이 값은 항상 유효한 사용자명임을 알고 있다!
빈 문자열도, 이모지도, 공백도 없음 등
}
다음 코드에서 버그를 찾을 수 있겠는가?
// 하지 마세요: 잘못된 조합을 허용
struct Configuration {
port: u16,
host: String,
ssl: bool,
ssl_cert: Option<String>,
}
문제는 ssl이 true인데 ssl_cert는 None일 수 있다는 점이다. 이는 잘못된 상태다! SSL 연결을 사용하려 해도 인증서가 없어 사용할 수 없다.
타입을 사용해 유효한 상태만 허용하도록 만들면 이 문제를 컴파일 타임에 잡을 수 있다. 먼저 연결의 가능한 상태를 정의하자:
enum ConnectionSecurity {
Insecure,
인증서 없는 SSL 연결은 불가능!
Ssl { cert_path: String },
}
이제 잘못된 상태를 만들 수 없다! 인증서가 있는 SSL이거나, 아예 SSL이 아니거나 둘 중 하나다.
struct Configuration {
port: u16,
host: String,
security: ConnectionSecurity, 가능한 모든 상태가 유효!
}
앞 절과 비교하면, 이 버그는 서로 밀접한 필드들의 “잘못된 조합” 때문에 발생했다. 이를 막으려면 가능한 모든 상태와 상태 간 전이를 명확히 도식화하자. 간단한 방법은 각 상태에 선택적 메타데이터를 더한 enum을 정의하는 것이다.
여기서 배울 점은 Rust가 로직 버그로부터 여러분을 보호해 주지는 않는다는 것이다. 더 궁금하다면 보다 심층적인 블로그 글을 참고하자.
아무 생각 없이 타입에 포괄적인 Default 구현을 추가하는 경우가 흔하다. 하지만 이는 예기치 않은 문제로 이어질 수 있다.
예를 들어 아래처럼 기본 포트가 0으로 설정되는 경우가 있는데, 이는 유효한 포트 번호가 아니다. 2
// 하지 마세요: 고려 없이 `Default`를 구현
#[derive(Default)] // 잘못된 상태를 만들 수 있음!
struct ServerConfig {
port: u16, 0이 되며, 기대와 다를 수 있음
max_connections: usize,
timeout_seconds: u64,
}
대신 해당 타입에 기본값이 의미가 있는지 고민하자. 합리적인 기본값이 없다면 아예 Default를 구현하지 말고 사용자에게 명시하도록 하자.
// 이렇게 하세요: Default에 의미를 부여하거나, 아예 구현하지 않기
struct ServerConfig {
port: Port,
max_connections: NonZeroUsize,
timeout_seconds: Duration,
}
impl ServerConfig {
pub fn new(port: Port) -> Self {
Self {
port,
max_connections: NonZeroUsize::new(100).unwrap(),
timeout_seconds: Duration::from_secs(30),
}
}
}
Debug Safely관련 문제로 Debug 트레이트가 있다. Debug는 “디버깅 용도”로만 쓰일 것이라 무해하다고 생각할 수 있지만, 아무 타입에나 무턱대고 Debug를 derive하면 민감한 데이터가 노출될 수 있다. Debug는 종종 운영 환경의 로깅이나 에러 메시지에도 사용되기 때문이다. 민감한 정보를 담는 타입에는 Debug를 수동 구현하자.
// 이대로면 로그에 민감한 데이터가 노출된다!
#[derive(Debug)]
struct User {
username: String,
password: String, 평문으로 출력됨!
}
대신 다음처럼 작성할 수 있다:
#[derive(Debug)]
struct User {
username: String,
password: Password,
}
struct Password(String);
// 여기서는 비밀번호를 숨기기 위해 Debug를 수동 구현한다
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
User 인스턴스를 출력한다고 해 보자:
let user = User {
username: String::from("ferris"),
password: Password(String::from("supersecret")),
};
println!("{user:#?}");
출력은 다음과 같다:
User {
username: "ferris",
password: [REDACTED],
}
운영 코드에서는 secrecy 같은 크레이트 사용을 고려하자.
하지만 이 문제는 흑백논리처럼 단순하지도 않다. Debug를 수동 구현하면, 구조체가 바뀔 때 구현을 갱신하는 것을 잊을 수 있다. 흔한 패턴은 Debug 구현에서 구조체를 구조 분해해 이런 오류를 잡는 것이다.
다음처럼 하는 대신:
// 이렇게 하지 마세요
impl std::fmt::Debug for DatabaseURI {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}://{}:[REDACTED]@{}/{}", self.scheme, self.user, self.host, self.database)
}
}
변경을 잡아내기 위해 구조 분해를 써보자?
// 이렇게 하세요
impl std::fmt::Debug for DatabaseURI {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let DatabaseURI { scheme, user, password: _, host, database, } = self;
write!(f, "{scheme}://{user}:[REDACTED]@{host}/{database}")?;
Ok(())
}
}
힌트를 준 Wesley Moore(wezm)와 예시를 제공한 Simon Brüggen(m3t0r)에게 감사한다.
민감한 데이터에 대해 Serialize와 Deserialize를 무턱대고 derive하지 말자. 읽고/쓰는 값이 당신의 기대와 다를 수 있다!
#[derive(Serialize, Deserialize)]
struct UserCredentials {
#[serde(default)] ⚠️ 역직렬화 시 빈 문자열을 허용!
username: String,
#[serde(default)]
password: String, ⚠️ 직렬화 시 비밀번호가 그대로 노출!
}
역직렬화 시 필드가 비어 있을 수 있다. 빈 자격 증명은 적절히 처리하지 않으면 검증을 통과할 가능성이 있다.
게다가 직렬화 동작 또한 민감한 데이터를 유출할 수 있다. 기본적으로 Serialize는 password 필드를 직렬화 결과에 포함하므로, 로그나 API 응답, 디버그 출력 등에 민감한 자격 증명이 노출될 수 있다.
일반적인 해결책은 impl<'de> Deserialize<'de> for UserCredentials로 사용자 정의 역직렬화/직렬화를 구현하는 것이다.
장점은 입력 검증을 완전히 통제할 수 있다는 점이다. 단점은 모든 로직을 직접 구현해야 한다는 점이다.
대안으로 #[serde(try_from = "FromType")] 속성을 사용할 수 있다.
Password 필드를 예로 들어 보자. 우선 뉴타입 패턴으로 표준 타입을 감싸고 사용자 정의 검증을 추가한다:
#[derive(Deserialize)]
// serde에 `Password::try_from`을 `String`으로 호출하라고 알린다
#[serde(try_from = "String")]
pub struct Password(String);
이제 Password에 대해 TryFrom을 구현하자:
impl TryFrom<String> for Password {
type Error = PasswordError;
새 비밀번호를 생성
비밀번호가 너무 짧으면 에러를 던진다.
여기에 더 많은 검사를 추가할 수 있다.
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.len() < 8 {
return Err(PasswordError::TooShort);
}
Ok(Password(value))
}
}
이렇게 하면 더 이상 잘못된 비밀번호를 역직렬화할 수 없다:
// 패닉: 비밀번호가 너무 짧음!
let password: Password = serde_json::from_str(r#""pass""#).unwrap();
아이디어는 dev.to의 EqualMa 글과 힌트를 준 Alex Burka(durka)에게서 빌렸다.
조금 더 고급 주제지만 알아둘 가치가 있다. TOCTOU(Time-of-Check to Time-of-Use)는 조건을 확인하는 시점과 리소스를 사용하는 시점 사이의 변경으로 인해 발생하는 소프트웨어 버그의 한 분류다.
// 하지 마세요: 검사와 사용을 분리한 취약한 접근
fn remove_dir(path: &Path) -> io::Result<()> {
먼저 디렉터리인지 확인
if !path.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"not a directory"
));
}
TOCTOU 취약점: 위 검사와 아래 사용 사이에
해당 경로가 접근해서는 안 되는 디렉터리를 가리키는 심볼릭 링크로 바뀔 수 있다!
remove_dir_impl(path)
}
더 안전한 접근은 먼저 디렉터리를 열어, 우리가 검사한 것과 동일한 대상에 대해 작업하게 하는 것이다:
// 이렇게 하세요: 먼저 열고, 그다음 검사하는 더 안전한 접근
fn remove_dir(path: &Path) -> io::Result<()> {
심볼릭 링크를 따라가지 않고 디렉터리를 연다
let handle = OpenOptions::new()
.read(true)
디렉터리가 아니거나 심볼릭 링크면 실패
.custom_flags(O_NOFOLLOW | O_DIRECTORY)
.open(path)?;
이제 열린 핸들을 사용해 디렉터리 내용을 안전하게 제거할 수 있다
remove_dir_impl(&handle)
}
왜 더 안전한가? 우리가 핸들을 쥐고 있는 동안에는 디렉터리가 심볼릭 링크로 교체될 수 없기 때문이다. 이렇게 하면 작업 중인 디렉터리는 우리가 검사한 것과 동일하다. 교체 시도는 이미 핸들이 열려 있으므로 우리에게 영향을 주지 않는다.
이 문제를 간과하더라도 무리는 아니다. 사실 표준 라이브러리에서도 Rust 코어 팀이 이 문제를 놓친 적이 있다. 위의 코드는 std::fs::remove_dir_all 함수에서 실제로 발생했던 버그를 단순화한 버전이다. CVE-2022-21658에 관한 이 블로그 글에서 자세히 읽을 수 있다.
타이밍 공격은 애플리케이션에서 정보를 빼내는 교묘한 방법이다. 두 값을 비교하는 데 걸리는 시간이 그 값에 대한 정보를 새어나가게 할 수 있다는 아이디어다. 예컨대 두 문자열을 비교하는 데 걸리는 시간은 몇 글자가 맞았는지 드러낼 수 있다. 따라서 운영 코드에서 비밀번호 같은 민감 데이터를 다룰 때는 일반적인 동등성 비교에 주의하자.
// 하지 마세요: 민감한 비교에 일반 동등성 비교 사용
fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
stored == provided 타이밍 공격에 취약!
}
// 이렇게 하세요: 상수 시간 비교 사용
use subtle::{ConstantTimeEq, Choice};
fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
stored.ct_eq(provided).unwrap_u8() == 1
}
리소스 한도로 서비스 거부(DoS) 공격에 대비하자. 무제한 입력을 받아들이면 발생한다. 예컨대 메모리에 담을 수 없을 정도로 큰 요청 본문을 받는 경우다.
// 하지 마세요: 무제한 입력 허용
fn process_request(data: &[u8]) -> Result<(), Error> {
let decoded = decode_data(data)?; 엄청 클 수 있음!
디코딩된 데이터를 처리
Ok(())
}
대신 수용할 페이로드 크기를 명시적으로 제한하자:
const MAX_REQUEST_SIZE: usize = 1024 * 1024; // 1MiB
fn process_request(data: &[u8]) -> Result<(), Error> {
if data.len() > MAX_REQUEST_SIZE {
return Err(Error::RequestTooLarge);
}
let decoded = decode_data(data)?;
디코딩된 데이터를 처리
Ok(())
}
Path::join With Absolute Paths상대 경로와 절대 경로를 Path::join으로 결합하면, 상대 경로가 조용히 절대 경로로 대체된다.
use std::path::Path;
fn main() {
let path = Path::new("/usr").join("/local/bin");
println!("{path:?}"); "/local/bin"을 출력
}
이는 두 번째 경로가 절대 경로라면 Path::join이 그것을 그대로 반환하기 때문이다.
나만 이 동작에 혼란을 겪은 것이 아니다. 다음 스레드에는 Johannes Dahlström의 답변도 포함되어 있다:
이 동작은 유용합니다. 호출자는 (…) 상대 경로 또는 절대 경로 중 무엇을 사용할지 선택할 수 있고, 피호출자는 자신의 프리픽스를 추가하여 경로를 절대화하면 절대 경로는 영향을 받지 않으며, 이는 아마도 호출자가 원했던 바일 것입니다. 피호출자는 경로가 절대인지 별도로 확인할 필요가 없습니다.
그럼에도 나는 여전히 이것이 함정이라고 생각한다. 사용자 제공 경로를 사용할 때 이 동작을 간과하기 쉽다. 아마 join이 Result를 반환했어야 하지 않았을까? 어쨌든, 이 동작을 기억해 두자.
cargo-geiger지금까지는 자신의 코드에 관한 문제만 다뤘다. 운영 코드에서는 의존성도 점검해야 한다. 특히 unsafe 코드는 우려 사항이다. 의존성이 많다면 더더욱 어렵다.
cargo-geiger는 의존성의 unsafe 코드를 점검하는 유용한 도구다. 프로젝트의 잠재적인 보안 위험을 식별하는 데 도움을 준다.
cargo install cargo-geiger
cargo geiger
의존성에 unsafe 함수가 얼마나 있는지 보고서를 제공한다. 이를 바탕으로 해당 의존성을 계속 사용할지 판단할 수 있다.
다음은 이러한 문제를 컴파일 타임에 잡는 데 도움이 되는 clippy 린트 세트다. Rust playground에서 직접 확인해 보자.
요점은 다음과 같다:
cargo check는 아무 문제도 보고하지 않는다.cargo run은 런타임에 패닉하거나 조용히 실패한다.cargo clippy는 모든 문제를 컴파일 타임에 잡아낸다(!) 😎// 산술
#![deny(arithmetic_overflow)] // 정수 오버플로를 일으키는 연산 금지
#![deny(clippy::checked_conversions)] // 숫자 타입 간 변환에 검사되는 변환을 권장
#![deny(clippy::cast_possible_truncation)] // 캐스팅 시 값이 잘릴 가능성 감지
#![deny(clippy::cast_sign_loss)] // 캐스팅 시 부호 정보 손실 감지
#![deny(clippy::cast_possible_wrap)] // 캐스팅 시 값 래핑 가능성 감지
#![deny(clippy::cast_precision_loss)] // 캐스팅 시 정밀도 손실 감지
#![deny(clippy::integer_division)] // 정수 나눗셈 절단으로 인한 잠재적 버그 강조
#![deny(clippy::arithmetic_side_effects)] // 부작용이 있을 수 있는 산술 연산 감지
#![deny(clippy::unchecked_duration_subtraction)] // Duration 뺄셈 언더플로 방지
// unwrap 사용
#![warn(clippy::unwrap_used)] // 패닉을 유발할 수 있는 .unwrap() 사용 자제
#![warn(clippy::expect_used)] // 패닉을 유발할 수 있는 .expect() 사용 자제
#![deny(clippy::panicking_unwrap)] // 패닉을 일으킬 것이 확실한 값에 대한 unwrap 금지
#![deny(clippy::option_env_unwrap)] // 존재하지 않을 수 있는 환경변수 unwrap 금지
// 배열 인덱싱
#![deny(clippy::indexing_slicing)] // 직접 인덱싱을 피하고 .get() 같은 더 안전한 방법 사용
// 경로 처리
#![deny(clippy::join_absolute_paths)] // 절대 경로와의 조인 시 문제 방지
// 직렬화 이슈
#![deny(clippy::serde_api_misuse)] // Serde 직렬화/역직렬화 API의 잘못된 사용 방지
// 무제한 입력
#![deny(clippy::uninit_vec)] // 초기화되지 않은 벡터 생성 방지(unsafe)
// unsafe 코드 탐지
#![deny(clippy::transmute_int_to_char)] // 정수→문자 변환의 unsafe transmute 방지
#![deny(clippy::transmute_int_to_float)] // 정수→부동소수 변환의 unsafe transmute 방지
#![deny(clippy::transmute_ptr_to_ref)] // 포인터→참조 변환의 unsafe transmute 방지
#![deny(clippy::transmute_undefined_repr)] // 표현이 정의되지 않을 수 있는 transmute 감지
use std::path::Path;
use std::time::Duration;
fn main() {
산술 이슈
정수 오버플로: 디버그 모드에서는 패닉, 릴리스에서는 조용히 래핑됨
let a: u8 = 255;
let _b = a + 1;
안전하지 않은 캐스팅: 값이 잘릴 수 있음
let large_number: i64 = 1_000_000_000_000;
let _small_number: i32 = large_number as i32;
캐스팅 시 부호 손실
let negative: i32 = -5;
let _unsigned: u32 = negative as u32;
정수 나눗셈은 결과를 절단할 수 있음
let _result = 5 / 2; 결과는 2.5가 아니라 2
Duration 뺄셈은 언더플로 가능
let short = Duration::from_secs(1);
let long = Duration::from_secs(2);
let _negative = short - long; 언더플로 발생
unwrap 이슈
None일 수 있는 Option에서 unwrap 사용
let data: Option<i32> = None;
let _value = data.unwrap();
Err일 수 있는 Result에서 expect 사용
let result: Result<i32, &str> = Err("error occurred");
let _value = result.expect("This will panic");
존재하지 않을 수 있는 환경변수 가져오기
let _api_key = std::env::var("API_KEY").unwrap();
배열 인덱싱 이슈
경계 검사 없이 직접 인덱싱
let numbers = vec![1, 2, 3];
let _fourth = numbers[3]; 패닉 발생
.get()을 사용하는 안전한 대안
if let Some(fourth) = numbers.get(3) {
println!("{fourth}");
}
경로 처리 이슈
절대 경로와 조인하면 기준 경로가 버려짐
let base = Path::new("/home/user");
let _full_path = base.join("/etc/config"); 결과는 "/etc/config", base는 무시됨
안전한 대안
let base = Path::new("/home/user");
let relative = Path::new("config");
let full_path = base.join(relative);
println!("Safe path joining: {:?}", full_path);
unsafe 코드 이슈
초기화되지 않은 벡터 생성(정의되지 않은 동작을 일으킬 수 있음)
let mut vec: Vec<String> = Vec::with_capacity(10);
unsafe {
vec.set_len(10); }
}
휴, 함정이 정말 많았다! 이 중 얼마나 알고 있었는가?
Rust는 안전하고 신뢰할 수 있는 코드를 쓰기 위한 훌륭한 언어지만, 버그를 피하려면 개발자 스스로의 규율이 여전히 필요하다.
우리가 본 흔한 실수의 상당수는 Rust가 시스템 프로그래밍 언어라는 사실과 관련 있다. 컴퓨팅 시스템에서는 많은 연산이 성능에 민감하고 본질적으로 위험하다. 우리는 운영체제, 하드웨어, 네트워크처럼 통제할 수 없는 외부 시스템을 상대한다. 목표는 위험한 세계 위에 안전한 추상화를 구축하는 것이다.
Rust는 C와 FFI 인터페이스를 공유하므로 C가 할 수 있는 것은 무엇이든 할 수 있다. 따라서 Rust가 허용하는 몇몇 연산은 이론적으로 가능하지만, 예상치 못한 결과를 낳을 수 있다.
하지만 모든 게 끝난 것은 아니다! 이러한 함정을 알고 있다면 피할 수 있고, 위의 Clippy 린트로 대부분을 컴파일 타임에 잡아낼 수 있다.
그래서 테스트, 린팅, 퍼징은 Rust에서도 여전히 중요하다.
최대한의 견고함을 위해 Rust의 안전 보증을 엄격한 검사와 강력한 검증 기법과 결합하자.
이 글이 도움이 되었길 바란다! Rust 코드를 한 단계 끌어올리고 싶다면 전문가의 코드 리뷰를 고려해 보라. 나는 규모와 상관없이 Rust 프로젝트에 대한 코드 리뷰를 제공한다. 더 알아보려면 문의하기.
래핑과 포화(saturating) 연산을 위한 메서드들도 있으며, 경우에 따라 유용하다. 더 알아보려면 std::intrinsics 문서를 확인하자. ↩
포트 0은 보통 OS가 임의의 포트를 할당해 준다는 뜻이다. 따라서 TcpListener::bind("127.0.0.1:0").unwrap()은 유효하지만, 모든 운영체제가 지원하는 것은 아닐 수 있고 기대와 다를 수도 있다. 자세한 내용은 TcpListener::bind 문서를 참고. ↩