Java식 사고로 Rust를 쓰려다 겪는 시행착오를 다루며, 트레이트·제네릭·박싱, 동적/정적 디스패치, 소유권과 수명, Arc, 그리고 객체 대신 함수 중심의 접근을 통해 Rust다운 코드를 제안한다.
나는 몇 년 전부터 Rust라는 _아이디어_에 관심이 있었다. 타입 안전성, 메모리 안전성, 그리고 정합성에 대한 강조. 사랑하지 않을 이유가 있을까?
Apollo (파이썬 앱)을 개발하면서 겪는 오류 중에서 Rust 컴파일러가 잡아줄 수 있었던 건의 비율은 꽤 높다(100%라고까지는 못 하겠지만, 매우 근접하다). 일반적으로 동적 언어(파이썬이나 루비 같은)를 사용할 때 프로덕션까지 흘러들어갈 수 있는 많은 문제를 컴파일러가 사전에 잡아줄 수 있다. 물론 모든 컴파일러가 동일하진 않다. 타입 안전성은 훌륭하지만, Rust가 _정합성_에 무게를 두는 점이 내게는 가장 매력적이다.
최근 일하면서 Java도 꽤 많이 작성한다. 내가 가장 좋아하는 언어는 아니지만, 컴파일 타임 검사 덕분에 힘이 난다. 파이썬이나 루비에 비해 대규모 리팩터링도 덜 두렵다. 컴파일러가 내 편이니까! 잘못되었거나 빠진 import 문 때문에 런타임에 프로그램이 멈춰 서는 일은 없을 것이다. 물론 이런 문제를 잡기 위해 보통 테스트를 작성하지만, 언어 자체에 이런 검사가 녹아 있다는 것은 분명 의미가 있다.
하지만 자바 컴파일러가 완벽한 것은 아니다. 자바가 보호해 주지 못하는 오류의 범주가 분명 존재하는데, 그중 가장 악명 높은 것이 null 참조다. 자바에서는 (거의) 모든 것이 null이 될 수 있고, 이는 런타임이 되어서야 드러난다. 반면 Rust에는 알 수 없는 값을 다루도록 안내하는 구성 요소들이 마련되어 있다. 물론 그런 안내를 무시할 수도 있지만, 그 경우에도 컴파일러는 당신이 그렇게 하겠다는 _명시적인 결정_을 내리도록 강제한다.
그렇다면 Rust는 자바의 더 나은 대체재일까? 분명 마음에 드는 점이 많다. Rust가 내세우는 _약속_은 정말로 매혹적이다. 하지만 내 Rust 여정이 내내 햇살과 무지개만 가득했던 것은 아니다. 유사점이 많음에도, Rust는 자바가 아니다. 나는 Rust를 즐겁게 쓰게 된 순간이, 이 언어를 그것이 아닌 무언가로 만들려는 시도를 멈췄을 때였다.
완전히 정확한 말은 아니지만, 자바 개발자는 모든 것을 인터페이스로 만들고 싶어 한다는 상투적인 표현에는 어느 정도 진실이 담겨 있다(나도 그런 개발자 중 하나다). 자바의 인터페이스는 다루기 즐겁다. 애플리케이션은 작은 작업 단위들의 조합으로 구성되고, 어느 한 작업 단위도 다른 단위의 내부 동작을 직접 알지 못한다. 의존성 트리를 부트스트랩하는 데는 초기에 약간의 노력이 필요하지만, 일단 완료되면 호출만 하면 되는 독립 서비스들의 군단을 갖게 된다.
Rust에는 인터페이스가 없다. 대신 트레이트가 있다. 여러 면에서 자바의 인터페이스와 비슷하다. 하지만 Rust에서 _모든 것_을 트레이트로 만들려고 하면 재미가 없다. Rust의 뛰어난 기능인 메모리 안전성을 기억하는가? 그 대가로 트레이트를 구현한 무언가를 쉽게 “주입”하기가 어렵다.
trait Named {
fn name(&self) -> String;
}
struct Service {
named: Named
}
위 코드는 컴파일되지 않는다. 컴파일 타임에 Named의 크기를 알 수 없기 때문이다. 이를 우회하기 위해 “박스(Box)”로 트레이트를 감싸 힙의 동적 메모리(트레이트 오브젝트라고 부름)를 가리키게 할 수 있다. Box 자체는 크기가 고정되어 있어 프로그램이 컴파일될 수 있다.
trait Named {
fn name(&self) -> String;
}
struct Service {
named: Box<dyn Named>
}
나는 박싱 패턴을 그다지 좋아하지 않는다. 다루기 어색하기 때문이다. 가능하면 피한다. 대신 _제네릭_을 사용해 트레이트 타입을 지정할 수 있다.
trait Named {
fn name(&self) -> String;
}
struct Service<T: Named> {
named: T
}
무엇이 다른가? 겉보기에는 결과가 같다. 차이는 동적 vs 정적 디스패치에 있다. 트레이트 오브젝트를 쓰면 구체 타입이 _런타임_에 결정된다. 제네릭을 쓰면 구체 타입이 _컴파일 타임_에 결정된다.
실무적으로는, 컴파일 타임에 모든 타입을 추론할 수 있다면 제네릭으로 충분하다는 뜻이다. 타입이 런타임까지 정해지지 않는다면 박스가 필요하다.
소유권 문제는 여전히 남아 있다. 애플리케이션의 다른 서비스들이 Named 트레이트에 의존해야 한다면 어떻게 할까? Named의 “마스터” 인스턴스를 하나 만들고 각 의존자에게 &Named를 넘겨주어 수명(lifetime)을 도입할까?
struct Service<'a> {
named: &'a dyn Named
}
아니면 Arc를 사용해 의존 서비스들이 Arc<dyn Named>를 보유하도록 만들어, 소유한 리소스에 동시 접근하도록 할까?
struct Service {
named: Arc<dyn Named>
}
두 접근 모두 사용해 봤다. 동작은 한다. 하지만 즐겁지는 않다. 특히 애플리케이션의 모든 서비스가 그 영향을 받는다면 더더욱 그렇다.
Rust를 순수한 객체지향 언어로 만들려 들면 재미가 없다. 위 예시처럼 나는 여전히 “서비스 객체”를 작성하긴 하지만, 꼭 필요할 때만 그렇게 하고 그 외에는 함수 사용을 선호한다.
우리 시스템에서 Stripe 고객 ID를 갱신하는 Stripe 체크아웃 세션 완료 이벤트를 처리하는 함수를 생각해 보자.
async fn handle_session_completed(
user_repo: &mut impl UserRepo,
session: &CheckoutSession,
) -> anyhow::Result<()> {
let user_id = session
.client_reference_id
.clone()
.context("Missing client reference ID")?;
let customer_id = session
.customer_id
.clone()
.context("Missing customer ID")?;
user_repo
.update_stripe_customer_id(user_id, &customer_id)
.await?;
Ok(())
}
UserRepo를 주입한 서비스 객체로 이 코드를 작성할 수도 있겠지만, 그렇게 하면 앞서 살펴본 복잡성이 스며든다. 굳이 서비스를 쓸 _이유_도 없다. 여전히 라이브 데이터베이스를 치지 않는 구현 등 UserRepo의 다양한 구현을 쉽게 주입할 수 있기 때문이다. 단점이라면 함수 시그니처가 다소 번잡해질 수 있다는 점인데, 이 정도의 “불편함”은 대안들에 비하면 아무것도 아니다.
나는 한때 Rust는 어렵다라는 수렁에 깊이 빠져 있었다. 큰 이유는 Rust 코드가 내가 이전에 써왔던 다른 코드처럼 보여야 한다는 고집이었다. 과거에서 배우는 것은 경험의 축복이지만, 기존의 관용구를 받아들이는 일은 숙련에 필수적이다. Rust는 사고방식의 전환을 요구한다. Rust가 아닌 무언가가 되도록 억지로 싸우지 말고, Rust를 있는 그대로 받아들이자.