Rust의 예상치 못한 생산성 향상
(lubeno.dev)- Rust는 강력한 안전 보장으로 대규모 코드베이스에서도 리팩토링을 자신 있게 수행할 수 있어 생산성과 유지보수성을 높임
- 비동기 스케줄링과 관련된 버그를 컴파일러가 사전에 탐지, 미정의 동작 방지로 안정성 강화
- TypeScript와 같은 언어는 느슨한 타입 시스템으로 인해 비동기 버그가 프로덕션 환경에서 발견되는 경우가 빈번
- Rust의 타입 시스템은 코드 변경의 영향을 명확히 알려주어 복잡한 프로젝트에서 신뢰도와 실험 의지를 높임
- Zig는 Rust와 달리 오류 처리에서 느슨한 검사로 인해 오타로 인한 버그를 놓칠 수 있어 신뢰성 낮음
요약 및 배경
-
Lubeno의 백엔드는 100% Rust로 작성, 코드베이스가 커져 전체를 머릿속으로 파악하기 어려운 단계 도달
- 대규모 프로젝트는 일반적으로 변경의 부작용을 확인하기 어려워 생산성 저하 발생
- Rust의 안전 보장은 코드 변경 시 영향을 명확히 알려주어 리팩토링에 대한 두려움 감소
- 이는 장기적인 유지보수성과 생산성 향상에 기여
- 이 글은 Rust 컴파일러가 비동기 버그를 탐지한 사례로 시작, Rust의 생산성 이점을 탐구
Rust의 안전 보장 사례
-
문제 상황: 구조체를 동시 접근을 위해 뮤텍스로 래핑, 락 획득 후 비동기 작업 수행
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
-
원인 분석:
- 웹 프레임워크는 HTTP 연결마다 비동기 태스크를 생성, 작업 스케줄러가 태스크를 스레드 간 이동
- 뮤텍스는 동일 스레드에서 락 해제 필요,
.await
지점에서 스레드 이동 시 미정의 동작 발생 가능 - Rust 컴파일러는 락의 수명을 추적, 다른 스레드에서 해제될 가능성을 감지
-
해결 방법:
.await
전에 락 해제 - 의의: Rust는 개발 환경에서 재현 어려운 비동기 버그를 컴파일 타임에 방지
TypeScript의 비교 사례
-
문제 상황: TypeScript 코드에서 비동기 리다이렉션 버그 발생
if (redirect) { window.location.href = redirect; } let content = await response.json(); if (content.onboardingDone) { window.location.href = "/dashboard"; } else { window.location.href = "/onboarding"; }
-
문제 원인:
-
window.location.href
는 즉시 리다이렉션하지 않고 스케줄링, 코드 실행은 계속 진행 - 레이스 컨디션으로 인해 의도하지 않은 리다이렉션 발생
-
-
해결 방법:
if
블록에return
추가if (redirect) { window.location.href = redirect; return; }
-
한계: TypeScript는 수명 추적이나 빌려오기 규칙이 없어 이런 버그를 컴파일 타임에 탐지 불가
- 프로덕션 환경에서 발견, 디버깅에 긴 시간 소요
Rust의 리팩토링 이점
- 웹 개발에서 Python, Ruby, JavaScript/Node.js는 초기 생산성이 높으나, 코드베이스 확장 시 느슨한 결합으로 변경 어려움
- 변경 후 예상치 못한 오류 발생, 코드 수정 의지 저하
- Rust는 타입 시스템이 변경의 영향을 명확히 알려주어 리팩토링에 대한 두려움 감소
- 예: “이 변경이 다른 부분에 영향을 미칠 수 있다”는 경고로 문제 사전 방지
- 코드베이스 성장에도 생산성 증가, 기존 코드를 재사용하고 변경 시 안정성 유지
테스트와의 비교
- 테스트는 리팩토링 시 회귀 방지에 유용하나, 컴파일러가 강제하지 않아 생략 가능
- 테스트 작성은 추상화 수준, 동작 vs 구현 세부 사항, 오류 방지 여부를 결정해야 하므로 정신적 부담 큼
- Rust는 컴파일러가 일반적인 실수를 사전에 차단, 테스트의 의사결정 부담 감소
- 타입 시스템으로 확인 불가능한 속성은 테스트로 보완
Zig와의 비교
-
Zig는 Rust와 유사한 시스템 프로그래밍 언어이나, 오류 처리에서 느슨함
- 예: 오류 처리 코드
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", .{}); } }; }
-
AccessDenid
오타로 인해 버그 발생, 그러나 Zig 컴파일러는 이를 숫자로 처리해 컴파일 성공
- 예: 오류 처리 코드
- switch 문 사용 시 오타를 탐지하나, if 문에서는 무시, 신뢰성 문제 발생
- Rust는 이런 설계적 허점을 방지, 오타나 논리적 오류를 엄격히 검사
시사점
- Rust는 안전 보장과 엄격한 타입 시스템으로 대규모 프로젝트의 생산성과 안정성 향상
- 비동기 버그와 같은 복잡한 문제도 컴파일 타임에 탐지, 유지보수 비용 절감
- TypeScript와 Zig의 사례는 느슨한 검사로 인한 위험을 보여주며, Rust의 엄격한 컴파일러의 가치를 강조
- Rust는 웹 개발에서도 초기 생산성뿐 아니라 장기적인 코드베이스 관리에 강력한 도구로 자리 잡음
하도 이게 최고다 이게 강력한 언어다!! 하는 거 볼 때마다 느끼는 건데
생각보다 러스트 개발자가 생각보다 많이 없어서 러스트하라고 꼬시나?? 하는 생각듬
Hacker News 의견
-
작년에 Rust로 작성된 virtio-host 네트워크 드라이버를 포팅했음. 백엔드, 인터럽트 메커니즘 전환, 라이브러리에서 독립 실행형 프로세스로 변경했음. 메모리 매핑, VM 인터럽트, 네트워크 소켓, 멀티스레딩까지 다루는 복잡한 프로그램이었음. Rust 경험 거의 없고, virtio 경험도 적은데, 프로젝트가 컴파일될 무렵엔 완벽하게 동작했음. Drop 관련 버그 하나 빼면 쉽게 고쳤음. Rust의 라이브러리들이 잘못 사용할 수 없는 구조로 만들어져 도움을 많이 받았다고 생각함
- 나는 Rust로 오랫동안 개발하고 있는데, 대부분 컴파일만 되면 코드가 잘 작동함. 가끔 데드락이나 순서 관련 버그가 나올 때도 있지만, 기본적으로 컴파일 성공이 프로젝트의 상당 부분이 잘 돌아간다는 의미임
-
난 Rust가 훌륭하다고 생각함. 하지만 href 할당 버그가 TypeScript 탓이라는 의견에는 동의하지 않음. 문제의 핵심은 href를 설정해도 페이지 이동이 즉시 일어나지 않고 나중에 처리된다는 점임. Rust에서도 똑같은 문제가 있을 수 있음. 만약 Rust에 set_href 함수가 있고 이 동작이 나중에 처리된다면, 아래와 같은 코드가 가능함:
set_href('/foo')
if (some_condition) { set_href('/bar') }
Rust라면 이렇게 설계하지 않을 거라고 생각함. setter에서 행동이 일어나는 건 좋은 라이브러리 설계가 아니고, href 할당과 동시에 페이지 이동이 안 되는 것은 이상함. Rust의 표준 라이브러리라면 이런 바보 같은 구현은 없을 것임. Rust vs TypeScript의 문제가 아니라 Rust의 표준 라이브러리와 Web Platform API의 차이임. Rust라면 이런 유저 경험을 제공하지 않을 거라는 점에는 동의함
-
공식적으로 말하자면, setter에서 즉시 행동이 일어나게 설계하는 건 바람직하지 않음. 네이밍도 navigate_to(href)처럼 바꾸는 게 맞음. 브라우저 환경에서는 JS 코드가 모두 콜백으로 동작하고 이벤트 루프에 의해 제어되기 때문에, 즉시 동작하지 않는 것도 자연스러운 상황임
-
Rust 예시는 흥미롭지만 TypeScript 예시만으로 TS가 대규모 프로젝트에 적합한지 알 수 없음. 나는 Ruby에서 런타임에 자주 버그를 잡아야 해서 불안하지만, 결국 커밋 전에 잘 동작하고 코드를 읽고 수정하는 게 쉽고 만족스러움. 위치 이동 이슈는 JavaScript의 문제이자 TS가 상속받은 부분임. JS가 속성을 마음대로 수정할 수 있게 하기 때문에 생긴 일임. 하지만 페이지가 즉시 사라지는 것도 아니므로, 이 동작은 알게 되면 합리적임
-
기술적으로 Rust라면 set_href가 () 또는 !를 반환하는 방식에 따라 의미를 명확히 힌트 줄 수 있음. 하지만 조건부 리다이렉트에서는 여전히 잘못된 사용을 잡아내긴 힘듦
-
내 의도는 Rust의 소유권 모델이라면 window.set_href('/foo') 호출 시 window의 소유권을 가져가기 때문에 두 번 호출이 불가능하도록 API를 설계할 수 있다는 점을 얘기하고 싶었음. TypeScript는 라이프타임 추적 개념 자체가 없어 이런 불가능함. JS API가 이미 존재하기 때문에 TypeScript 쪽에서 소유권 시스템을 도입할 방법도 없음. Rust의 여러 기능들이 결합되어 더 강력한 보증을 제공한다는 사례로 드러내고 싶었음
-
네가 Rust가 더 낫다고 주장하는 근거가 결국 “Rust 프로그래머가 더 나아서”라는 논조로 들림. Rust 프로그래머는 이런 순환적인 논증을 하지 않을 것 같음
-
-
할당 이후 코드는 명시적으로 조기 리턴하지 않으면 계속 실행됨. 진지하게, 값 할당이 스크립트 실행을 멈출 거라고 왜 생각하는지 모르겠음. TS 예시에 맥락이 부족할 수는 있지만, "데이터 레이스"로 들고 나오기엔 이상한 예임
-
window.location.href에 값을 할당하면 브라우저가 해당 링크로 이동하는 부수효과가 있음. 이런 동작은 의외인데, 단순 할당이 새 페이지를 로드한다는 점에서 execve랑 비슷한 느낌이라 JS 실행이 즉시 멈춘다고 생각해도 이상하지 않음. 프로그래밍할 때 이런 가정에 의존하진 말아야 하는데, 동작이 사실 이상하기 때문에 헷갈릴 수 있다 생각함
-
그걸 생각하든 아니든, 이런 버그는 누군가 알려주면 수정이 명확해짐. 저자가 하려던 주장 핵심은 TS로 포착하지 못하는 이런 버그가 실제로 잡기 어렵고 시간도 오래 걸릴 수 있다는 점임
-
exit(), execve() 등은 실제로 실행을 즉시 멈추기 때문에 리다이렉트도 그런 동작이라고 생각할 수 있음
-
자신의 경험을 공유했다는 이유로 문제 삼는 건 이상함
-
이 할당은 페이지를 떠나게 만드는 큰 부수효과를 가짐. 즉시 동작하는 비동기 액션이라고 생각하는 것도 무리는 아니라 생각함. 나도 그런 가정 했던 적 있음
-
-
개발자가 정적 타입 시스템이 유용하다는 걸 깨달았다는 이야기임. 이런 글을 볼 때마다 항상 재밌음
- 내 블로그에서 보여주고 싶었던 건 Rust의 라이프타임 추적과 트레이트 시스템이 단순 타입 불일치 잡는 것보다 훨씬 복잡한 이슈도 잡아낼 수 있다는 점임. TypeScript도 정적 타입 언어이지만 Rust만큼 보증해주진 못함
-
대부분의 장점은 결국 정적 타입, 즉 컴파일 언어를 써서 오는 게 아닐까? Java, Go, C++도 마찬가지임. TypeScript는 트릭이 있는데, JS로 컴파일되고 JS의 문제도 물려받지만 그래도 쓸 만함. Rust는 타입 시스템이 더 엄격해서 추가적인 컴파일 타임 체크를 받을 수 있지만, 그 만큼 배우기 어렵고 읽기도 어렵다고 생각함
-
어느 정도는 동의하지만, Rust는 타입 시스템에 소유권, 공유/독점 접근, 스레드 안전성, sum type(합 타입) 등 더 많은 차원이 있음. 소유권/빌림 시스템 덕분에 인자 전달이 임시 뷰인지, 완전히 넘겨주는 것인지 명확해짐. 대규모 프로그램이나 외부 라이브러리 쓸 때 이점이 큼. 예를 들어 go의 슬라이스 타입은 런타임에 어떤 연산이 허용되는지 명확하지 않고, 읽기 전용으로 빌릴 방법도 애매함. Rust는 타입 시스템 차원에서 스레드 안전성 보장이 가능해서, 다른 언어라면 러닝타임에 찾기 힘든 데이터 레이스도 컴파일 타임에 막아줌
-
모든 정적 타입 언어를 묶어서 하나로 보는 건 union(sum) 타입과 패턴 매칭의 진짜 힘을 아직 못 느꼈기 때문임. 한번 union 타입에 익숙해지면 다른 전통 정적 타입 언어들은 만족 못 하게 됨
-
큰 장점 하나는 traits/impl traits임. Rust는 C#의 Extension Method처럼 어떤 타입에도 나중에 트레이트 추가가 가능함. 대부분의 언어는 타입이 라이브러리에서 정의될 때 딱 정해지는데, Rust는 단순한 타입에 기능을 점진적으로 계속 쌓아갈 수 있음. 이 late-bound 성격이 타입 시스템에 동적성을 불어넣는 요소임. 좀 극단적으로 표현해 Rust의 진짜 초능력은 빌림 검사기보다 타입 시스템의 개방성과 유연함임. 모든 걸 처음부터 다 설계하지 않아도, 점진적으로 확장하면 됨
-
정적 타입 언어라고 모두 같은 효과를 내지는 않음. Java는 결국 Object와 런타임 캐스팅에 의존함. Go는 enum이 없음. C++은 variant 개념이 추가됐지만 안전하게 쓰려면 try/except 같은 수동 처리가 필요해서 구조적으로 불편함
-
Rust를 배우기 어렵다는 얘기하는데, 사실 확실히 배운다면 어렵지 않음. 약간은 막 짜다 뭔가 돌아가게 만드는 게 코딩 초기에 중요한데, Rust는 그 방식에 불친절한 언어임. 입문용 언어로는 권하지 않지만, 읽기에 어렵진 않음
-
-
Rust의 강한 안전성 덕분에 코드베이스를 만질 때 자신감이 커짐. 이 자신감으로 핵심 부분 리팩터링도 두렵지 않고, 결과적으로 생산성과 유지보수성이 크게 좋아짐. 하지만 이런 효과 때문에 테스트를 쓰는 것임. 테스트가 없으면 엄격한 컴파일러가 큰 도움을 주겠지만, 테스트를 잘 작성하면 어떤 언어든 자신있게 리팩터링할 수 있음
-
가능한 부분은 컴파일러가 정적으로 증명해주는 게 더 좋음. 테스트는 정적 보증이 힘든 상황에만 쓰는 게 최적임. 이상적인 끝판왕은 포멀 검증인데, 현실적으로 매우 어려우니 일반론은 아니지만 원칙으로는 맞음
-
좋은 테스트와 잘 활용된 타입 시스템 모두 버그 잡기에 효과적임. 그런데 테스트 작성은 xkcd "Standards" 만화가 떠오르기도 함. 표준을 더 만들면서 표준을 고치듯, 코드를 더 쓰면서 버그를 잡는 모습임. 그래도 타입 시스템 유지 관리는 언어 설계자가 해주니, 프로젝트별로 관리할 필요 없음
-
코드 리팩터링할 때마다 테스트도 같이 리팩터링해야 하니 일이 두 배로 늘어남
-
-
Rust나 F#의 타입 시스템이 코드 리팩터링할 때 가장 빛을 발한다고 생각함. 두려움 없는 리팩터링이란 표현이 딱 맞음
- 단점이라면, Rust는 미완성 코드를 용납하지 않기 때문에 리팩터링 중에 '부분적으로 동작하는 코드'를 가지는 게 불가함. 완전히 끝내던가, 아예 안 하던가 해야 해서 실험적인 코드 작성하려면 불편함. 하지만 이런 엄격함이 결국엔 괜찮은 코드로 이어지는 요인임
-
Zig 예시는 충격적임. 너무 불안정해 보여서 이런 설계를 어떻게 좋다고 생각할 수 있는지 이해가 안 감
-
이건 아마 버그라고 생각함. 하지만 Zig처럼 창작자 위주 언어라면, 버그 수정이 가능하려면 그 창작자도 버그라고 인정하는 게 중요함. 의도라고 생각하면 계속 저 설계대로 갈 수도 있음
-
모든 언어엔 불안정한 설계가 조금씩은 있음. 예를 들어 Go나 Zig는 mutex.unlock()을 항상 명시적으로 해줘야 하고, 범위 넘어가면 자동 해제가 안 됨. 반면 Rust의 as 연산자처럼 숫자 타입 간 암시적 변환이 쉽고, 이것 때문에 하루 종일 버그 찾아 헤매기도 했음
-
처음에는 해당 에러를 못 봤다가 이 댓글 보고 알았음
-
린터가 시스템 내에서 존재하지 않는 에러 참조를 잡아내고, switch 사용을 권장하는 식으로 경고해줄 수 있지 않을까 생각함
-
함수 시그니처 기반으로 에러셋이 생성된다고 생각했었음. 좀 독특함
-
-
강력하고 건전한 정적 타입 시스템이 다양한 기능을 제공하는 점이 마음에 듦. 나도 Haskell 코드베이스(100만 SLOC)에서 대규모 리팩터링이 쉬웠던 경험이 있음. 고도화된 기능 없이도, 타입 시스템만으로 충분히 가능했음
-
Rust가 await 경계에서 락을 잡고 있는 걸 제대로 감지해줬지만, 그 락을 await 전 해제하는 게 실제로 안전한지는 추가 맥락이 필요함. 락은 트랜잭션 커밋이 생성될 때까지 잡고 있어야 한다고 생각하는데, await 전에 해제하면 동시성 이슈가 발생할 수도 있음. Rust async를 잘 모르지만 커밋 이후엔 join이나 select로 막아야하는 거 아닌가 싶음
-
await에서 락을 유지해야 하면, async-aware mutex를 쓰면 됨. futures나 tokio crate가 이런 락을 구현하고 있음. 오래 잡고 있거나 await 사이에 락을 유지해야 할 때 주로 사용함. 일반 락보다 비용이 더 듦
-
await 경계에서도 락을 유지해야 할 필요가 있다면, Tokio의 async-aware mutex를 사용할 수 있음. tokio/sync/struct.Mutex 문서 참고
-