왜 타입 시스템이 중요한가
저는 지금까지 대부분의 코드를 Python이나 PHP 같은 동적 타입 언어로 작성해 왔습니다. 하지만 Rust를 살짝 다뤄본 이후로 정적 타입 시스템에 푹 빠지게 되었습니다.
어느 순간부터는 아주 자연스럽게 느껴졌습니다. 마치 완전히 새로운 자기표현 방법을 얻은 것 같았습니다.
타입은 도움을 주기 위해 존재한다
타입을 통해 여러분은 보장하는 것과 기대하는 것을 전달합니다. 기계에게도, 다른 개발자에게도 말이죠. 타입은 의도를 표현합니다.
프로그래머라면 타입에 대해 어느 정도 직관을 갖고 있을 겁니다.
sentence = "hello world"sentence가 문자열일 거라고 짐작할 수 있습니다. 어쨌든 따옴표로 감싸져 있으니까요. 하지만 타입이 다른 곳에서 추론되는 경우에는 조금 더 까다로워집니다.
sentence = xsentence는 여전히 문자열일까요? 음… 알 수 없습니다. x의 타입에 따라 달라지니까요. 어쩌면 x는 숫자이고, 그래서 sentence도 숫자일지 모릅니다. 어쩌면 x는 원래 문자열이었지만 리팩터링 과정에서 이제는 바이트 배열이 되었을지도 모릅니다. 정말 즐거운 상황이네요. 🎉
그럼 이 코드는 어떨까요?
filesize = "5000" # Size in bytes여기서는 파일 크기를 문자열로 표현했습니다.
이렇게 해도 동작은 하겠지만, 어딘가 불안한 발상입니다.
간단한 연산조차 예상치 못한 결과로 이어질 수 있습니다:
file1 = "5000"
file2 = "3000"
total = file1 + file2
print(total) # prints '50003000'어떻게 고칠 수 있을까?
파일 크기는 항상 숫자라고 안전하게 가정할 수 있습니다. 더 정확히 말하면 양의 자연수여야 합니다. 파일 크기가 음수일 수는 없고, 메모리의 가장 작은 단위는 1바이트입니다(가장 특수한 시스템을 제외하면요). 그리고 우리는 이산적인 기계를 다루고 있으므로, 컴퓨터가 처리할 수 있는 범위 내의 파일 크기만 가능하다는 것도 알 수 있습니다. 이 모든 걸 정확한 방식으로 표현할 수만 있다면 얼마나 좋을까요…?
바로 이 지점에서 타입 시스템이 등장합니다.
Rust에서는 size라는 필드를 가진 File 타입을 이렇게 정의할 수 있습니다.
struct File {
name: String,
size: usize,
}usize는 메모리의 어떤 포인터든 담을 수 있을 만큼 항상 충분히 크다는 것을 보장합니다(64비트 컴퓨터에서는 usize = u64 입니다). 이제 size의 타입에 대한 모호함은 사라졌습니다. 잘못된 file 객체는 만들려고 해도 만들 수 없습니다:
// Error: `size` can't be a string.
let weird_file = File { name: 123, size: "hello" };타입 시스템은 잘못된 상태를 막아줍니다. 여러분이 스스로 정한 규칙을 어기는 것을 그저 허용하지 않습니다. 설계상의 선택에 대해 책임을 지게 만듭니다. 감히 말하자면, 타입 시스템은 여러분 뇌의 연장선이 됩니다. 시간이 지나면 타입 체커에 의지하게 됩니다. “컴파일되면 실행된다”는 강력한 주문이 됩니다.
타입은 가독성을 높이고 맥락을 제공한다
다음 Python 코드 조각을 살펴보겠습니다:
def filter_files(files):
matches = []
for file in files:
if file.status == 0:
matches.append(file)
return matches0은 무엇을 의미할까요? 알 수 없습니다. 맥락이 부족하기 때문입니다!
다음과 같이 enum 타입을 정의하면 이야기가 조금 더 명확해집니다:
from enum import Enum
class FileStatus(Enum):
OPEN = 0
CLOSED = 1앞서 본 예제는 이렇게 바뀝니다
def filter_files(files):
matches = []
for file in files:
if file.status == FileStatus.OPEN:
matches.append(file)
return matches규모가 큰 코드베이스에서는 0보다 FileStatus.OPEN이 훨씬 찾기 쉽습니다.
참고: 네이티브 enum 타입은 Python 역사에서 아주 늦게 도입되었습니다. 타입 시스템을 강화하는 것이 가독성 향상에 어떻게 도움이 되는지 보여주는 좋은 예시입니다.
서로 다른 타입을 조합하면 마법이 일어난다
타입을 현명하게 선택하면 모든 조각이 갑자기 제자리를 찾습니다. 어느새 컴파일러가 여러분의 설계 결정을 검증하고 모든 타입이 서로 잘 어우러지는지 확인하기 시작합니다. 머릿속 모델의 결함을 짚어내 주기도 합니다. 이는 리팩터링할 때 큰 자신감을 줍니다.
예를 들어 정렬을 생각해 봅시다. 정렬을 떠올리면 저는 가장 먼저 숫자로 이루어진 리스트를 떠올립니다:
sorted([1,5,4,3,2]) # [1,2,3,4,5]이건 잘 동작하는 경우입니다. 그럼 이건 어떨까요?
sorted(1)앗, 이건 동작할 수 없습니다. 1은 컬렉션이 아니라 단일 숫자이기 때문입니다! sorted에 넘기기 전에 타입을 확인하는 걸 잊으면 프로그램 실행 중에 오류가 발생합니다.
sorted([1, "fish"])Python 2에서는 결과가 [1, 'fish']가 됩니다 (문자열은 길이에 따라 비교되기 때문에)
수정: Reddit 사용자 jcdyer3님이 지적했듯이 이유는 비교할 수 없는 타입끼리 비교할 때 타입을 기준으로 정렬되기 때문에 모든 정수가 모든 문자열보다 앞에 오게 됩니다. 이는 CPython 구현 세부 사항입니다).
출처: Freepik 제공 일러스트
Python 3부터는 예외가 발생합니다.
TypeError: '<' not supported between instances of 'str' and 'int'훨씬 낫습니다! 오류 원인이 하나 줄어든 셈입니다. 하지만 문제는 이 오류가 런타임에 발생한다는 것입니다. Python의 동적 타이핑 때문이지요. 정적 타입 언어였다면 이를 피할 수 있었을 겁니다.
fn sorted<T>(collection: &mut [T]) where T: PartialOrd {
// TODO: Sort the collection here.
}무섭게 생겼지만 사실 그렇지 않습니다.
우리는 collection이라는 입력 매개변수 하나를 받는 sorted라는 함수를 정의합니다.
collection의 타입은 네 부분으로 이루어져 있습니다:
&는 컬렉션을 “빌린다”는 뜻으로, 소유하지 않는다는 의미입니다. 함수가 반환된 뒤에도 컬렉션은 그대로 존재합니다. 정리되지 않습니다.mut은 컬렉션이 가변(mutable)이라는 뜻입니다. 수정할 수 있습니다.[T]는 입력으로 리스트/슬라이스/벡터를 기대한다는 뜻입니다. 그 외의 것은 컴파일 타임(프로그램이 실행되기도 전)에 거부됩니다.PartialOrd가 핵심 비법입니다. 이는 일종의 인터페이스와 같은 트레이트입니다. 컬렉션 안의 모든 원소T가 부분 순서를 가져야 한다는 뜻입니다.
이 모든 정보는 컴파일러가 우리가 스스로 발등을 찍는 일을 방지하도록 돕습니다. 그리고 다른 곳을 찾아보지 않고도 함수의 입력과 출력을 이해할 수 있게 해 줍니다.
핵심 정리
- 타입은 개발자가 숙제를 제대로 하고 코드의 보장과 한계에 대해 고민하도록 만든다.
- 타입을 제약으로 생각하지 말고, 자신의 잘못된 사고 모델로부터 지켜주는 안전망으로 생각하라.
- 항상 의도를 가장 정확하게 표현하는 타입을 선택하라.
- 표준 라이브러리에 완벽한 타입이 없다면 더 단순한 타입들을 조합해 직접 만들어라.
이런 규칙들을 따르다 보니, 마치 마법처럼 아이디어를 가장 우아하게 표현하는 방향으로 이끌리고 있음을 알게 되었습니다. 제 코드는 훨씬 더 관용적으로 바뀌었습니다.
글을 무작위로 읽기