이 글은 TypeScript에 익숙한 입장에서 Rust를 처음 펼쳤을 때 가장 먼저 들게 된 의문인, "왜 타입이 이렇게 많은가"를 다룹니다. 답을 찾는 과정에서 타입이라는 개념 자체에 대한 관점의 전환, 내지는 확장까지 함께 다룹니다. Rust 학습 시리즈를 계속 작성하고자 하는데, 그 첫 글입니다. 우선 필자는 Rust 에 대해 거의 무지했으며, Typescript / Javascript 기반의 언어만을 다뤄왔음을 먼저 말씀드립니다. 제가 드는 대부분의 예시와, 비유는 이에 기반해있음을 말씀드리고 싶습니다.
우선, TypeScript의
number로부터 그 고찰이 시작됩니다. TypeScript의 number타입은 오직 number뿐이었습니다. 정수든 실수든, 음수든 양수든, 1이든 80억이든 모두 number 하나로 받았습니다. Rust에서는 정수만 i8, i16, i32, i64, i128, u8, u16, u32, u64, u128, 거기에 usize와 isize까지 12종이 늘어서 있었습니다. 처음에는 유난스럽다고 생각했습니다. 같은 정수를 굳이 12가지로 나눌 이유가 있는가? 이에 대해 묻는 글입니다.추론이 막히는 지점
Rust의 정수 타입을 처음 마주한 곳은 의외로 간단한 코드였습니다.
다음 코드는 사용자의 나이를 받아 남은 햇수를 계산하는 예시입니다.
let age: u32 = 150; let years_left = 100 - age;
age는 u32로 명시되어 있습니다. 그렇다면 years_left는 어떤 타입일까요. 더 근본적으로, 100은 어떤 타입일까요.TypeScript에서는 이런 질문 자체가 성립하지 않습니다.
100은 number이고, age도 number이고, 100 - age도 number입니다. 모든 정수 리터럴이 같은 타입이라 추론할 거리가 없습니다.Rust에서
100은 처음에는 타입이 없습니다. 정수 리터럴은 내부적으로 미정 상태로 있다가, 자기가 어디에 쓰이는지를 보고 타입을 정합니다. 100 - age에서 age가 u32로 확정되어 있으므로, 컴파일러는 100도 u32여야 한다고 추론합니다. 결과인 years_left도 자동으로 u32가 됩니다. (애초에 같은 타입끼리밖에 뺄 수 없어서 u32라고 생각해도 편합니다.)100이라는 값은 그대로지만, 어떤 변수 옆에 놓이느냐에 따라 다른 타입으로 정해집니다. 문맥에 따라 정해지는 모습입니다.let a: i8 = 100; // 100은 i8 let b: u64 = 100; // 100은 u64 let c: f64 = 100.0; // 100.0은 f64
여기까지는 TypeScript의 contextual typing과 비슷해 보입니다. 차이는 그다음입니다.
years_left가 u32라는 사실은 단순한 분류가 아니라, 이 변수에 음수가 절대 들어올 수 없다는 약속입니다. 위 코드에서 age가 150이면 100 - 150은 음수여야 하지만 u32는 음수를 담을 수 없을 겁니다. 이러한 상황에 대해서 항상 Rust는 의심합니다.타입이 값의 가능한 범위를 제한한다. 이것이 첫 번째 깨달음이었습니다.
비트 패턴은 같은데 해석이 다르다
이 시점에서 더 근본적인 의문이 생겼습니다. 컴퓨터는 메모리에 0과 1만 저장합니다. 그런데 어떻게 같은 메모리를 보고 어떤 곳에서는 양수만, 어떤 곳에서는 음수까지 다룰 수 있는가.
8비트 메모리 하나를 떠올려보면 답이 보입니다.
11111111이라는 비트 패턴이 있을 때, 이것을 u8로 해석하면 255입니다. 같은 패턴을 i8로 해석하면 -1입니다. 정확히는 2의 보수 표현이지만 이 글에서는 그 깊이까지 들어가지 않습니다.비트 패턴: 11111111 u8로 해석하면 → 255 (0 ~ 255 범위의 부호 없는 정수) i8로 해석하면 → -1 (-128 ~ 127 범위의 부호 있는 정수)
같은 메모리를 두고 한쪽은 255라고 읽고, 다른 쪽은 -1이라고 읽습니다. 메모리는 그대로인데 해석이 다릅니다. 결국 한 가지 결론을 내릴 수 있습니다.
타입은 메모리 자체가 아니라, 메모리를 어떻게 읽을지에 대한 약속입니다.
이 한 문장을 받아들이고 나니 정수 타입이 12개인 이유가 전부 따라왔습니다.
i와 u는 부호 비트를 쓸 것인지의 약속이고, 뒤의 숫자는 몇 비트짜리 메모리를 약속의 단위로 삼을 것인지입니다. 12종은 자의적인 분류가 아니라, 메모리 해석 방식의 가능한 조합이었습니다.u8 : 8비트 → 0 ~ 255 u16 : 16비트 → 0 ~ 65,535 u32 : 32비트 → 0 ~ 약 42억 u64 : 64비트 → 0 ~ 약 1800경 i8 : 8비트 → -128 ~ 127 i16 : 16비트 → -32,768 ~ 32,767 i32 : 32비트 → -약 21억 ~ 약 21억 i64 : 64비트 → -약 900경 ~ 약 900경
비트가 많을수록 더 큰 값을 담지만 메모리를 그만큼 더 씁니다. 같은 정보를 어느 정밀도로 다룰 것인지를 개발자가 고릅니다.
usize / isize - 컴퓨터에 따른 타입?
12종 중에서
usize와 isize만 다른 결로 존재합니다. 비트 수가 고정되어 있지 않고, 컴파일되는 컴퓨터의 포인터 크기에 따라 변합니다. 64비트 컴퓨터에서는 64비트, 32비트 컴퓨터에서는 32비트가 됩니다. 어렸을 때, 게임을 설치할 때면 항상 x32 와 x64 도중 고민을 해야했던 기억이 있을 겁니다. 아래의 이미지는 둘 다 64비트네요.처음에는 이 타입의 존재 이유를 이해하지 못했습니다. 항상
u32나 u64로 충분하지 않은가. 답은 배열 인덱싱이 무엇인지를 들여다본 뒤에야 보였습니다.다음 코드는 Rust에서
i32로 배열에 접근하려 했을 때의 모습입니다.let v = vec![10, 20, 30, 40, 50]; let i: i32 = 5; let item = v[i]; // 컴파일 에러: usize를 기대했는데 i32가 왔음 let item = v[i as usize]; // 명시적 변환 - Typescript 의 타입 단언과도 비슷하게 생겼죠?
v[3]이라는 표현은 사실 시작 주소에서 3칸 떨어진 메모리 위치를 계산하는 작업입니다. 배열 시작 주소가 1000번지이고 원소가 4바이트라면, v[3]은 1000 + 3 × 4 = 1012번지입니다. 인덱싱은 메모리 주소 계산입니다.그렇다면 인덱스는 어떤 타입이어야 자연스러운가. 메모리 주소는 음수일 수 없으므로 부호 없는 타입이어야 합니다. 그리고 그 컴퓨터의 메모리 주소 공간 전체를 표현할 수 있어야 합니다. 두 조건을 만족하는 것이
usize입니다.usize는 자의적으로 도입된 타입이 아니라, 메모리 주소를 다룬다는 도메인이 강제한 타입이었습니다. Rust가 자동 변환을 막고 as usize를 쓰게 한 것도 같은 맥락입니다. i32의 -5를 자동으로 usize로 변환하면 비트 패턴이 그대로 해석되어 약 42억이라는 거대한 양수가 되고, 배열 범위를 한참 벗어난 메모리에 접근하게 됩니다. 명시적 변환은 그 위험을 개발자가 의식하라는 강제입니다.number 하나의 편안함이 감춘 것
다시 처음으로 돌아옵니다. JavaScript의
number는 어떻게 이 모든 구분 없이 살아남았는가.JavaScript는 모든 숫자를 64비트 IEEE 754 부동소수점 하나로 통일했습니다. 정수든 실수든, 1이든 80억이든 같은 8바이트 박스에 담깁니다. 개발자가 메모리 레이아웃을 신경 쓰지 않아도 되는 추상화입니다.
이 추상화에는 대가가 따릅니다. 정수 1을 저장하는 데도 8바이트가 그대로 쓰이므로 메모리 효율이 떨어집니다.
0.1 + 0.2가 0.3이 되지 않는 부동소수점 오차가 정수 연산에까지 따라옵니다. 2^53 이상의 정수는 정확하게 표현되지 않아 나중에 bigint라는 별도 타입이 추가되었습니다.100만 명의 나이 데이터를 다룬다고 해봅니다. 각 나이를
u8로 저장하면 100만 바이트, 즉 1MB입니다. JavaScript의 number로 저장하면 800만 바이트, 8MB입니다. 같은 정보가 8배의 메모리를 씁니다. 브라우저에서 React 컴포넌트를 다루는 동안에는 보이지 않던 차이가, 시스템 레벨로 내려가면 결정적인 차이가 됩니다.JavaScript는 "타입을 신경 쓰지 않아도 되는 편안함"을 택했습니다. Rust는 그 편안함이 감추고 있던 선택지를 개발자에게 돌려주었습니다. 12종의 정수 타입은 사치가 아니라, 시스템 프로그래밍이라는 도메인에서 정직하게 보여야 할 정보였습니다.
마무리
Rust의 정수 타입이 12개인 이유는 결국 한 문장으로 압축됩니다. 타입은 메모리 자체가 아니라 메모리를 읽는 약속입니다. JavaScript는 약속을 하나로 통일하여 편의를 얻었고, Rust는 약속을 여럿으로 나누어 정밀함을 얻었습니다. 어느 쪽이 옳은지가 아니라, 두 언어가 들여다보는 추상화의 층위가 다를 뿐입니다.
이 글에서는 정수 리터럴의 추론에서 출발해 비트 해석,
usize의 정당성, 그리고 JavaScript의 number가 감춘 비용까지 살펴봤습니다. 다음 글에서는 같은 관점을 더 멀리 끌고 가보려 합니다. Rust의 타입 시스템이 컴파일 타임에 검사하려는 것은 메모리 레이아웃만이 아닙니다. Option<T>와 Result<T, E>를 통해, 런타임에 일어날 수 있는 가능성 자체를 타입으로 끌어올립니다. 다음 학습 노트에서 이어서 다루겠습니다.
kyu-log