Rust에서 블록이 표현식이라는 성질을 활용해 코드의 의도를 더 분명히 하고, 스코프와 가변성을 제한하며, 네임스페이스 오염을 줄이는 ‘블록 패턴’이라는 관용구를 소개한다.
세계 최고의 notgull 소스
Home Projects Git Search About Archive
John Nunley · 2025년 12월 18일
여기, 어디에서도 딱히 논의되는 걸 본 적은 없지만 Rust 코드를 훨씬 더 깔끔하고 견고하게 만들어 준다고 생각하는 작은 관용구가 하나 있다.
이 관용구에 실제로 통용되는 이름이 있는지는 모르겠다. 더 나은 단어가 떠오르지 않아서 나는 이를 “블록 패턴(block pattern)”이라고 부르고 있다. 코드에서 꽤 자주 손이 가는 방식이고, 다른 Rust 코드들도 이 패턴을 따르면 더 깔끔해질 수 있다고 생각한다. 만약 기존에 널리 쓰이는 이름이 있다면 알려 달라!
이 패턴은 Rust에서 블록이 유효한 표현식(expression) 이라는 점에서 나온다. 예를 들어 이 코드는:
let foo = { 1 + 2 };
…이 코드와 동일하다:
let foo = 1 + 2;
…그리고 이는 다시 이 코드와도 동일하다:
let foo = {
let x = 1;
let y = 2;
x + y
};
어떤 함수가 설정 파일을 로드한 다음, 그 설정 파일을 바탕으로 몇 개의 HTTP 요청을 보낸다고 해보자. 설정 파일을 로드하려면 먼저 디스크에서 그 파일의 원시 바이트(raw bytes)를 읽어 와야 한다. 그런 다음 설정 파일 형식에 맞게 파싱해야 한다. 이 패턴의 가치를 보여 주기 위해 충분히 복잡한 프로그램을 가정해 보자. 설정 파일은 “주석이 있는 JSON”이라고 하자. 그러면 먼저 plaintext regex 크레이트를 이용해 주석을 제거한 뒤, plaintext serde-json 같은 것을 사용해 남은 JSON을 파싱해야 한다.
그런 함수는 대략 이렇게 생겼을 것이다:
use regex::{Regex, RegexBuilder};
use std::{fs, sync::LazyLock};
/// 설정 파일의 형식.
#[derive(serde::Deserialize)]
struct Config { /* ... */ }
// 정규식은 항상 캐싱해 두자!
static STRIP_COMMENTS: LazyLock<Regex> = LazyLock::new(|| {
RegexBuilder::new(r"//.*").multi_line(true).build().expect("regex build failed")
});
/// 설정을 로드하고 몇 개의 HTTP 요청을 보내는 함수.
fn foo(cfg_file: &str) -> anyhow::Result<()> {
// 파일의 원시 바이트를 로드한다.
let config_data = fs::read(cfg_file)?;
// 정규식이 적용될 수 있도록 문자열로 변환한다.
let config_string = String::from_utf8(&config_data)?;
// 모든 주석을 제거한다.
let stripped_data = STRIP_COMMENTS.replace(&config_string, "");
// JSON으로 파싱한다.
let config = serde_json::from_str(&stripped_data)?;
// 이 데이터를 바탕으로 작업을 수행한다.
send_http_request(&config.url1)?;
send_http_request(&config.url2)?;
send_http_request(&config.url3)?;
Ok(())
}
이 코드는 꽤 단순하고, JSON을 파싱하고 뭔가를 하기 위해 몇 가지 Rust 크레이트와 언어 기능을 활용하고 있을 뿐이다.
하지만 여기에는 몇 가지 약점이 있다.
plaintextfoo
함수에서 설정을 파싱하기 위해 새로운 변수 네 개(
plaintextconfig_data
,
plaintextconfig_string
,
plaintextstripped_data
,
plaintextconfig
)를 선언했지만, 그중 설정 파싱 이후에 실제로 사용되는 변수는 오직 하나(
plaintextconfig
)뿐이다. 게다가, 만약 이 코드가 무엇을 하는지 사전에 모르고 있었고, 이런 주석이 없다면(혹은 주석이 부정확하다면) 왜 정규식
plaintextSTRIP_COMMENTS
를 선언하는지, 왜 파일에서 데이터를 로드하는지 의문이 들 수도 있다.
나는 코드를 쓸 때 코드의 목적이 무엇인지, 그리고 왜 이런 방식으로 작성되었는지가 즉시 분명해지도록 하려고 한다. 그래서 나는 일반적으로 C의 “바텀업(bottom-up)” 방식으로 코드를 조직하는 전략을 피한다. 마치 나사 몇 개를 던져 주고는, 그걸 의도적으로 의자를 만들라는 걸 암묵적으로 이해하라고 기대하는 것과 같다. Rust에서는 최상위 함수를 먼저 정의하고, 그 다음 아래로 내려가며 필요한 조각들을 정의할 수 있다는 점이 마음에 든다.
그런데, 우리는 여기서 조금 더 나아갈 수 있다.
plaintextfoo
함수를 이렇게 정리해 보면 어떨까?
/// 설정을 로드하고 몇 개의 HTTP 요청을 보내는 함수.
fn foo(cfg_file: &str) -> anyhow::Result<()> {
// 파일에서 설정을 로드한다.
let config = {
// 주석 제거를 위한 캐시된 정규식.
static STRIP_COMMENTS: LazyLock<Regex> = LazyLock::new(|| {
RegexBuilder::new(r"//.*").multi_line(true).build().expect("regex build failed")
});
// 파일의 원시 바이트를 로드한다.
let raw_data = fs::read(cfg_file)?;
// 정규식이 적용될 수 있도록 문자열로 변환한다.
let data_string = String::from_utf8(&raw_data)?;
// 모든 주석을 제거한다.
let stripped_data = STRIP_COMMENTS.replace(&config_string, "");
// JSON으로 파싱한다.
serde_json::from_str(&stripped_data)?
};
// 이 데이터를 바탕으로 작업을 수행한다.
send_http_request(&config.url1)?;
send_http_request(&config.url2)?;
send_http_request(&config.url3)?;
Ok(())
}
이 함수에서는 설정과 관련된 모든 코드(파싱, 로딩, 심지어 static 정규식까지)를 블록 안으로 옮겼다. 이는 Rust가 블록 안에 아이템(item), 문(statement), 표현식(expression)을 둘 수 있게 해 주기 때문에 가능하다. 그래서 모든 것을 블록 안으로 옮길 수 있었다. 이 패턴에는 즉각적인 장점이 세 가지 있다:
plaintextlet config = ...
)에서 시작한다. 어떤 종류의 설정 객체를 해결(resolve)하려고 한다는 것을 처음부터 바로 알 수 있다. 그 다음에야 구현 세부사항으로 들어간다. *
plaintextfoo
함수와 최상위 모듈 양쪽의 네임스페이스 오염을 줄인다. 이제
plaintextfoo
안에서는
plaintextconfig_data
,
plaintextconfig_string
등의 변수 이름을 더 이상 쓰지 않는다. 이렇게 하면 이런 변수 이름을 재사용할 수도 있고, 코드가 훨씬 더 “바보 방지(idiot-proof)”가 된다. 누군가
plaintextfoo
함수를 수정하더라도, 그들이 사용할 수 있는 것은
plaintextconfig
뿐이다.
plaintextraw_data
나
plaintextSTRIP_COMMENTS
같은 것들은
plaintextconfig
파서에만 쓰이도록 의도된 것이고 블록 밖에서는 접근할 수 없다. *
plaintextraw_data
와
plaintextdata_string
변수는 블록 끝에서 스코프를 벗어나므로 drop되어 리소스를 해제한다.
덧붙이자면, 위의 세 가지 장점은 이 블록을 별도의 함수로 리팩터링해도 얻을 수 있다. 하지만 이 패턴은 그 방식에 비해 핵심적인 장점이 두 가지 있다:
위 예제에는 드러나지 않은 이점이 하나 더 있다: 가변성(mutability)의 소거(erasure) 이다. 나중에 함수의 다른 부분에서 사용할 어떤 객체를 구성한다고 해보자:
let mut data = vec![];
data.push(1);
data.extend_from_slice(&[4, 5, 6, 7]);
data.iter().for_each(|x| println!("{x}"));
return data[2];
문제는
plaintextdata
가 가변으로 선언되어 있어서, 함수의 나머지 부분에서도 이를 변경할 수 있다는 점이다. 많은 버그는 “변경되면 안 되는 데이터가 변경되는 것”에서 오기 때문에, 데이터의 가변성을 함수의 특정 영역으로 제한하고 싶다. 블록 패턴을 쓰면 이것도 가능하다:
let data = {
let mut data = vec![];
data.push(1);
data.extend_from_slice(&[4, 5, 6, 7]);
data
};
data.iter().for_each(|x| println!("{x}"));
return data[2];
이렇게 하면 가변성이 함수의 특정 구간 안으로 효과적으로 “닫힌다(closes)”.
이 패턴이 Rust 커뮤니티에서 이미 잘 알려져 있는지 나는 모르겠다. 설령 그렇다 하더라도, Rust에 익숙하지 않은 사람들에게 이 패턴을 소개하는 건 여전히 좋은 일이라고 생각한다.
이 웹사이트의 소스 코드는 Codeberg에 호스팅되어 있다.
위에 표현된 모든 의견은 전적으로 내 개인 의견이며, 과거/현재/미래의 어떤 고용주를 대표하지 않는다.