대규모 Rust 백엔드에서 컴파일러의 강력한 안전 보장이 어떻게 두려움 없는 리팩터링을 가능하게 하고, 생산성과 장기 유지보수성을 높이는지 TypeScript·Zig와의 대비 사례로 설명합니다.
Lubeno의 백엔드는 100% Rust로 되어 있고, 이제는 코드베이스의 모든 부분을 동시에 머릿속에 담아두는 것이 불가능할 만큼 커졌습니다.
제 경험상 이 단계에 이르면 보통 프로젝트 속도가 크게 느려집니다. 변경 사항이 예기치 않은 결과를 낳지 않았는지 확인하는 일 자체가 매우 어려워지거든요.
Rust의 강력한 안전 보장은 제가 코드베이스를 다룰 때 훨씬 더 큰 자신감을 줍니다. 그 덕분에 앱의 핵심 부분이라도 과감히 리팩터링할 수 있고, 이는 제 생산성과 장기 유지보수성에 매우 긍정적인 영향을 줍니다.
최근 겪은 한 이슈가 많은 생각을 하게 했고, 결국 이 글을 쓰게 만들었습니다. 동시에 접근되는 구조체를 감싸기 위해 mutex를 사용해야 했습니다. 내부 구조체에 접근하려면 먼저 mutex의 락을 획득해야 하죠.
let lock = mutex.lock();
// … 잠긴 데이터를 사용해 커밋을 생성 …
db.insert_commit(commit).await;
이 변경은 제 눈에는 전혀 문제없어 보였고, rust-analyzer도 동의했습니다. 그 파일에서는 어떤 오류도 보이지 않았거든요. 그런데 갑자기 편집기에서 다른 파일(라우터 정의)이 빨갛게 물들며 컴파일 오류를 표시했습니다. 도대체 무슨 말이지? 내가 건 락이 라우터가 어떤 핸들러를 선택하는지에 어떻게 영향을 준다는 거지?
.route("/api/git/post-receive", post(git::post_receive))
^^^^^^^^^^^^^^^^^
error: future cannot be sent between threads safely
help: within 'impl Future<Output = Result<Response<Body>>', the trait 'Send' is not implemented for "MutexGuard<'_, GitInternal>"
인정하건대, 무슨 일이 일어나는지 이해하는 데 생각보다 훨씬 오래 걸렸습니다. 차근차근 설명해볼게요!
새 HTTP 연결이 들어오면, 우리가 사용하는 웹 프레임워크는 그 연결마다 새로운 비동기 태스크를 스폰합니다. 이 비동기 태스크들은 워크-스틸링 스케줄러에서 실행됩니다. 즉, 어떤 스레드의 일이 끝나면 다른 스레드의 태스크를 “훔쳐” 와서 작업량을 균형 있게 맞춥니다. Rust에서는 이런 일이 '.await' 지점에서만 일어날 수 있습니다.
여기에 또 하나 중요한 규칙이 있습니다. 만약 한 스레드에서 mutex를 잠갔다면, 반드시 같은 스레드에서 풀어야 합니다. 그렇지 않으면 정의되지 않은 동작이 발생하니까요.
이제 Rust는 모든 수명을 추적하고, 락이 충분히 오래 살아서 '.await' 지점을 지나간다는 사실을 압니다. 즉, 락을 해제하는 동작이 다른 스레드에서 일어날 수도 있고, 그건 허용되지 않습니다. 정의되지 않은 동작을 일으킬 수 있기 때문이죠.
해결책은 아주 간단합니다. '.await' 전에 락을 풀어 주면 됩니다.
이런 종류의 버그가 최악입니다! 개발 환경에서는 잡기가 거의 불가능하거든요. 스케줄러가 실행을 다른 스레드로 옮겨야 할 만큼 시스템에 부하가 걸리는 경우가 거의 없기 때문입니다. 그래서 “재현이 불가능하고, 가끔 실패하지만, 내 환경에서는 절대 안 실패하는” 버그가 되어버립니다.
Rust 컴파일러가 이런 걸 잡아낼 수 있다는 건 정말 놀랍습니다. 그리고 mutex, 수명(lifetime), 비동기 연산처럼 서로 무관해 보이는 언어 요소들이 이렇게 일관된 하나의 시스템을 이룬다는 점도요.
대조적으로, 우리 TypeScript 코드베이스에서 최근의 비동기 버그는 프로덕션 배포 후 꽤 오랜 시간이 지나도록 발견되지 못했습니다. 문제의 코드는 이겁니다.
// 사용자가 성공적으로 로그인했습니다!
if (redirect) {
window.location.href = redirect;
}
let content = await response.json();
if (content.onboardingDone) {
window.location.href = "/dashboard";
} else {
window.location.href = "/onboarding";
}
아주 단순합니다. 로그인할 때 redirect가 있으면 그 페이지로 보내고, 없으면 대시보드나 온보딩 페이지로 보냅니다. 'window.location.href'에 값을 할당하면 브라우저가 해당 위치로 리다이렉트되죠.
확인했을 땐 잘 동작했던 것 같습니다. 그런데 어느 순간 갑자기 동작하지 않기 시작했습니다. 애초에 제대로 동작했던 적이 있기는 했나? 무슨 일이죠? redirect가 있어도 항상 대시보드로 가 버렸습니다.
여기에는 스케줄링 경쟁 상태(race condition)가 있습니다. 'window.location.href'에 값을 할당한다고 해서 제 생각처럼 즉시 리다이렉트가 일어나지 않습니다. 값만 설정하고 가능한 한 빨리 리다이렉트를 예약할 뿐이죠. 하지만 코드는 계속 실행됩니다! 즉, 브라우저가 리다이렉트를 시작하기 전에 다음 할당이 실행되어 잘못된 위치로 리다이렉트될 수 있습니다. 이걸 알아내는 데 한참이 걸렸습니다. 해결책은 if 블록에 return을 추가해 아래로 진행되지 않게 하는 것입니다.
if (redirect) {
window.location.href = redirect;
return;
}
Rust와 TypeScript의 이슈 둘 다 비슷하다고 느낍니다. 둘 다 비동기 스케줄링과 관련되어 있고, 직관적으로 전혀 명확하지 않은 정의되지 않은 동작을 보이죠. 하지만 Rust의 타입 체커는 훨씬 더 유용합니다. 아예 버그가 컴파일되지 못하게 막아 주니까요. TypeScript 컴파일러는 수명을 추적하지도, 빌림 규칙을 가지지도 않기 때문에 이런 종류의 문제를 잡아낼 능력이 없습니다.
Rust는 종종 훌륭한 시스템 프로그래밍 언어로 추천되지만, 웹 애플리케이션에 관해서는 보통 첫 선택은 아닙니다. Python, Ruby, JavaScript/Node.js가 웹 개발에선 늘 더 “생산적”이라고 여겨지죠. 프로젝트를 막 시작하는 단계라면 이 말이 맞다고 생각합니다! 이 언어들은 기본으로 제공해 주는 것이 많고, 초기 진척 속도가 매우 빠르거든요.
하지만 프로젝트가 어느 정도 규모에 이르면 모든 것이 멈춰 서기 시작합니다. 코드베이스의 여러 부분이 느슨하게 결합되어 있어서, 무언가를 바꾸기가 매우 어려워지기 때문입니다.
우리 모두 그런 경험이 있죠. 뭔가를 바꿨고 모든 게 잘 동작합니다. 그런데 이틀 뒤, 당신의 변경이 다른(완전히 무관해 보이는) 페이지를 깨뜨렸다는 알림이 옵니다. 이런 일이 세 번째쯤 되면, 코드베이스를 건드릴 의지가 뚝 떨어집니다.
Rust에선 이런 걱정이 훨씬 적습니다. 그래서 더 많은 시도를 해볼 수 있게 됩니다. 오히려 코드베이스가 커질수록 제 생산성이 높아졌다는 느낌마저 듭니다. 기존 코드를 토대로 더 많이 쌓고, 재사용하고, 바꿔도 기존 것을 우연히 망가뜨릴지 걱정할 필요가 별로 없으니까요.
Rust는 이렇게 말하는 데 매우 능숙합니다. “지금 하려는 변경은 프로젝트의 다른 부분에도 영향을 줘요. 당신은 아마 그걸 전혀 생각하고 있지 않겠죠. 왜냐면 지금 함수 호출이 여섯 단계나 깊은 곳에 있고, 마감은 코앞이라 빠르게 끝내야 하니까요. 하지만 정확히 이런 이유로 문제가 생길 수 있어요.”
저는 테스트가 훌륭하다고 생각합니다! 특히 큰 리팩터링을 할 때 회귀(regression)를 잡아내는 데 매우 강력한 도구죠. 하지만 코드를 실행하기 위해 컴파일러가 테스트를 요구하지는 않습니다. 즉, 마음만 먹으면 테스트를 안 쓰겠다고 쉽게 결정할 수 있습니다.
어떤 날은 더 스트레스가 심하기도 하고, 시간은 매우 부족한데 해야 할 일은 많습니다. 테스트에는 추가적인 정신적 부담이 따릅니다. 적절한 추상화 수준을 결정해야 하죠. 동작을 테스트할까요, 구현 세부를 테스트할까요? 이 테스트가 미래의 오류를 정말 막아 줄까요? 이런 결정을 내리는 건 피곤하고, 실수하기 쉽습니다.
Rust는 배우고 쓰기가 때로는 어렵습니다. 하지만 좋은 점은, Rust가 제게서 많은 결정을 덜어 준다는 겁니다. 저보다 훨씬 똑똑한 사람들이 거대한 코드베이스에서 일하며 흔한 실수들을 컴파일러 안에 규칙으로 녹여 놓았거든요.
물론 어떤 앱의 속성들은 타입 시스템의 영역 밖에 있습니다. 그런 경우엔 테스트가 최고죠!
Zig는 종종 Rust와 비교됩니다. 둘 다 시스템 프로그래밍 언어를 지향하죠. 저는 Zig가 정말 멋지다고 생각하고, 이 언어는 제 안의 너드 기쁨을 자극합니다. 하지만 그러다 보면 다시금 무섭다는 생각이 듭니다. 간단한 에러 처리 예시를 보죠.
const std = @import("std");
const FileError = error{
AccessDenied,
};
fn doSomethingThatFails() FileError!void {
return FileError.AccessDenied;
}
pub fn main() !void {
doSomethingThatFails() catch |err| {
if (err == error.AccessDenid) {
std.debug.print("Access was denied!\n", .{});
} else {
std.debug.print("Unexpected error!\n", .{});
}
};
}
'doSomethingThatFails'라는 함수가 항상 'FileError.AccessDenied' 오류 값으로 실패하고, 우리는 그 오류를 잡아 접근이 거부되었다고 출력합니다.
그런데 사실은 그렇지 않습니다. 에러 처리 로직에 오타가 있죠. 'AccessDenid != AccessDenied'. 이 코드는 문제 없이 컴파일됩니다. Zig 컴파일러는 각 고유한 'error.*'에 대해 새로운 숫자를 생성하고, 무엇을 비교하는지 타입은 신경 쓰지 않습니다. 그냥 숫자일 뿐이죠.
하지만 'if' 대신 'switch' 문을 사용하면 Zig 컴파일러는 갑자기 이렇게 말합니다. “이건 명백히 잘못됐어요! 반환될 수 있는 에러는 'FileError'에 없으니 이 값이 될 수 없어요.” 그리고 컴파일을 거부합니다. 잡아낼 능력이 있는데도, 굳이 신경 쓰지 않기로 선택하는 셈입니다. 숫자처럼 보인다면, 'if' 비교도 숫자 비교로 여겨 버리는 거죠.
언어의 이런 사소해 보이는 설계 결정들은 Rust와 크게 대조됩니다. 그리고 이름을 자주 틀리는 저 같은 사람에겐 꽤 무서울 수 있습니다.