Rust 트레이트 해결기에서 발생하는 미묘한 제한과 그 근본 원인을 예제와 함께 설명하고, 실제 코드에서 왜 문제가 되는지와 우회 방법, 그리고 오류 메시지가 왜 혼란스러울 수 있는지를 다룹니다.
저는 러스트 컴파일러에서 … 버그(?) … 제한(?) … 이슈(!) 같은 것을 하나 우연히 발견했습니다. 다행히 Rust 프로그래밍 언어 포럼에서 무슨 일이 일어나고 있는지 알아낼 수 있었죠. 이 이슈가 충분히 흥미롭고 또 미묘하다고 느껴져서 블로그 독자분들께 설명해 보려 합니다.
명확히 하자면, 이것은 실제 이슈입니다. Rust의 트레이트 해결기(trait solver)의 제한입니다. Rust 컴파일러의 메인테이너들이 어떤 원칙적 이유로 Rust를 이렇게 설계한 것은 아닙니다. Rust가 반드시 이 제한을 가져야만 하는 특별히 강한 이론적 이유도 없습니다.
즉, 이 이슈를 이해하는 것이 Rust를 이해하는 데 도움은 되지만, 어느 정도는 그냥 우연의 산물입니다. 제 설명을 읽는 동안, 제가 Rust가 실제로 어떻게 동작하는지를 설명함으로써 왜 이런 제한이 “실제로 구현된 방식”에서 가능한지를 보여주려 한다는 점을 염두에 두세요. 이것이 왜 필연적이거나 근본적인 제한인지를 설명하는 것이 아닙니다. 실제로 그렇지 않으며, 고칠 수도 있고 언젠가 정말로 고쳐질 수도 있는 종류의 제한입니다.
아래는 문제를 최소한으로 재현한 제 버전이며, Rust playground 퍼머링크로도 볼 수 있습니다:
use std::marker::PhantomData;
pub trait Simple {
type State;
fn do_stuff();
}
struct Parameterized<T> {
_phantom: PhantomData<T>,
}
impl<T> Simple for Parameterized<T> {
type State = u32;
fn do_stuff()
// where Self: Simple
{
let state: Self::State = 3;
println!("{state}");
}
}
주석 처리된 where 절을 해제하면, 갑자기 컴파일러가 do_stuff() 함수의 스코프 안에서 Self::State와 u32가 같은 타입이라는 사실을 잊어버립니다. 잘못된 타입의 변수에 대입한다고 불평하죠. 심지어 <Parameterized<T> as Simple>::State가 도대체 어떤 타입인지 알 수 없다고까지 합니다! 이상하죠!
where 절이 없으면 잘 동작합니다. 매개변수화된 타입을 쓰지 않으면 잘 동작합니다. 하지만 매개변수화된 타입에 where 절을 붙이면 망가집니다.
명확히 하자면, where 절을 주석 해제했다고 해서 즉시 에러가 나는 것은 아닙니다. 비록 그 where 절이 trait 정의에는 없더라도요. 일반적으로 컴파일러는 트레이트 구현에서, 트레이트의 시그니처가 호출될 수 있는 모든 곳에서 구현이 유효한 한, 중복되는 where 절을 추가하는 것을 허용합니다. 또한 사용하지 않는 where 절이나 기타 트레이트 바운드를 제거할 수도 있습니다. 예컨대 아래 코드는 전혀 문제 없습니다:
pub trait Simple {
fn do_something<T: Clone>(&self, other: T);
}
impl Simple for u32 {
fn do_something<T>(&self, _other: T) where u32: Simple {}
}
여기서 <u32 as Simple>::do_something()는 같은 수의 타입 매개변수, 같은 수의 일반 매개변수를 받고, 전체 제약 집합이 Simple::do_something()보다 덜 제한적입니다. 따라서 비록 시그니처가 달라 보이더라도 여전히 적합합니다. 구현에서는 : Clone이 필요하지 않으니 명시하지 않아도 되었던 거죠.
하지만 그렇다고 해서 이제 Clone을 구현하지 않은 값으로 <u32 as Simple>::do_something()를 호출할 수 있다는 뜻은 아닙니다. 아래 코드는 여전히 에러입니다:
fn main() {
struct Foo;
1u32.do_something(Foo);
}
아니요. <u32 as Simple>::do_something()의 내부는 우리가 작성한, 그 특정 구현의 시그니처로 컴파일되지만, 외부에서의 호출은 여전히 그 함수를 호출할 수 있는 곳인지 여부를 확인하기 위해, 그 트레이트에 정의된 함수 시그니처에 대해 검증됩니다. 심지어 구체 타입 값에 직접 호출하더라도요.
함수 시그니처는 호출자(caller)와 피호출자(callee) 간의 계약입니다. 호출자가 어떤 타입의 값을 인자로 제공해야 하는지(그리고 피호출자는 그 값을 받을 수 있다고 기대할 수 있음), 피호출자가 어떤 타입의 값을 반환할 수 있는지(그리고 호출자는 그 값을 받을 수 있다고 기대할 수 있음)를 명시합니다. 이 관점에서 T: Clone의 : Clone이나 where Self: Simple 같은 where 절은 계약에 더해지는 약속으로, 호출자가 피호출자에게 추가로 제공하는 보증이며, 따라서 피호출자가 의존할 수 있는 추가 보장입니다.
각 트레이트 바운드(제약이라고도 부릅니다. 말 그대로 제한입니다)는 그 함수 호출에 허용되는 타입을 제한합니다. 각 바운드는 그 함수가 가질 수 있는 유효 호출들의 집합을 더 작게 만듭니다. 중복되는 바운드의 경우에는 집합 크기가 정확히 같겠죠.
이렇게 보면, 트레이트의 메서드를 구현할 때 바운드를 제거할 수 있다는 것이 말이 됩니다. 호출자에 대한 제한을 제거하는 한, 어떤 호출자도 깨뜨릴 수 없으니까요. 호출자는 우리가 쓰지 않는 약속도 여전히 지킬 수 있습니다.
또한 중복 바운드를 추가할 수 있다는 것도 말이 됩니다. 우리가 이미 Simple의 impl이라면 Self: Simple인 것을 알고 있으니, 왜 where 절로 그렇게 적지 않겠습니까? 트레이트 Trait의 모든 메서드는 사실상 암묵적으로 Self: Trait 바운드를 갖고 있다고 봐도 되니까요. 호출자에게 이미 참인 사실 이상의 것을 약속하라고 요구하는 게 아니니까요.
지금까지는 좋습니다. Rust의 동작은 상식(적어도 제 직관, 그리고 여러분도 공유하시길 바라는 그 직관)과 일치합니다. 다음 아이디어로 넘어가죠. 트레이트 바운드는 호출자가 하는 약속이고, 피호출자는 그 약속에 의존할 수 있으니, 호출자가 추가 약속을 한다고 해서 피호출자를 깨뜨리면 안 됩니다.
다시 말해보면: 때로는 트레이트 바운드가 필요한 이유가 함수 내부의 구현 코드(피호출자)가 그 바운드가 전달하는 약속에 의존해야 하기 때문입니다. T에 Clone 바운드를 건다면, 아마 피호출자가 T::clone()을 호출하고 싶기 때문일 겁니다(적어도 그 권리를 보류하고 싶기 때문이라도요).
따라서 트레이트 바운드를 제거하면, 그 약속을 발밑에서 빼앗음으로써 구현 코드를 깨뜨릴 수 있습니다. 그러나 트레이트 바운드를 _추가_한다면, 그런 일은 결코 일어나선 안 됩니다. 구현은 그대로 유효해야 합니다. 논리적으로 그렇지 않나요?
논리적으로는 그렇습니다. 하지만 위의 작은 예시에서 보듯, 사실은 그렇지 않습니다. 트레이트 바운드를 추가하고, 구현이 알아야 할 정보를 더하고, 호출자가 해야 할 추가 약속을 더했는데, 어떻게든 우리의 구현이 무효가 되어 버립니다.
호출자에게 트레이트 바운드는 지켜야 하는 약속이며, 그 함수 호출을 제약합니다. 피호출자, 즉 함수 내부에서는 트레이트 바운드가 의존할 수 있는 사실입니다. 예를 들어 T: Clone 바운드를 보면, T 타입 값에 대해 clone() 메서드를 사용할 수 있다는 뜻입니다. 이것이 없다면 clone()을 사용하면 컴파일러가 불평할 겁니다.
이런 검증을 구현하려면, 컴파일러는 이 모든 정보를 추적할 방법이 필요합니다. 즉, 시그니처에 T: Clone이 있으면, 컴파일러는 함수 본문을 읽을 때 내부적으로 타입 변수 T에 대한 데이터 구조에 이 바운드와, T에 대해 알고 있는 다른 모든 정보를 함께 기억해야 합니다.
다시 우리 예제로 돌아가 봅시다:
impl<T> Simple for Parameterized<T> {
type State = u32;
fn do_stuff()
// where Self: Simple
{
let state: Self::State = 3;
println!("{state}");
}
}
여기서 우리는 Self::State를 사용하고 있습니다. Self::State는 Simple 트레이트에 정의된 연관 타입이므로, Self가 Simple을 구현한다는 것을 알고 있을 때만 Self::State를 쓸 수 있습니다. 그리고 impl 블록이 Parameterized<T>에 대한 것이므로, 여기서 Self는 Parameterized<T> 타입을 다른 식으로 쓴 것입니다.
where 절이 없으면, 우리는 실제로 impl Simple for Parameterized<T>의 본문 안에 있으므로 Self가 Simple을 구현한다는 걸 알고 있습니다. impl Simple for X 블록 안에서는 항상 Self: Simple이며, 특히 Self의 Simple 구현이 바로 이 블록에 있다는 사실을 알고 있죠.
이 정보를 바탕으로, 컴파일러는 Self::State를 쓸 수 있도록 허용할 뿐만 아니라, 이 컨텍스트에서 Self::State가 u32라는 사실도 사용할 수 있게 해줍니다. 이는 우리가 바로 그 impl 블록 안에 있다는 사실에서 비롯됩니다. 컴파일러가 Self에 대한 정보를 추적하기 위해 사용하는 내부 데이터는 Rust 코드로 그대로 표현할 수 없습니다. 단지 Self: Simple일 뿐 아니라, Self::State가 실제로 타입 u32라는 사실까지 포함하고 있기 때문입니다.
where 절을 추가하면, 이제 Self: Simple이라는 또 다른 트레이트 바운드가 생깁니다. 이 버전의 트레이트 바운드는, impl에서 암시되는 버전과 달리, Self::State가 타입 u32라는 사실을 전달하지 않습니다. where 절에 Self: Simple을 적으면, Rust는 그 말을 곧이곧대로 믿습니다. 왜냐하면 Rust가 그게 사실이 아닌 상황에서는 이 함수를 호출하지 못하게 할 것이기 때문입니다.
그렇다면 do_stuff() 함수 내부에는 Self: Simple이라는 사실의 출처가 두 개가 생깁니다. 둘 중 어느 쪽이든 Self::State를 쓸 수 있게 해줍니다. 하지만 impl에서 암시된 버전만이 Self::State가 실제로 u32라는 사실을 알 수 있게 해줍니다. 그렇다면 우리가 Self::State를 쓸 때, Rust는 어느 버전을 사용해 이를 해석할까요?
아마 Rust 컴파일러가 두 가지를 모두 참조할 거라고 생각할 수 있습니다. 사람이 코드를 읽는다면 아마 그렇게 하겠죠. Rust 컴파일러는 impl 블록이라는 사실과 where 절 모두로부터 Self가 Simple을 구현한다는 걸 압니다. 또한 impl 블록 버전으로부터 Self::State가 u32라는 것도 압니다. 그러니 Self::State가 u32라고 결론 내릴 충분한 정보가 있고, 이 코드를 컴파일하도록 허용해야 할 겁니다.
하지만 컴파일러는 실제로 그렇게 하지 않습니다. do_stuff()를 컴파일할 때, Rust 컴파일러는 where 절에서 온 트레이트 바운드 버전을 우선시합니다. 이 버전은 Self::State가 u32인지 여부에 대해 아무것도 알려주지 않습니다. 그래서 Self::State를 쓸 때, Rust는 그것이 어떤 타입이든 될 수 있다고 결론 내립니다. 그리고 = 3을 쓰려고 하면, 더 많은 정보 없이는 그렇게 할 수 없다고 결론 짓습니다. 마치 where Self::State is u32 같은 문법이 필요하다고 말하죠(그런 문법은 Rust에 없습니다. Rust는 그런 정보를 where 절에서 명시적으로 전달하는 것을 지원하지 않거든요).
이건 일종의 섀도잉(shadowing)과 비슷합니다. where 절의 트레이트 바운드가 impl에서 암시된 버전을 가려 버리는 셈이니까요. 하지만 섀도잉과는 달리, 이 동작은 꼭 필요하지는 않습니다. 섀도잉 상황에서는 어떤 변수를 의미하는지 규칙이 필요하거나, 아예 이름이 겹치는 변수를 금지해야 합니다. 둘 다 명백히 의미한다고 선언하는 건 말이 되지 않죠. 그러나 이 경우에는 두 타입 바운드를 모두 갖도록 하고, Rust 컴파일러가 둘의 정보를 병합할 수도 있습니다. 서로 모순되지 않는 사실이니까요.
더 중요한 점은, 이것이 의도적인 설계 결정에서 나온 것이 아니라는 겁니다. 이 에러 메시지를 요구하는 Rust 표준 같은 것도 없습니다. 이 상황에서 Rust가 에러를 내야 한다는 원칙적인 이유조차 없습니다. 제 생각에는 그래선 안 된다고 봅니다(그리고 저에게도 덜 성가실 테니까요).
제가 든 최소 예제에서는 별것 아닌 것처럼 보일 수 있습니다. “같은 말을 두 번, 중복해서 했다고 Rust가 에러를 내면 어때? 그냥 그러지 않으면 되잖아?”라는 반론이 벌써 들리는 것 같네요.
하지만 제 경우에는 상황이 더 복잡했습니다. 단순히 impl<T> Simple for SomeType<T>의 impl 블록에서 where Self: Simple을 쓴 게 아니었습니다. 실제로는 where Self: Complicated<T>였고, Complicated<T>가 Simple의 서브트레이트였기 때문에 그 사실이 가려지고 있었습니다. 코드는 SomeType에 타입 매개변수를 추가하기 전에는 잘 동작했고, 타입 매개변수를 추가하자 깨졌습니다.
제 실제 예제에 좀 더 가까운 내용은 제 Rust 포럼 글을 참고하세요.
저는 쉽게 우회할 수 있었습니다. where Self: Complicated<T>를 where T: Complicated<Self>로 바꾸고, Complicated의 의미론도 그에 맞게 수정했습니다. 하지만 왜 이런 에러 메시지가 나오는지 이해하기 전까지는 그렇게 할 수 없었습니다. 특히 SomeType에 타입 매개변수가 있을 때만 에러가 나타났기 때문에 더 혼란스러웠습니다!
여기서 문제는 Rust의 제한 그 자체만은 아닙니다. 이 제한은 고쳐져야 한다고 생각하고, 시간적 여유가 된다면(그럴 가능성은 높지 않지만요) 저도 고쳐 보려고 할지 모릅니다. 하지만 더 중요한 문제는 에러 메시지가 매우 오해의 소지가 있었다는 점입니다.
그럼에도 이런 경험을 해서 기쁩니다. Rust 트레이트 의미론에 대해 더 많이 배웠기 때문이죠.
사적으로, 익명으로 제게 무언가 전하고 싶으시다면, admonymous를 통해 익명으로 꾸짖거나(혹은 칭찬하거나) 하실 수 있습니다.