IT 정보

퍼온글 : 러스트 프로그래밍 언어란 무엇이며, 왜 중요한가?

seungdols 2015. 2. 18. 11:25
모든 권리는 해당 게시자인 랜덤여신님께 있습니다.

제가 '파이선'이라는 프로그래밍 언어를 처음 배우게 된 것은 2004년의 일입니다. 그 이후로 10년 동안, 파이선은 저의 제1언어였습니다. 제 모국어는 C++(MFC)이지만, 가장 오래 그리고 능숙하게 쓰는 언어는 파이선이죠.

파이선은 좋은 언어지만, 몇 가지 (심각한) 단점이 존재하는 언어이기도 합니다. 다른 언어로 바꾸고 싶은 마음이 자주 들던 무렵, 제 눈에 '러스트'라는 언어가 눈에 띄었고, 제 마음 속의 할 일 목록에 갈무리해 두었습니다. 그러다 지난해 말, 저는 마침내 파이선 대신 러스트를 주 언어로 써야겠다고 결심하게 되었습니다.

이 글에서는 러스트가 어떤 프로그래밍 언어인지 간략히 설명할 것입니다. 사실 간략하다고는 하나, 그것은 글의 길이가 짧다는 의미고, 내용 자체는 상당히 어려울 수도 있습니다. 또한, 저 자신이 러스트에 대해 완전히 알지 못하기 때문에, 간단 명료하게 설명하지 못한 부분이 있을 수도 있습니다. 양해하고 읽어 주시면 감사하겠습니다.

----

[러스트의 역사]



러스트는 원래 Graydon Hoare가 혼자 개발하던 언어였습니다. 그러던 것이 이 언어의 잠재성을 알아 본 모질라가 2009년부터 스폰싱을 시작하였고, 2012년 1월에 드디어 본격적인 개발이 시작되었습니다. 따라서 Graydon이 개인적으로 개발한 기간까지 합치면 8년 가까이, 본격적인 개발이 시작된 후부터 따지면 3년이 넘는 기간 동안 개발된 언어라고 할 수 있겠습니다.

모질라는 웹 기술의 주도자답게, 러스트를 이용하여 웹 브라우저를 다시 짤 생각을 하고 있습니다. 이를 위한 시험 프로젝트로 'Servo'라는 브라우저 엔진이 개발되고 있으며, 이미 Gecko의 3배 정도로 빠르다고 합니다.

----

[왜 러스트인가?]

C나 C++에서 프로그래밍을 하다 보면 자주 만나게 되는 것이 '이 프로그램에서 잘못된 연산을 수행'(segmentation fault)하는 오류입니다. 메모리 관리를 프로그래머가 직접 하다 보니 실수를 하게 되는 거죠. 대표적인 경우로 use-after-free(free()한 다음에 메모리를 사용하는 문제) 그리고 double-free(한 메모리를 두 번 free()하는 문제)가 있습니다. 이 실수가 좋게 끝나면 그냥 프로그램이 죽는 거고, 안 좋게 끝나면 온갖 보안 버그가 발생합니다. 그런데 사람이다 보니 실수를 아예 안 할 순 없죠.

그래서 자바를 필두로 파이선, PHP, C# 같은 오늘날의 프로그래밍 언어들은 '쓰레기 수집'(garbage collection)이라는 기능을 갖추고 있습니다. 이러한 언어를 사용하면 메모리 관리를 프로그래머가 직접 할 필요가 없습니다. 사실, 이들 언어에는 free() 자체가 없습니다. 하고 싶어도 못 하죠.

하지만 쓰레기 수집에 장점만 있는 것은 아닙니다. 다음과 같은 단점이 있죠:

- 비용: 쓰레기 수집은 [공짜가 아닙니다.] 어떤 객체 때문에 메모리를 필요로 할 때, 프로그래머는 이 메모리를 정확히 언제 반환해야 하는지 알지만, [컴퓨터는 모릅니다.] 그래서 컴퓨터는 주기적으로 객체가 아직 필요한지 아닌지 확인하거나(tracing GC), 아니면 포인터가 생성되고 제거될 때마다 참조 횟수를 확인해야 합니다(reference counting). 두 방법 모두 CPU와 메모리를 사용하며, 따라서 [프로그램 수행 속도가 느려집니다.]

- 멈칫멈칫: 쓰레기 수집이 일어나는 동안에는 [프로그램의 실행이 잠시 멈추게 됩니다.] 대부분의 경우 큰 문제는 없지만, 몇몇 사용례에서는 문제가 될 수 있습니다. 예를 들어 무인 자동차를 만들었는데, 쓰레기 수집을 하느라 [브레이크를 제때 밟지 못하면] 큰 사고가 나겠죠. 밀리초 단위로 온도가 왔다갔다하는 핵 발전소는 어떨까요. 하다못해 FPS 게임이나 리그 오브 레전드만 하더라도, 스킬을 썼는데 [랙 때문에 제때 안 나가면] 부모님 안부를 묻게 될 것입니다. 안드로이드가 과거 느릿느릿하게 느껴졌던 것도 이와 관련이 있습니다. 사람이 느끼는 '빠른 GUI'란, 무언가를 눌렀을 때 어떨 때는 빠르게 반응하고 어떨 때는 느리게 반응하는 것이 아니라, 항상 평균적인 빠르기로 반응하는 것이기 때문이죠. 이를 '예측 가능성'(predictability)이라고 합니다. 물론 수십 년 동안의 노력 덕분에 오늘날의 쓰레기 수집 알고리즘은 상당히 정교하지만, 그럼에도 이 문제를 완전히 해결하진 못했습니다.

- RAII: 메모리의 수명과 자원의 수명을 일치시키려는 'RAII'라는 원칙이 있습니다. 일부 쓰레기 수집 알고리즘에서는 메모리가 해제되는 시점이 불명확하기 때문에 이러한 원칙에 위배됩니다. 그래서 이들 언어에서는 반드시 .close()를 호출해 줘야 하죠. 혹시라도 잊게 되면 사단이 납니다.

따라서 성능이 소중한 몇몇 분야에서는 여전히 메모리 안전성이 떨어지는 C/C++를 사용할 수밖에 없었습니다. 대표적인 분야가 게임이나 운영 체제죠. 물론 덕분에 온갖 보안 버그도 같이 따라왔습니다. C/C++에서 발생하는 보안 버그들 중 절대 다수는, 알고리즘에 근본적인 문제가 있다기보다는, 순전히 프로그래머가 실수로 메모리 관리를 제대로 하지 못했기 때문에 발생합니다. 자바나 파이선처럼 쓰레기 수집이 되는 언어를 사용했다면 벌어지지 않았을 일이죠. 그러나 성능 때문에 C/C++를 포기할 수도 없었습니다.

그래서 나온 것이 러스트입니다. 러스트는,

쓰레기 수집이 필요 없는 메모리 안전성(memory safety without garbage collection)

을 갖추고 있습니다. 이는 러스트의 가장 큰 특징이자, 연구 언어가 아닌 상용 언어들 중에서는 유일하기도 합니다. 기존의 언어는 메모리 안전성이 없거나, 아니면 쓰레기 수집을 사용하기 때문이죠. 러스트는 대체 어떻게 이런 일을 실현했을까요?

이를 위해 러스트에는 '소유권'(ownership)이라는 개념이 있습니다. 어떤 객체를 가리키는 포인터는 여러 개가 있을 수 있지만, 그 중 오직 하나만 소유권을 가지고 있습니다. 또한 소유권이 있는 포인터만 객체의 내용을 바꿀 수 있습니다. 마지막으로, [소유권을 가진 그 포인터가 사라질 때, 메모리도 해제됩니다.] 가장 중요한 것은 이러한 확인 과정이 컴파일 시간에 이루어진다는 것입니다. 즉, 쓰레기 수집이 동반하는 런타임 오버헤드가 러스트에는 없습니다. 이 규칙에 어긋나는 코드가 발견되면(즉, 메모리 안전성을 침해할 만한 코드가 발견되면), 컴파일 오류가 납니다.

예를 들어, 다음과 같은 C++ 코드를 보겠습니다:


std::vector niko;

niko.push_back("니코니코니");

std::string &a = niko[0];
std::cout << a << std::endl;

niko.push_back("아나타노 하-토니");

std::cout << a << std::endl;


이 코드는 프로그래머의 의도대로라면 '니코니코니'를 두 번 출력해야 했을 것입니다. 하지만 실제로는 한 번만 출력되고, 두 번째 시도에서는 '잘못된 연산 수행 오류'(segmentation fault)가 납니다. 이는 niko에 두 번째 아이템이 추가되는 순간, 내부적으로 크기를 두 배로 뻥튀기한 배열을 만들고 기존 원소들을 새로 만들어진 배열에 옮겨 담기 때문입니다. 그래서 기존에 a가 가리키고 있던 메모리 주소는 무효화되고, a는 소위 말하는 'dangling pointer'가 되는 것이죠.

비슷한 일을 하는 코드를 러스트로 쓰면 다음과 같고:


let mut lyrics = vec![];

lyrics.push("쏴버려! 마음 속에 새긴 꿈을");

let a = &lyrics[0];
println!("{}", a);

lyrics.push("미래조차도 내버려 둔 채");

println!("{}", a);


이 코드는 컴파일 오류가 납니다.


test.ru:9:5: 9:11 error: cannot borrow `lyrics` as mutable because it is also borrowed as immutable
test.ru:9     lyrics.push("미래조차도 내버려 둔 채");
              ^~~~~~
test.ru:6:14: 6:20 note: previous borrow of `lyrics` occurs here; the immutable borrow prevents subsequent moves or mutable borrows of `lyrics` until the borrow ends
test.ru:6     let a = &lyrics[0];


오류 메시지에 대해 자세히 설명하진 않겠습니다만, 대충 훑자면 두 번째 아이템 추가 시도가 거부된 것을 알 수 있습니다. 그리고 그 이유는, 배열에 아이템을 추가하려면 배열에 대한 소유권을 얻어야 하는데, 그 소유권을 이미 a가 가지고 갔기 때문입니다. 따라서 배열에 아이템을 더 추가하기 전에 a가 기존 소유권을 반납해야 합니다.

----

러스트의 가장 핵심적인 부분을 설명하였으니, 이제 그외 잡다한(?) 특징들을 나열해 보겠습니다. 우선, 장점부터:

[장점]

비용이 안 드는 추상화(zero-cost abstractions)

러스트는 파이선이나 자바, 하스켈 등 고수준 언어처럼 높은 수준의 추상화를 제공합니다만, 이것을 비용이 안 드는(최소한, 적게 드는) 방향으로 달성합니다. 위의 메모리 안전성이 한 예입니다. 사람들이 C의 뒤떨어지는 추상화를 욕하면서도 계속 쓰게 되는 것은 C를 성능으로 대적할 만한 언어가 여전히 없기 때문입니다. 자바가 다년간의 노력 끝에 가장 근접해 있지만, 아직도 2배 가까이 느립니다. 하물며 파이선 같은 것은 C의 수십 배까지도 느립니다. 예전에콩식 생성기를 여러 가지 언어로 구현하면서 성능을 비교해 본 적이 있는데, 그때 결과는 이랬습니다:


C: 1.1s
Rust: 1.2s
C (unoptimized): 1.6s
Java: 3.7s
Haskell: 5.5s
Rust (unoptimized): 6.6s
JavaScript (with Node.JS): 9.3s
PyPy: 9.9s
JavaScript (with Chrome): 10.6s
JavaScript (with Firefox): 12.9s
Python: 69.8s
PHP: 107.2s


물론 콩식 생성기는 매우 특수한 목적의 프로그램이므로 위 통계가 결코 과학적인 분석이거나 한 것은 아닙니다만, 순수한 계산 성능 면에서만 보면 C 및 러스트와, 여타 언어들 사이에는 여전히 좁히기 어려운 격차가 있는 것을 알 수 있죠.

특히 러스트가 신생 언어이고, 앞으로 컴파일러의 발전에 따라 더 빨라질 가능성이 있다는 것을 생각해 보면, 최소한 성능 면에서는 러스트의 전망이 무척 밝다고 할 수 있겠습니다.

C와 호환이 쉽다

러스트는 C++와 유사하게 extern "C" 를 써서 C 호환 인터페이스를 제공할 수 있습니다. 따라서 다른 언어에서 러스트 코드를 호출하기가 쉽습니다. 또한 러스트에서 C 코드를 호출하는 것도 무척 간단합니다. 파이선처럼 ctypes 같은 모듈을 쓰면서 난리 법석을 피울 필요가 없습니다. 쓰레기 수집하는 언어들끼리는 메모리 모델이 서로 달라서 연동하는 게 매우 어렵기도 한데, 러스트는 쓰레기 수집하는 언어가 아니므로 이 경우에도 해당하지 않습니다.

문법

러스트의 문법은 C/C++/자바/PHP처럼 'C 계열'입니다. 즉 {, }, <, > 같은 것들을 씁니다. 이는 러스트의 공략 대상층이 C/C++ 사용자라서 그렇습니다. 친숙한 문법을 사용해야 기존 사람들이 적응하기 쉽겠죠.

C++보다 간단하다

C++가 지금처럼 복잡한 언어가 된 이유는, 하위 호환성을 유지하면서 온갖 개념을 무차별적으로 받아들였기 때문입니다. implicit copy/move constructor 같은 게 그렇죠. 이런 규칙을 일일이 외우고 쓰기란 무척 어렵습니다. 러스트는 하위 호환성을 고려할 필요가 없었기 때문에 (최소한 지금으로서는) 언어가 C++보다는 무척 간결합니다. 특히 C++11의 move semantics, r-value references 같은 것들이 러스트에서는 기본값이기 때문에 참 좋습니다.

Concurrency

모질라가 러스트를 바탕으로 Servo를 만들게 된 이유이기도 합니다만, 러스트는 오늘날 많은 언어처럼 concurrency를 위한 여러 가지 기능을 제공합니다. '채널'도 물론 있고요. 그 중 재밌는 기능이, 여러 스레드에서 하나의 변수를 변경하는 코드를 생각 없이 짜면, 위에서 말한 memory safety 기능과 마찬가지로, [컴파일 오류]가 난다는 것입니다. 멀티스레드에서 발생하는 온갖 문제 중 대표적인 것이 data race라고 생각해 볼 때, 이 기능 하나만으로도 상당한 버그를 잡는 데 도움이 됩니다.

뒷받침하는 기업이 있다

프로그래밍 언어란 혼자 만들 수도, 여럿이서 만들 수도 있지만, 아무래도 월급을 받는 전업 개발자가 투입되는 게 개발 속도가 더 빠르기 마련입니다. 러스트는 현재 [모질라에서 스폰싱]하고 있으며, 삼성도 일부 지원하고 있습니다. 러스트만 전업으로 개발하는 개발자가 8명 정도 있고요. 파이선 같은 경우 완전히 커뮤니티의 힘만으로 만들어졌기에 지금의 위치에 오르는 데 상당한 시간이 걸렸지만, 러스트는 훨씬 사정이 나을 것으로 보입니다.

기타

그밖에 패턴 매칭, algebraic data types, 타입 추론 같은 게 있지만 자세한 설명은 생략합니다.

----

이제 단점을 알아보면:

[단점]

이름

러스트(Rust)... 영어로 '녹'이라는 뜻입니다. 하도 많은 이름 중에 왜 하필 녹슬었다는 것인지 모르겠습니다. 잘 짠 파이선 코드를 흔히 '파이서닉'(Pythonic)하다고 하는데, 상당히 간지 나는 표현입니다. 그런데 잘 짠 러스트 코드는 현재 '녹슬었다'(Rusty)고 불리고 있습니다. 재밌긴 한데 영 슬픈 표현입니다.

게다가 러스트는 게임 러스트와도 이름이 겹칩니다. 가끔 러스트 개발자 커뮤니티에 러스트 게임 관련 질문이 올라오기도 하거든요.

컴파일러와의 사투

앞서 말한 것처럼 러스트의 컴파일러는 코드를 분석하여 잠재적으로 메모리 안전성을 침해할 만한 코드가 있으면 컴파일 오류를 냅니다. 그런데 가끔은 이게 너무 과해서 정상적인 코드처럼 보이는데도 컴파일러가 거부하는 경우가 있습니다. 95%의 경우 이것은 프로그래머가 실수로 잘못 짠 것입니다만, 나머지 5%는 컴파일러가 지나치게 보수적이기 때문에 발생하는 컴파일 오류입니다. 이 경우 다른 방식으로 짜거나, C#의 unsafe 블럭과 비슷한 구문을 동원해야 합니다.

문법

C/C++/자바/PHP 같은 'C 계열' 언어와 문법이 비슷하다는 것은 장점이지만, 단점도 됩니다. 파이선이나 하스켈 같은, 중괄호를 쓰지 않는 언어의 문법적 아름다움이 러스트에는 없습니다. 코드 전체가 특수 문자로 점칠되어 있습니다. 파이선을 주로 쓰던 저로서는 아쉬운 점입니다.

자바나 파이선, 루비보다 복잡하다

러스트가 C++보다 쉬운 건 사실이지만, 여전히 자바나 파이선 같은, 쓰레기 수집을 사용하는 자동 관리되는(managed) 언어들보다는 어렵고, 알아야 할 게 많습니다. 특히 러스트의 메모리 관리 메커니즘은, C/C++를 쓰면서 메모리 관리가 얼마나 골치 아픈 것인지 아시는 분들에게는 충분히 어필할 만하지만, 그렇지 않은 분들에게는 쓸데없이 복잡하게만 느껴질 것입니다.

----

언어는 종교와도 같기에 서로 다른 두 언어를 비교하는 것은 논란을 많이 일으키는 일이기도 합니다. 하지만 제 경험에만 의존하여, 순전히 주관적인 의견을 내놓자면 이렇습니다:

C++와의 비교

C++는 온갖 일들을 할 수 있는 언어지만, 그 대가로 엄청나게 복잡해졌습니다. C++11, C++14 등이 나오면서 훨씬 나아진 것은 사실이지만, 알아야 할 것은 더 많아졌고, 수많은 지뢰들을 밟지 않고 C++를 쓰는 것은 여전히 어렵습니다. 결정적으로 C++는 러스트만큼의 memory safety를 제공하지 않습니다.

파이선과의 비교

이 부분이 제가 중점적으로 생각한 부분입니다. 잘 쓰던 파이선을 버리고 러스트로 넘어갈 만한 충분한 이유를 발견해야, 러스트를 배우는 데 시간을 투자하는 것에 대한 자기 합리화를 할 수 있으니까요.

우선, 저는 파이선의 동적 타입에 지치기 시작했습니다. 동적 타입은 프로그래머에게 상당한 자유를 부여하지만, 변수 이름 하나 고칠 때마다 런타임에 무언가 오류가 나지 않을까 노심초사하는 것은 이제 싫증이 납니다. 그보다는 깔끔하게 컴파일 오류가 나는 게 행복할 것 같습니다.

또한, 때때로 파이선을 쓰면서 나쁜 성능 때문에 고통을 받을 때도 있었습니다. 쿨하게 서버를 더 붙여 버리는 수도 있지만, 그것도 다 비용이고, 결정적으로 몇몇 경우에는 horizontal scaling이 불가능한 경우도 있습니다. 한 예로, 위키백과의 경우 최근 PHP의 느린 속도를 개선하고자 페이스북의 PHP 컴파일러를 도입했고, 페이지 반응 속도를 두 배로 빠르게 할 수 있었습니다.

러스트가 파이선보다 어렵고 코드 줄 수도 늘어나는 건 사실이지만, 빠르고 효율적인 프로그램을 짤 수 있다면 감수할 만한 가치가 있다고 생각합니다.

하스켈과의 비교

파이선을 버리고 옮겨갈 언어로 지난 수 년 동안 고려했던 또 하나의 언어는 하스켈입니다. 하스켈은 순수 함수형 언어로서, 새롭고 참신한 개념을 많이 도입해 왔고, 성능 또한 준수합니다. 그러나 결국 주 언어로 선택하지 않았던 이유는:

하스켈은 함수형 언어고, 기존 프로그래머들이 익숙한 절차형 언어랑은 많은 부분에서 다릅니다. 그래서 하스켈을 배우는 건 상당히 어렵습니다. 반면에 러스트는 일단 절차형 언어고, C 계열 문법을 사용하기 때문에 상대적으로 배우기 쉽습니다. 저 혼자 짜는 프로그램이라면 상관이 없겠지만, 주위 동료들이 하스켈을 쓸 수 없다면, 여럿이서 하는 프로젝트에 하스켈을 적용하기는 여전히 어려움이 있다고 생각했습니다.

하스켈의 성능이 괜찮긴 하나 여전히 zero-cost abstractions이라고 부르기에는 어려움이 있습니다. 일단 하스켈 자체가 쓰레기 수집 언어인데, 함수형 언어들의 immutability한 특성 때문에 쓰레기가 무척 많이 발생합니다. 이는 메모리(I/O) 관련 성능이 떨어진다는 의미이기도 합니다. 그래도 위의 콩 벤치마크에서 보듯이 파이선보다는 훨씬 빠르지만, C/C++와 비교하면 다소 아쉽습니다.

----

지금까지 언급한 저의 '러스트 찬송가'가 사실에 가깝다고 해도, 잘 돌아가는 코드베이스를 버리고 러스트로 다시 짜는 것은 어지간한 모험심이 없으면 시도하기 어려운 일이겠죠. 특히 기존 코드베이스가 수백만 줄이라면 모든 것을 다시 짜는 건 불가능에 가깝습니다.

따라서 저의 제안은, 기존 프로젝트의 새로운 모듈만이라도 러스트를 도입해 보면 어떨까 하는 것입니다. 러스트의 C 호환성 때문에 다른 언어와 결합하는 것은 상대적으로 쉽습니다. 완전히 새로운 프로젝트를 시작할 일이 있다면 러스트를 고려해 보는 것도 괜찮겠고요.

물론 언어를 선택하는 데는 언어 자체의 완성도뿐만 아니라 수많은 고려 사항들이 있습니다. 개발 도구의 성숙도, 라이브러리 생태계, 주변에 도움을 요청할 사람이 많은가 여부 등...

그럼에도 불구하고 저는 러스트에서 상당히 큰 가능성을 보고 있습니다. 지금까지 C/C++를 대체하겠다고 수많은 언어가 나왔지만, 쓰레기 수집으로 인한 성능 저하와, 실시간성을 달성하기 어렵다는 문제(특히 임베디드에서는 이게 필수죠) 때문에 완전히 대체하지 못했습니다. 과연 러스트는 성공할 수 있을까요?

1.0 버전 출시가 5월 15일로 정해진 지금은 러스트를 배우기 최적의 때입니다. 어떤가요, 한번 도전해 보고 싶지 않으신가요?

http://www.rust-lang.org/


반응형