타입 시스템
고친 사람 github-actions[bot]
타입 시스템은 프로그램 속 값에 종류를 매겨서 종류가 맞지 않는 계산을 오류로 걸러 냅니다. 문자열에서 숫자를 빼는 코드가 이 규칙에 걸립니다. 대부분의 프로그래밍 언어가 저마다의 타입 시스템을 갖추고 있습니다. 서버와 클라이언트가 주고받는 데이터의 모양을 적는 스키마 언어에도 같은 장치가 들어 있습니다.
쉽고 빠른 이해
타입 시스템은 코드 조각마다 「정수」「문자열」 같은 딱지를 붙이는 규칙입니다. 딱지끼리 어울리지 않는 계산이 나오면 오류로 알립니다. 이름 글자 수에 1을 더하는 코드는 통과합니다. 글자 「유미」에서 1을 빼는 코드는 걸립니다.
이 규칙이 없으면 종류가 어긋난 계산이 끝까지 흘러갑니다. 틀린 값이 저장되거나 서버가 요청을 처리하다 멈춥니다.
어떻게 도나:
- 값과 변수마다 종류를 정합니다
- 작은 코드 조각의 종류부터 구해 큰 조각으로 올라갑니다
- 규칙에 없는 조합이 나오면 오류로 알립니다
대가는 손이 더 간다는 것입니다. 종류를 적고 맞추는 품이 듭니다. 실행하면 잘 도는 코드도 규칙에 안 맞으면 거절당합니다. 오래 여러 사람이 고치는 코드일수록 이 품이 값을 합니다. 한 번 돌리고 버릴 짧은 스크립트에서는 품만 남습니다.
상세
콘센트와 플러그는 규격이 맞아야 꽂힙니다. 전압이 다른 기기를 잘못 꽂는 실수는 전기가 흐르기 전에 손에서 걸립니다. 꽂아도 되는지를 규격 하나로 미리 가려 둔 덕분입니다.
값이 속하는 종류를 데이터 타입, 줄여서 타입이라고 부릅니다. 정수 · 문자열 · 날짜가 타입입니다. 타입 하나는 어떤 값이 들어올 수 있는지와 그 값으로 무엇을 할 수 있는지를 함께 정합니다. 앞의 콘센트로 치면 플러그 규격이 타입입니다.
타입 시스템은 프로그램의 각 부분에 타입을 매기는 규칙의 묶음입니다. 타입끼리 맞지 않는 조합이 나오면 그 프로그램을 오류로 거절합니다. 「정수 더하기 정수는 정수」 「문자열에서 정수는 뺄 수 없다」 같은 문장 하나하나가 이 묶음에 든 규칙입니다. 콘센트에서 꽂아도 되는지를 가리던 일을 이 규칙들이 맡습니다.
이런 규칙이 없으면 타입이 어긋난 계산이 끝까지 흘러갑니다. JavaScript 에서 글자 "30" 에 1 을 더하면 오류 없이 "301" 이 나옵니다. 이 값이 나이 칸에 그대로 저장되기도 합니다.
Python 에서는 값이 없다는 뜻의 None 에 문자열 메서드 upper() 를 부르면 실행 중에 오류가 납니다. 서버라면 그 요청 처리가 도중에 멈춥니다. 타입 시스템은 이런 어긋남을 규칙으로 찾아내 오류로 알립니다.
이 절은 먼저 규칙이 코드 한 줄을 어떻게 판정하는지 봅니다. 이어서 타입 규칙이 막는 오류와 못 막는 오류를 가릅니다. 그다음 언어마다 타입 시스템이 갈리는 네 축을 봅니다. 검사 시점 · 타입을 맞춰 보는 기준 · 말없이 바뀌는 타입 · 나중에 얹는 타입입니다.
검사 시점을 본 뒤에는 실행 전에 검사하는 쪽이 멀쩡한 코드까지 거절하는 까닭을 봅니다. 마지막으로 타입을 더 자세히 적는 장치와 API(Application Programming Interface, 프로그램끼리 기능을 주고받는 약속)의 스키마에 들어간 타입 시스템을 봅니다. 엄격한 규칙이 치르는 대가로 절을 맺습니다. 예는 Java · Python · JavaScript · TypeScript · Kotlin 코드와 GraphQL 스키마입니다.
식 하나를 판정하는 방법
값을 만들어 내는 코드 조각이 식입니다. 1 · name · name.length() + 1 이 모두 식입니다. 타입 시스템은 식마다 타입을 하나씩 붙입니다.
긴 식은 한 번에 판정하지 않습니다. 가장 작은 조각의 타입을 먼저 정합니다. 그다음 규칙을 적용해 한 단계 큰 식의 타입을 구합니다. 식 전체에 타입이 붙을 때까지 이 일을 되풀이합니다.
Java 코드로 봅니다. Java 에서는 실행하기 전에 컴파일러가 이 판정을 합니다. 컴파일러는 소스 코드를 실행할 수 있는 형태로 바꾸는 프로그램입니다. 코드 오른쪽 주석은 그 줄이 내는 값이나 오류입니다.
String name = "유미";
int n = name.length() + 1; // 3
둘째 줄의 식이 어떻게 판정되는지 그림으로 옮기면 아래와 같습니다. 콜론 오른쪽이 그 식에 붙은 타입입니다. 화살표는 큰 식에서 조각으로 내려갑니다. 판정은 거꾸로 맨 아래 조각부터 위로 올라갑니다.
flowchart TD
A["name.length() + 1 : int"] --> B["name.length() : int"]
A --> C["1 : int"]
B --> D["name : String"]
맨 아래의 name 은 첫 줄에서 String 으로 선언했습니다. String 에는 정수를 돌려주는 length() 가 있으므로 name.length() 는 int 가 됩니다. 1 도 int 입니다. int 더하기 int 는 int 라는 규칙에 따라 식 전체가 int 가 됩니다.
규칙에 없는 조합을 만나면 판정이 거기서 멈춥니다.
int m = name - 1; // 컴파일 오류
String 에서 int 를 빼는 규칙은 없습니다. 컴파일러는 이 줄을 오류로 알리고 프로그램을 만들지 않습니다. 이렇게 규칙을 적용해 보는 일이 타입 검사입니다.
타입 규칙이 막는 오류와 못 막는 오류
타입 검사를 통과한 프로그램에는 특정한 종류의 오류가 생기지 않습니다. 문자열에서 숫자를 빼는 일과 객체에 없는 메서드를 부르는 일이 그 종류입니다. 이 약속이 지켜지는 성질이 타입 안전성입니다.
타입이 보는 것은 값의 종류뿐입니다. 0 과 5 는 둘 다 정수입니다. 그래서 0으로 나누는지는 타입만으로 가려지지 않습니다. 아래 표는 흔한 오류를 타입 규칙이 잡는지로 가른 것입니다.
| 오류 | 타입 규칙에 걸리나 |
|---|---|
| 문자열에서 숫자 빼기 | 걸린다 |
| 객체에 없는 메서드 부르기 | 걸린다 |
| 0으로 나누기 | 대개 안 걸린다 |
| 배열 길이를 넘는 번호로 읽기 | 대개 안 걸린다 |
| 더해야 할 곳에서 빼기 | 안 걸린다 |
표의 아래 세 줄은 타입이 맞는 채로 틀리는 오류입니다. 그래서 타입 검사를 통과한 코드에도 테스트가 따로 필요합니다.
검사 시점 — 정적 타입과 동적 타입
타입 규칙을 언제 적용하느냐로 타입 시스템이 크게 둘로 갈립니다. 앞의 Java 처럼 실행하기 전에 코드 전체를 검사하는 쪽이 정적 타입입니다.
Python 처럼 실행하다가 그 줄에 도착했을 때 검사하는 쪽은 동적 타입입니다. 규칙에 어긋나면 그 계산을 멈추고 오류를 냅니다. 그 줄을 지나지 않는 실행에서는 오류가 드러나지 않습니다.
정적 타입 언어라고 타입을 매번 적어야 하는 것은 아닙니다. 컴파일러가 값을 보고 타입을 알아내는 타입 추론이 있습니다. Java 에서 var n = 1; 이라고 쓰면 n 은 적지 않아도 int 가 됩니다.
실행해 보지 않고 거절하는 코드
검사 시점을 실행 전으로 당기면 치르는 대가가 하나 있습니다. 정적 타입 검사는 코드를 실행해 보지 않고 판정합니다. 그래서 판정이 조심스럽습니다. 오류가 날 수 있는 코드는 실행하면 멀쩡하더라도 거절합니다.
Object 는 Java 의 모든 객체를 담을 수 있는 가장 넓은 타입입니다. 아래 코드에서 o 에 든 값은 문자열입니다.
Object o = "hello";
int n = o.length(); // 컴파일 오류
문자열이니 length() 를 부를 수 있습니다. 그런데 컴파일러가 아는 것은 선언된 타입 Object 뿐입니다. Object 에는 length() 가 없습니다. 그래서 컴파일러는 이 줄을 거절합니다.
아래 그림은 프로그램을 세 겹의 상자로 나눈 것입니다. 안쪽 상자일수록 좁습니다.
flowchart TD
subgraph ALL["작성할 수 있는 모든 프로그램"]
subgraph RUN["실행해도 타입 오류가 안 나는 프로그램"]
subgraph PASS["타입 검사를 통과하는 프로그램"]
P1["name.length() + 1"]
end
Q1["o.length()"]
end
R1["name - 1"]
end
타입 안전성을 지키는 타입 시스템이라면 통과한 프로그램은 모두 가운데 상자 안에 듭니다. 앞의 o.length() 처럼 가운데 상자에는 들지만 안쪽 상자에는 못 드는 프로그램도 있습니다. 안쪽 상자를 넓히려면 규칙이 값을 더 자세히 구별해야 합니다. 규칙이 늘수록 배우고 검사하는 데 품이 더 듭니다.
개발자가 컴파일러에게 타입을 알려 줄 수도 있습니다. 값을 다른 타입으로 보라고 적는 형 변환입니다.
int n = ((String) o).length(); // 5
이렇게 쓰면 통과합니다. 대신 o 에 문자열이 아닌 값이 들어 있으면 실행 중에 오류가 납니다. 검사를 컴파일러에서 실행 시점으로 넘긴 셈입니다.
타입을 맞춰 보는 기준 — 이름과 모양
타입 시스템은 한 타입이 필요한 곳에 다른 타입의 값을 넣어도 되는지 판정해야 합니다. 이 판정의 기준이 크게 둘입니다. 이름으로 맞추는 방식과 모양으로 맞추는 방식입니다.
Java 는 이름으로 맞춥니다. 아래 두 클래스는 칸 구성이 똑같습니다.
class UserId { long value; }
class OrderId { long value; }
UserId id = new OrderId(); // 컴파일 오류
구성이 같아도 이름이 달라서 서로 대신할 수 없습니다. 이름으로 맞추는 이 방식이 명목적 타이핑입니다. 주문 번호를 회원 번호가 필요한 곳에 넣는 실수를 이 방식이 막아 줍니다.
TypeScript 는 모양으로 맞춥니다. TypeScript 는 JavaScript 에 타입 검사를 얹은 언어입니다.
type User = { name: string };
function greet(u: User) { return u.name; }
const cat = { name: "유미", age: 3 };
greet(cat); // "유미"
cat 은 User 라고 선언한 적이 없습니다. 그래도 문자열 name 칸을 갖고 있어서 User 가 필요한 곳에 들어갑니다. 모양으로 맞추는 이 방식이 구조적 타이핑입니다. 타입을 미리 엮어 두지 않아도 되므로 서로 모르는 코드끼리 값을 주고받기 쉽습니다.
말없이 바뀌는 타입 — 강한 타입과 약한 타입
타입이 맞지 않을 때 언어가 값의 타입을 몰래 바꿔서 계산을 이어 가기도 합니다. 이것이 암묵적 형 변환입니다. 앞 절의 형 변환은 개발자가 코드에 직접 적었지만, 이 변환은 아무것도 적지 않아도 언어가 알아서 합니다. JavaScript 가 이 변환을 많이 합니다.
"1" + 1 // "11"
"3" * 2 // 6
첫 줄에서는 숫자 1 을 글자로 바꿔 이어 붙였습니다. 둘째 줄에서는 거꾸로 글자 "3" 을 숫자로 바꿔 곱했습니다. 오류 없이 값이 나와서, 틀린 값이 어디서 생겼는지 뒤늦게 찾게 됩니다.
Python 은 같은 계산을 오류로 멈춥니다.
"1" + 1 # TypeError
몰래 바꾸는 일이 적은 쪽을 강한 타입, 많은 쪽을 약한 타입이라고 흔히 부릅니다. 두 말에는 합의된 정의가 없어서 어느 언어가 강한지를 두고 말이 갈립니다. 이 축은 검사 시점과 따로 갑니다. Python 은 동적 타입이지만 몰래 바꾸는 일이 적습니다.
나중에 얹는 타입 — 점진적 타입
타입을 적지 않는 언어에 타입을 나중에 얹는 방식도 있습니다. 코드 일부에만 타입을 적습니다. 검사도 적힌 부분만 합니다. 이 방식이 점진적 타입입니다.
Python 에서는 변수와 함수에 타입 힌트를 적을 수 있습니다.
def double(x: int) -> int:
return x * 2
double("ab") # "abab"
x 에 int 라고 적었지만 Python 은 실행할 때 이 표시를 보지 않습니다. 그래서 문자열을 넣어도 "abab" 가 나옵니다. 이 표시를 읽고 오류를 알려 주는 것은 mypy 같은 별도 검사 도구입니다.
TypeScript 도 같은 방식으로 돕니다. 검사가 끝나면 타입 표시를 지운 JavaScript 코드가 만들어집니다. 실행되는 것은 그 코드입니다.
이미 크게 자란 코드에 타입을 조금씩 들일 수 있다는 것이 이 방식의 쓸모입니다. 대가로 타입이 안 적힌 부분은 검사에서 빠집니다.
타입을 더 자세히 적는 장치
목록 타입이 「무언가의 목록」뿐이면 꺼낸 값이 무엇인지 컴파일러가 모릅니다. 제네릭은 타입에 다른 타입을 매개변수로 넘겨서 「문자열의 목록」처럼 적게 해 줍니다.
List<String> names = new ArrayList<>();
names.add(1); // 컴파일 오류
names 에는 문자열만 들어간다는 것을 컴파일러가 압니다. 그래서 정수를 넣는 줄이 걸립니다. 꺼낸 값도 형 변환 없이 문자열로 씁니다.
Java 에서는 String 변수에 값이 없다는 뜻의 null 을 넣을 수 있습니다. 그 변수로 메서드를 부르면 실행 중에 오류가 납니다. 이 오류를 널 포인터 역참조라고 부릅니다.
Kotlin 은 null 이 들어갈 수 있는 타입을 물음표로 따로 둡니다. String 에는 null 을 못 넣고 String? 에만 넣습니다.
val a: String = null // 컴파일 오류
val b: String? = null
b.length // 컴파일 오류
b?.length // null
b 는 비어 있을 수 있는 타입이라서 곧바로 length 를 부르면 걸립니다. ?. 처럼 비어 있는지 따지는 호출을 써야 통과합니다. 이렇게 null 을 타입으로 가르는 성질을 널 안전성이라고 부릅니다.
API 스키마의 타입 시스템
타입 시스템은 프로그래밍 언어 안에만 있지 않습니다. 서버와 클라이언트가 주고받는 데이터의 모양을 적는 스키마 언어에도 있습니다. 스키마는 데이터가 어떤 칸으로 이루어지고 각 칸이 무슨 타입인지 적은 설계도입니다.
GraphQL 은 클라이언트가 필요한 칸을 골라 요청하는 API 방식입니다. 서버는 타입으로 적은 스키마를 공개합니다.
type User {
id: ID!
name: String
}
User 타입에는 id 와 name 두 칸이 있습니다. ID! 의 느낌표는 이 칸이 비어 있을 수 없다는 표시입니다. 클라이언트가 스키마에 없는 age 칸을 요청하면 서버는 요청을 실행하기 전에 오류로 돌려보냅니다.
프로토콜 버퍼도 스키마 언어입니다. 프로그램끼리 주고받는 데이터 한 덩어리를 메시지라고 합니다. 프로토콜 버퍼는 메시지의 칸마다 타입을 적습니다.
언어 안의 타입 시스템은 한 프로그램 안의 오류를 막습니다. 스키마의 타입 시스템은 서로 다른 프로그램 사이에 오가는 데이터의 오류를 막습니다.
엄격한 규칙의 대가
규칙이 엄격할수록 잡히는 오류가 늘어납니다. 대신 타입을 적고 맞추는 데 손이 더 갑니다. 앞의 Object 예처럼 멀쩡한 코드를 규칙에 맞게 돌려 쓰는 일도 생깁니다.
얻는 것은 코드를 고칠 때 돌아옵니다. 메서드 이름이나 칸의 타입을 바꾸면 컴파일러나 mypy 같은 검사 도구가 고쳐야 할 줄을 모두 짚어 줍니다. 동작은 두고 코드 구조만 고치는 리팩터링이 그래서 수월해집니다. 편집기가 부를 수 있는 메서드를 자동 완성으로 보여 주는 것도 타입 정보 덕분입니다.
그래서 오래 여러 사람이 고치는 코드일수록 얻는 쪽이 커집니다. 한 번 돌리고 버릴 짧은 스크립트에서는 적는 비용만 남습니다.
관련 항목
타입 시스템을 이루는 구성 요소
데이터 타입 · 기본 타입 · 참조 타입 · 제네릭 · 열거형 · 유니온 타입 · 인터페이스 · 서브타입
타입 시스템을 가르는 성질
정적 타입 · 동적 타입 · 강한 타입 · 약한 타입 · 명목적 타이핑 · 구조적 타이핑 · 점진적 타입 · 덕 타이핑
타입 시스템이 지키는 성질
타입 안전성 · 널 안전성 · 메모리 안전성 · 건전성
타입 규칙을 적용하는 도구와 과정
타입 검사 · 타입 추론 · 타입 힌트 · 컴파일러 · 인터프리터 · mypy · 정적 분석 · 린터
타입 규칙으로 막으려는 오류
타입 오류 · 널 포인터 역참조 · 암묵적 형 변환 · 형 변환 · ClassCastException
타입 시스템을 갖춘 언어
Java · Kotlin · Python · JavaScript · TypeScript · Haskell · Rust · C++
데이터 모양을 타입으로 적는 스키마 언어
GraphQL · 프로토콜 버퍼 · JSON Schema · 스키마 · API 설계
타입 시스템이 속하는 상위 분류와 이론
프로그래밍 언어 · 타입 이론 · 람다 대수 · 의미론 · 형식 문법
다른 이름: type system · type systems · 타입 체계 · 형 체계 · 자료형 체계