Rust 코드 `*pointer = blah;`가 어떻게 해석되는지, 어휘/구문/의미 분석의 흐름으로 살펴보며, 할당식에서의 장소(place)와 값(value), 경로/역참조/`Deref` 동작을 예제로 설명합니다.
2025년 4월 7일
얼마 전, 누군가 인터넷에서 Rust의 다음 문법에 대해 물었습니다:
*pointer_of_some_kind = blah;
그들은 이 코드가 컴파일러에 의해 어떻게 이해되는지, 특히 포인터가 레퍼런스가 아니라 스마트 포인터인 경우에는 어떻게 되는지 궁금해했어요. 저는 길게 답변을 달았는데, 더 넓은 분들이 흥미로워할 수도 있을 것 같아 글로 정리해 확장해 보았습니다.
저는 Rust 컴파일러 팀에서 일하지도 않았고, 사실상 일해 본 적도 없습니다. 다만 언어 의미론은 잘 압니다. 언어 덕후라면, 이 글은 Rust의 값 범주를 배우는 것 말고는 크게 새로울 게 없을지도 몰라요. 하지만 프로그래밍 언어의 세부에 많은 시간을 써본 적이 없다면, 이 세계를 엿볼 수 있는 좋은 기회가 되길 바랍니다.
프로그래밍 언어는 사람이 쓰는 언어와 같은 의미에서의 언어입니다. 음, 대부분은요. 어쨌든 요점은, 다음과 같은 Rust 코드를 이해하려면:
*pointer_of_some_kind = blah;
다음과 같은 “영어 코드”를 이해하려 할 때와 비슷한 도구를 적용할 수 있다는 겁니다:
You can't judge a book by its cover.
또 다음 단락들을 읽으며 “왜 단계가 이렇게 많지?”라고 스스로 묻고 있을지도 모르겠어요. 짧은 대답은 고전적인 바로 그것: “이게 무슨 뜻이냐?”라는 큰 문제를 더 작은 단계로 나누면 각 단계가 쉬워진다는 겁니다. 한 번에 다 하려면 작은 단계 여러 개를 하는 것보다 훨씬 어렵죠. 여기서는 전통적인 컴파일러 접근을 다룹니다. 현대적인 기법으로 가면 단계가 섞이기도 하고, 순서가 바뀌기도 하고, 별의별 변화가 있어요. 오류 처리만 해도 엄청난 주제죠! 이 글은 출발점일 뿐, 종착점은 아닙니다.
자, 시작해 봅시다.
우리가 가장 먼저 하고 싶은 일은 이 “단어들”이 정말 유효한 단어인지부터 확인하는 것입니다. 컴퓨터 언어에서는 이 과정을 “어휘 분석(lexical analysis)”이라고 부르지만, “토크나이징(tokenizing)”이라고 부르기도 합니다. 즉, 이 단계에서는 의미에는 관심이 없고, 입력이 대체 무엇으로 이뤄져 있는지 알아내는 데에만 집중합니다.
예를 들어 이 영어 문장을 봅시다:
You can't judge a book by its cover.
이를 토큰화하기 위해 두 단계를 거칩니다. 먼저 “스캔”을 통해 “렉심(lexeme)”의 시퀀스를 만듭니다. 규칙을 따라 그렇게 하죠. 영어 규칙을 여기서 자세히 설명하진 않겠습니다만, 대략 이런 결과가 나올 수 있습니다:
You
can't
judge
a
book
by
its
cover
.
can't에는 '가 붙어 있지만, .는 cover와 분리되어 있다는 점에 주목하세요. 이런 식의 규칙을 따릅니다. '는 축약형이기 때문에 단어의 일부지만, .는 cover의 일부가 아니라 별개의 기호이기 때문이죠.
그 다음, 각 문자열을 평가해 “토큰(token)”으로 바꿉니다. 토큰은 (아마) 컴파일러 안에서 어떤 데이터 타입입니다. 예를 들어 Rust에서는 이렇게 할 수 있겠죠:
enum Token {
Word(String),
Punctuation(String),
}
그래서 토크나이저의 출력은 대략 이런 배열일 수 있습니다:
[
Word("You"),
Word("can't"),
Word("judge"),
Word("a"),
Word("book"),
Word("by"),
Word("its"),
Word("cover"),
Punctuation("."),
]
이 시점에서 우리는 뭔가 반쯤은 일관성 있는 것을 갖고 있지만, 여전히 이것이 올바른지는 확신할 수 없습니다. 다음 단계로 가봅시다!
재미있게도, 이 부분은 인간 언어학과 컴파일러에서 약간 다른 의미로 쓰이곤 합니다. 자연언어에서는 파싱이 다음 단계인 의미 분석과 종종 결합되지만, 컴파일러에서는 (대부분의 경우) 구문과 의미 분석을 분리하려고 합니다.
다시, 영어를 크게 단순화해 보겠습니다. 문장에 대한 규칙을 이렇게 정하죠:
물론 영어의 극히 일부분만 반영했지만, 요지는 전달됩니다. 구문 분석의 목표는 토큰 시퀀스를 더 다루기 쉬운 풍부한 데이터 구조로 바꾸는 것입니다. 즉, 입력이 유효한 문자 시퀀스로 구성되어 있다는 건 알았지만, 이제 그게 우리 언어의 문법 규칙을 만족하는지가 궁금한 단계죠. 또 모든 것을 저장할 필요는 없습니다. 예를 들어 영어용 데이터 구조가 이렇게 생겼다고 해봅시다:
struct Sentence {
subject: String,
words: Vec<String>,
}
마침표는 어디 갔죠? subject가 대문자로 시작해야 한다는 건 어떻게 알죠? 그건 구문 분석의 역할입니다. 모든 문장은 마침표로 끝난다고 가정했다면, 데이터 구조에 굳이 저장할 필요가 없습니다. 분석 과정에서 그 사실을 확인했으니 다시 저장하지 않아도 되죠. 마찬가지로, subject를 굳이 대문자 문자열로 저장하지 않아도 됩니다. 입력에서 그랬다는 걸 확인했으니 필요에 맞게 변환할 수 있어요. 그래서 구문 분석 이후 값은 대략 이렇게 생겼을 수 있습니다:
Sentence {
subject: "you",
words: ["can't", "judge", "a", "book", "by", "its", "cover"],
}
컴퓨터 언어에서는 트리 같은 구조가 유용한 경우가 많아서, 이 단계 끝에 “추상 구문 트리(AST)”가 만들어지곤 합니다. 하지만 반드시 그래야 하는 건 아니고, 상황에 맞는 데이터 구조면 충분합니다.
이제 더 풍부한 데이터 구조를 얻었으니 거의 다 왔습니다. 다음은 의미로 들어가야죠.
우리의 문장이 “You can’t judge a book by its cover.”가 아니라, 대신 다음과 같다고 상상해 봅시다:
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
유명하지만 비문인 텍스트입니다. 단어들은 모두 라틴어 단어이고, 문장처럼 느껴지긴 하지만, 실제로는 무의미하죠. 우리는 이것을 Sentence로 파싱할 수 있습니다:
Sentence {
subject: "Lorem",
words: ["ipsum", "dolor", "sit", "amet", /* 이하 계속 */ ],
}
하지만 이것은 유효하지 않습니다. 그걸 어떻게 판단하죠?
영어라는 맥락에서는 “Lorem”이 유효한 영어 단어가 아니기 때문입니다. 그래서 주어가 유효한 단어인지 검사한다면, 이 문장을 적절히 거부할 수 있습니다. 컴퓨터 언어에서도 비슷한 일을 합니다. 예를 들어 5 + "hello"는 어휘 분석과 구문 분석은 무사히 통과할 수 있지만, 의미를 파악하려 하면 말이 안 된다는 걸 알게 됩니다. 물론 숫자와 문자열을 더하게 놔두는 언어라면 예외겠죠!
의미 분석을 통과했다면, 우리는 프로그램이 “올바르게 작성되었다(well formed)”고 판단한 것입니다. 컴파일러라면 그 다음에 기계어 코드나 바이트코드 생성 단계로 넘어가겠죠. 하지만 그 또한 매우 흥미롭지만 여기의 주제는 아닙니다. 우리의 원래 목표를 기억하세요. 이해하려는 건 바로 이것이었죠:
*pointer_of_some_kind = blah;
이건 의미론의 영역입니다. 이제 배경지식도 쌓았으니, 이 코드를 어떻게 이해하는지 이야기해 봅시다.
코드를 이해하려면, 먼저 이 코드가 어떻게 어휘/구문 분석되는지를 알아야 합니다. 즉, 우리 언어의 문법 이 무엇인지 알아야 하죠. 언어가 입력을 어떻게 렉싱하고, 토큰화하고, 파싱하는가 말입니다. 여기서는 Rust를 다룹니다. Rust의 문법은 크고 복잡하니, 오늘은 그 일부만 이야기하겠습니다. 문장(statement)과 식(expression)에 집중하죠.
Rust를 “식 기반 언어”라고 들어본 적이 있을 겁니다. 바로 그 얘기예요. 프로그램에서 우리가 말하는 대부분의 것들은 대개 이 둘 중 하나입니다. 식은 어떤 값을 만들어내고, 문장은 식의 평가를 순서화하는 데 쓰입니다. 다소 추상적이라, 구체적으로 살펴보죠.
Rust에는 몇 가지 종류의 문장이 있습니다. “선언문(declaration statements)”과 “식 문장(expression statements)”이 있고, 각각 하위 종류가 있어요.
선언문에는 두 가지가 있습니다. 아이템 선언(item declarations) 과 let 문장 입니다. 아이템 선언은 mod, struct, fn 같은 것들로, 무언가가 존재함을 선언합니다. let 문장은 아마 Rust에서 가장 유명한 형태의 문장일 겁니다. 모양은 이렇습니다:
OuterAttribute* let PatternNoTopAlt ( : Type )? (= Expression † ( else BlockExpression) ? ) ? ;
꽤… 장황하죠. 아직 *나 ?에 대해서 이야기하지 않았고, Rust의 더 이국적인 부분도 지금은 다루지 않으려 합니다. 그래서 더 단순한 문법으로 먼저 이야기하겠습니다:
let Variable = Expression;
이게 Rust에서 새 변수를 만드는 방법입니다. let을 쓰고, 이름을 쓰고, =를 쓰고, 마지막으로 어떤 식을 씁니다. 그 식을 평가한 결과가 변수의 값이 됩니다.
물론 많이 생략했습니다. 이름은 사실 단순한 이름이 아니라 “패턴”이고, 아주 멋진 기능이죠. 이제는 let else도 Rust에 있고, 그것도 멋집니다. 여기서는 타입을 무시하고 있어요. 그래도 이 단순한 버전만으로도 기본은 파악할 수 있습니다.
식 문장은 훨씬 단순합니다:
ExpressionWithoutBlock ; | ExpressionWithBlock ;?
여기서 |는 또는(or)입니다. 세미콜론 ;으로 끝나는 단일 식을 쓰거나, 블록(중괄호 {}로 둘러싼 것)을 쓰되, 그 뒤에 세미콜론을 선택적으로(?는 있어도 되고 없어도 됨) 붙일 수 있습니다.
컴파일러처럼 생각하면, 이런 규칙들을 어떻게 조합할지 떠올릴 수 있습니다. 예를 들어:
let x = {
5 + 6
};
여기서는 let 문장이 있고, = 오른쪽의 식은 ExpressionWithBlock입니다. 깜짝 퀴즈: 세미콜론 ;은 let의 일부일까요, 아니면 오른쪽 식의 일부일까요?
정답은, let의 일부입니다. let 문장은 필수 ;를 가지지만, 블록은 그렇지 않으므로:
let x = ExpressionWithBlock;
만약 블록에 세미콜론을 붙였더라도, let의 세미콜론이 또 필요하니 };;처럼 됩니다. 컴파일러는 이를 받아들이지만 경고를 냅니다.
이제 원래 코드로 돌아가 봅시다:
*pointer_of_some_kind = blah;
여기에는 let이 없고, 아이템 선언도 아닙니다. 즉, 식 문장입니다. ExpressionWithoutBlock 뒤에 ;가 오죠. 그러니 이제 식을 이야기해야 합니다.
Rust에는 정말 많은 식 종류가 있습니다. 레퍼런스 8.2절에는 무려 19개의 하위 절이 있어요. 헉! 이번 경우 이 코드는 연산자 식(Operator Expression), 그중에서도 할당식(Assignment Expression)입니다:
Expression = Expression
간단하죠! =의 왼쪽은 *pointer_of_some_kind라는 식이고, 오른쪽은 blah라는 식입니다. 쉽습니다!
하지만 이 두 식이야말로 제가 이 글을 쓴 진짜 이유와 맞닿아 있습니다. 드디어 여기에 도달했네요! 레퍼런스에는 할당식에 대해 이렇게 적혀 있습니다:
할당식은 지정된 장소(place)에 값을 이동(move)시킨다.
그 “장소”가 뭔가요?
C와 예전 C++에서는 이 둘을 각각 “lvalue”와 “rvalue”라고 불렀습니다. =의 왼쪽(left)과 오른쪽(right)이라는 뜻이죠. 최신 C++ 표준에는 더 많은 범주가 있습니다. Rust는 그 중간쯤에 서 있습니다. C처럼 두 가지 범주만 두지만, C++의 두 범주에 더 깔끔히 대응되도록요. Rust는 lvalue(왼쪽)를 “장소(place)”, rvalue(오른쪽)를 “값(value)”이라고 부릅니다. Unsafe Code Guidelines에 있는 좀 더 정확한 정의를 보죠:
이 둘 모두 “표현식” 형태가 있어서, 장소 표현식(place expression) 은 평가되면 장소를 만들고, 값 표현식(value expression) 은 평가되면 값을 만듭니다. 그리고 =는 이렇게 동작합니다. 왼쪽에 장소 표현식, 오른쪽에 값 표현식이 있고, 그 값을 그 장소에 넣습니다. 간단하죠!
다시 우리가 이해하려던 코드:
*pointer_of_some_kind = blah;
여기서 *(역참조 연산자)는 포인터를 받아, 그 포인터가 가리키는 장소 (주소)로 평가됩니다. 그리고 blah는 그곳에 넣을 값 을 만듭니다.
이제 끝? 아직 아닙니다!
Rust에는 Deref라는 트레이트가 있어 * 연산자의 동작을 오버로드(재정의)할 수 있습니다. 더 쉬운 예제를 통해 이야기해 봅시다:
use std::ops::{Deref, DerefMut};
struct DerefMutExample<T> {
value: T
}
impl<T> Deref for DerefMutExample<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<T> DerefMut for DerefMutExample<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.value
}
}
fn main () {
let mut x = DerefMutExample { value: 'a' };
*x = 'b';
assert_eq!('b', x.value);
}
직접 놀아보고 싶다면 여기를 보세요.
아직 이야기하지 않은 식의 한 종류가 있습니다. 경로 표현식(path expression) 입니다.
지역 변수나 static 변수로 해석되는 경로 표현식은 장소 표현식이고, 그 밖의 경로는 값 표현식이다.
앞서 let을 이야기했죠. 위 코드의 let mut x = DerefMutExample { value: 'a' };에서 x는 경로 표현식이고, 새로 만든 변수로 해석되므로 장소 표현식입니다. 반면 DerefMutExample { value: 'a' }는 변수로 해석되지 않으므로 값 표현식입니다.
*x = 'b';를 이야기해 봅시다. 우리의 할당식:
expression = expression;
그리고 그 동작: 값을 어떤 장소로 이동시킵니다.
*가 어떻게 동작하는지 이해하려면 한 가지만 더 추가하면 됩니다. 바로 “역참조 표현식(dereference expression)”. 이는 * 연산자에 의해 만들어집니다. 모양은 이렇습니다:
*expression
의미는 꽤 직관적입니다.
&T, &mut T, *const T, *mut T라면, 그것이 가리키는 값의 장소로 평가되고, 가변성(mutability)도 동일하게 부여된다.*std::ops::Deref::deref(&x)와, 가변인 경우 *std::ops::DerefMut::deref_mut(&mut x)와 동등하게 확장된다.이게 전부입니다. 이제 *x = 'b'를 완전히 이해할 수 있습니다:
'b'는 값 표현식이다.*x는 포인터 타입이 아니므로 *std::ops::DerefMut::deref_mut(&mut x)로 확장해 다시 본다.std::ops::DerefMut::deref_mut(&mut x)는 이 경우 타입 &mut char를 반환하며, 이는 self.value의 장소(여기서는 줄여서 <that place>라고 하겠습니다)를 가리키고 있습니다. 현재 그 장소에는 'a'가 저장되어 있습니다. 이제 우리는 *&mut <that place>를 갖게 됩니다.&mut T이므로, *&mut <that place>는 <that place>를 가리킵니다.<that place> = 'b'가 되었고, 그 장소로 'b'를 이동합니다.후우! 끝났습니다.
컴파일러처럼 생각하는 것은 꽤 재밌습니다! 문법(grammars)의 개념에 익숙해지고 치환(substitution)에 익숙해지면 온갖 흥미로운 것들을 스스로 풀어낼 수 있어요. 이 구체적인 사례에서, 사람들은 종종 “가리키는 곳을 보여주는 게 목적이라면 왜 Deref는 참조를 반환하죠?”라고 궁금해합니다. 만약 역참조 표현식이 내부적으로 *가 들어간 무언가로 확장된다는 사실을 모른다면 꽤 혼란스러울 수 있죠! 하지만 이제는 알고 있습니다. 그리고 그 과정에서 값과 장소, 그리고 컴파일러가 코드를 어떻게 생각하는지에 대해 흥미로운 것들을 배우셨길 바랍니다.
이 글에 대한 제 BlueSky 게시물은 여기: