타입과 타입 시스템 : 기본 개념
강타입/약타입, 정적/동적 타입 검사, 점진적 타이핑, 형변환, 안전성/완전성 등의 개념에 대해 다룹니다.
들어가며
먼저 첫 글을 읽고 (혹은 읽는 도중에) 창을 꺼버리거나 기억 속에서 지워버리지 않고, 두 번째 글을 찾아주신 독자 여러분께 감사의 마음을 표한다.
❤️🙇❤️
첫 번째 글에서는 타입과 타입 시스템의 중요성과 의의, 그리고 타입을 바라보는데 사용할 수 있는 기본적인 직관에 대해 다루었다. 이번 글에서는 추후 구체적인 주제들에 대해 논의를 전개해나기에 앞서 필요한 기본 개념들을 확립하고자 한다. 다룰 내용은 다음과 같다.
- 강타입과 약타입
- 정적 타입 검사와 동적 타입 검사
- 점진적 타이핑
- 타입 선언
- 형변환
- 안전성과 완전성
독자분들께
평어체로 써놓으니 왜인지 어색하여 이 부분만 경어체로 작성합니다.
저는 이 주제로 학위를 받은 적도 없거니와 가진 경력도 그다지 길지 않습니다. 글을 써나가며 공부를 게을리하지 않겠으나 부족한 부분이 많을 것입니다. 글에서 잘못된 정보를 발견하신다면 부디 메일 등의 수단으로 알려주시길 부탁드립니다. 이어질 글들이 첫 선보임 이후에도 독자분들께서 지적/보충해 주신 내용을 바탕으로 꾸준히 나아지는 것이 저의 바람입니다. 꼭 지적이 아니더라도 연재에 관한 어떤 의견이든 환영합니다. ❤️
또한 저는 이 연재가 타입 시스템에 이미 큰 관심을 두고 있는 일부만이 즐길 수 있는 내용에서 벗어나, 가능한 많은 프로그래머에게 흥미롭게 읽히고 도움이 되길 바랍니다. 그 때문에 이론적 엄밀성을 다소 희생하는 한이 있더라도 저와 같은 보통의 프로그래머에게 더욱 쉽게 다가갈 수 있다면 그 길을 택할 예정입니다. 너그러운 양해 바랍니다. 연재의 기조에 대한 의견도 환영합니다. ❤️
강타입과 약타입
언어의 타입 시스템을 설명하는 글을 읽다 보면 “강타입strong type”, 그리고 “약타입weak type”이라는 용어를 맞닥뜨리게 된다. 종종, 이 용어들은 언어가 올바르지 않은 타입 정보를 가진 프로그램을 실행하는 것을 허용하는지를 나타낸다. 즉, 강타입 언어는 타입 검사를 통과하지 못한 프로그램의 실행 자체를 막지만, 약타입 언어는 런타임에 타입 오류를 만나는 한이 있더라도 실행을 막지 않는다는 것이다.
하지만 이러한 맥락에서의 강타입, 약타입은 정확히 나뉘는 두 개념 이라기보단 스펙트럼으로 이해해야 한다. 이 두 용어에 정확히 일대일로 대응되는 개념에 대한 합의는 광범위하게 이루어지지 않았다. 다시 말해, 누군가 어떤 언어가 강타입이다, 약타입이다 라고 말할 때의 기준은 자의적인 경우가 많다. 심지어는 본인의 선호에 따라 언어의 급을 나누기 위한 용도로 사용되는 경우도 왕왕 있다.
본 연재에서는 이 두 용어를 모호하다 간주하고 사용하지 않는다. 또한, 만약 어떤 구분이 필요한 경우, 강타입 또는 약타입이라는 모호한 용어 대신 실제로 표현하고자 하는 특성을 직접 명시한다.
정적 타입 검사와 동적 타입 검사
타입 시스템이 프로그램의 타입을 검사하는 시점이 언젠지에 따라 프로그래밍 언어를 크게 두 분류로 나눌 수 있다. 정적 타입 검사static type checking를 시행하는 언어와 동적 타입 검사dynamic type checking를 시행하는 언어가 바로 그 두 경우다.
정적 타입 검사
정적 타입 검사를 시행하는 프로그래밍 언어는 프로그램의 타입이 올바른지에 대한 검사를 런타임 이전에 시행한다. 때문에 앞서 언급한 예제들에서 드러났듯, 프로그램을 실행해보지 않고도 특정한 종류의 오류(타입 에러type error)를 예방할 수 있다. 다만 타입 검사를 통해 모든 가능한 오류를 제거할 수 있는 것은 아니다. 대표적인 예외가 바로 0으로 나눔(divide-by-zero)이다. 정적 타입 검사를 시행하는 언어를 정적 타입 언어라고 부르기도 하며, 정적 타입 언어의 대표적인 예로 C, C++, Go, Haskell, Java, Kotlin, Rust, Scala 등이 있다.
동적 타입 검사
동적 타입 검사를 시행하는 프로그래밍 언어는 프로그램의 타입이 올바른지에 대한 검사를 런타임에 실행한다. 즉, 프로그램을 실행해보기 전까지는 어떠한 타입 에러도 적발할 수 없고, 만약 타입 에러가 존재한다면 이는 프로그램의 실행 중 발생한다. 동적 타입 검사만을 시행하는 언어를 동적 타입 언어라고 부르기도 하며, 동적 타입 언어의 대표적인 예로 Javascript, Lisp, Lua, Perl, PHP, Python, Ruby 등이 있다.
주의할 점
두 가지 짚고 넘어갈 점이 있다.
첫째로, 정적 타입 검사와 동적 타입 검사는 굉장히 거대한 범위의 개념이다. 예를 들어 C와 Haskell은 모두 컴파일 타임에 타입 검사를 시행하지만, 두 언어의 타입 시스템은 그 외에는 닮은 부분을 찾기 힘들 정도로 다르다. 따라서 “정적 타입 검사를 시행하는 언어가 동적 타입 검사를 시행하는 언어보다 낫다”와 같은 주장은 반례를 맞닥뜨리기 쉬운 취약한 주장이다. 타입 시스템과 관련된 생산적인 주장을 위해서는 구체적인 언어의 구체적인 기능을 이야기하는 것이 좋다.
다음으로, 정적 타입 검사와 동적 타입 검사는 상호 배제적인 개념이 아니다. 정적 타입 검사를 시행하는 많은 언어가 런타임에도 어느 정도의 타입 검사를 시행한다. 런타임에만 존재하는 정보를 통해서만 그 적법성을 판단할 수 있는 경우가 존재하기 때문이다. 타입 B가 타입 A의 서브타입일 때, A 타입의 객체를 B 타입으로 형변환하는 다운캐스팅downcasting이 그런 예이다.
점진적 타이핑
앞서 언급했듯, 최근 들어 기존에 동적 타입 검사만을 수행하던 언어에 정적 타입 검사를 도입하고자 하는 시도가 점점 늘어나고 있다. 하지만 프로젝트가 10k LoC 정도 되는 상황만 생각해봐도, 모든 코드 베이스에 동시에 타입 정보를 전부 적어야만 정적 타이핑을 사용할 수 있다면 현실적으로는 정적 타입 검사를 도입하기가 극히 어렵거나 심지어는 불가능할 것이라 짐작할 수 있다.
이런 상황을 피하기 위해 이런 시도 중 다수가 점진적 타이핑gradual typing을 지원한다. 프로그램 전체가 아닌 프로그래머가 명시한 일부 부분만 정적 타입 검사를 거치게 하고 나머지 부분은 그대로 동적 타입 검사가 이루어지도록 하여, 말 그대로 점진적인 개선을 가능케 하는 것이다. 점진적 타이핑이 가능한 대표적인 동적 타입 언어로 Closure, TypeScript, Hack 등이 있다. 또한, C#의 경우 정적 타입 검사 언어로 시작했으나 후에 dynamic
키워드로 동적 타입 검사를 거칠 부분을 지정하는 것을 허용함으로써 점진적 타이핑을 도입했다.
타입 선언
많은 정적 타입 언어가 프로그래머가 직접 모든 값 또는 변수가 어떤 타입에 속하는지 명시하는 명시적 타입 선언explicit type declaration을 작성할 것을 요구한다. 예를 들어, C에서는 모든 변수명 앞에 해당 변수의 타입을 적어주어야 한다. (int i
) 이런 언어에서는 타입 선언이 수반되지 않는 값 또는 변수를 포함하는 소스 코드를 허용하지 않는다.
한편, Haskell을 비롯한 일부 언어는 프로그래머의 명시적 타입 선언 없이도 이미 타입이 알려진 값, 변수, 메소드 등의 정보를 이용해 코드의 타입 정보를 추론 해낼 수 있다. 이렇듯, 별도의 타입 선언 없이 타입 정보를 규명하는 일을 타입 시스템에게 맡기는 방식을 암시적 타입 선언implicit type declaration이라 부른다. 어떤 상황에서 타입 정보를 얼마나 정확하게 추론해 낼 수 있느냐는 타입 시스템에 따라 극명하게 달라진다.
타입 추론은 굉장히 유용하고 흥미로운 주제인 만큼, 차후 별도의 포스트로 좀 더 깊이 다룬다.
형변환
앞선 글에서 언급했듯, 한 값은 여러 타입에 속할 수 있다. 하지만 이는 가능성의 문제일 뿐, 실제로는 특정 시점에 어떤 값(또는 변수)에는 하나의 타입만이 할당 될 수 있다. 하지만 다양한 필요로 인해 이 값의 타입을 지금 할당된 타입과 다르게 바꾸어야 할 필요가 생길 수 있다. 예를 들어, 정수 타입과 부동소수점 타입을 구분하는 언어에서 두 타입에 속하는 값을 더하는 경우가 그러하다. 이때, 정수 값의 타입을 부동소수점으로 변경하거나 혹은 그 반대의 작업이 선행되어야 한다. 이렇게 값의 타입을 변경하는 작업을 형변환type conversion이라 부른다.
형변환, 타입 캐스팅, 타입 강제
형변환에 관해 이야기할 때 자주 쓰이는 세 용어가 있다.
- 형변환type conversion
- 타입 캐스팅type casting
- 타입 강요type coercion
이 세 용어는 그 의미와 관계가 정확히 합의되지 않았고, 언어와 플랫폼 별로 다르게 사용된다. 본 연재에서는 형변환과 타입 캐스팅을, 그리고 아래에서 언급할 암시적 형변환과 타입 강제를 각각 같은 개념으로 정의하고 사용한다.
형변환은 크게 암시적 형변환과 명시적 형변환으로 나눌 수 있다.
암시적 형변환
암시적 형변환implicit type conversion은 이름이 암시🙃하듯 프로그래머의 명시적 선언 없이 컴파일러 또는 인터프리터를 통해 자동으로 일어난다. 어떤 연산이 허용하지 않는 타입에 대해 이루어질 때, 타입 에러를 내는 대신 컴파일러 혹은 인터프리터가 하나 이상의 피연산자를 ‘말이 되는’ 타입으로 자동으로 변경한 후 작업을 진행하는 것이다.
이 때, ‘말이 되는’ 변환이란 무엇인지에 관한 정의는 언어마다 다르다. 어떤 언어는 정수 값과 부동소수점 값 사이를 포함해 모든 암시적 형변환을 전혀 허용하지 않고, 어떤 언어는 지나치다 싶을 정도로 관용적인 태도로 모든 프로그램을 어떻게든 타입 에러 없이 실행시킨다. 그리고 대부분 언어의 정책은 그 두 극단 사이 어딘가에 위치한다. 독창적인 암시적 형변환으로 악명 높은 언어로 자바스크립트를 꼽을 수 있다.
/* Javascript, the good parts */
console.log(1 + 1) // 1
console.log(1 + 3.1) // 4.1
console.log(1 + 3.14) // 4.140000000000001 (IEEE 754)
/* Javascript, some parts of it... at least */
console.log(1 + []); // '1'
console.log(1 - []); // 1
console.log(1 + {}); // '1[object Object]'
console.log(1 - {}); // NaN
console.log([] + []); // ''
console.log({} + []); // 0
console.log({} + {}); // NaN
명시적 형변환
암시적 형변환과 다르게 프로그래머가 값을 다른 타입으로 해석하겠다는 의도를 명확히 밝힘에 따라 일어나는 형변환을 명시적 형변환explicit type conversion이라 부른다. 명시적 형변환은 언어에 따라 타입 캐스팅 연산자type casting operator, 타입 변환 함수type conversion function 등을 이용해 일어난다.
// C
double a = 3.3;
int b = (int) a;
// Python
a = 3.3;
b = int(a)
// Haskell
a = 3.3
b = round a
안전성과 완전성
‘올바른’ 프로그램과 그렇지 않은 프로그램을 구분하는 타입 시스템의 효용을 평가하기 위한 척도로 논리학에게서 안전성soundness와 완전성completeness이란 두 개념을 빌려올 수 있다. 이때, 프로그래밍에서의 타입 시스템과 타입, 그리고 프로그램은 각각 논리학의 논리 체계와 명제, 그리고 증명에 대응한다.
안전성
어떤 논리 체계에서 증명 가능한 모든 명제는 참일 것이 보장될 때, 이 논리 체계를 안전하다고 한다. 어떤 타입 시스템이 안전하다는 것은 이 타입 시스템의 타입 검사를 통과한 프로그램은 타입 오류를 일으키지 않을 것이 보장된다(no false positive)는 의미로 이해할 수 있다. 즉, 안전한 타입 시스템하에서 타입 검사를 통과한 프로그램은 모두 (타입 시스템이 보장하는 범위 내에서) 올바르다.
완전성
어떤 논리 체계에서 모든 참인 명제는 증명 가능할 것이 보장될 때, 이 논리 체계를 완전하다고 한다. 어떤 타입 시스템이 완전하다는 것은 타입 오류를 일으키지 않을 모든 프로그램이 이 타입 시스템의 타입 검사를 통과할 것이 보장된다(no false negative)는 의미로 이해할 수 있다. 즉, 완전한 타입 시스템하에서 모든 (타입 시스템이 보장하는 범위 내에서) 올바른 프로그램은 타입 검사를 통과한다.
트레이드오프
이 두 성질은 공짜가 아니다. 안전성을 높이다 보면 올바르지만 컴파일을 통과하지 못하는 프로그램이 생기는 –프로그래머가 타입 시스템의 비위를 맞추는– 상황이 발생한다. 또한 완전성을 높이다 보면 올바르지 못한 프로그램이 타입 검사를 통과해 런타임에 오류를 만날 수 있다. 비록 안전성과 완전성, 그리고 결정 가능성decidability에 관한 이론적 한계가 존재하나, 현실의 프로그래밍 언어가 맞닥뜨리는 이와 관련된 대부분의 문제는 이론적 한계라기보다는 구현 상의 문제에 해당한다. 모든 언어는 이 트레이드오프에서 어느 쪽에 무게추를 달 것인지의 디자인 결정을 내리며, 그 결정은 해당 언어를 이용한 개발 경험에 직접적인 영향을 준다.
마치며
이번 글에서는 앞으로 논의를 전개하기 위해 필요한 용어들에 대해 알아보았다. 다음 글에서는 프로그래머에게 제약으로 작용하는 타입을 적극적으로 활용하면서도 유연함을 잃지 않기 위한 하나의 수단인 다형성polymorphism에 대해 다룬다. 다형성이란 무엇이고 왜 필요한지, 어떤 종류의 다형성이 존재하며 각각 어떤 특징을 갖는지, 서로 어떻게 다른지 등을 소개할 예정이다.