변수의 타입 정보가 언제, 무엇에 의해 사용되는지 살펴보고, 특히 실행 중에 필요한 타입 정보(런타임 타입 정보)를 정적 타입 언어와 동적 타입 언어의 관점에서 설명한다.
URL: https://mortoray.com/what-is-runtime-type-information/
변수에는 타입을 가진 값이 담깁니다. 그런데 이 정보는 누가, 언제 사용 할까요? 이 글에서는 언어가 타입 정보를 사용하는 방식을 살펴보며, 특히 실행 시간(runtime)에 타입 정보가 필요한 경우를 다룹니다. 타입 처리를 이해하면 언어가 어떻게 동작하는지, 그리고 어떤 한계가 있는지 더 깊이 이해할 수 있습니다.
먼저 정적 타입 언어의 관점에서 시작하겠습니다. 이해하기 더 쉽기 때문입니다. 그 다음 동적 타입 언어가 어떻게 다른지 보여드리겠습니다.
다음 의사코드에서 서로 다른 타입을 가진 변수를 몇 가지 소개합니다. 이 글 전체에서 이 변수들을 계속 참조하겠습니다.
age: Integer = 17
name: String = "Kesara"
user: User = User(id=123, security=AccessType.read, name=name)
이 예에서 age는 정수 타입, name은 문자열 타입, user는 User 타입입니다. 코드를 보면 알 수 있습니다. 번역기(translator)도 코드를 이해하므로 이를 알고 있습니다. 여기서 번역기라는 용어는 이 코드가 실행될 컴파일러 또는 인터프리터, 그리고 추상 머신을 가리키는 의미로 사용하겠습니다.
소스 코드가 처리되는 동안, 실행 전에 알 수 있는 타입 정보는 “정적(static)” 타입 정보라고 부릅니다. 코드를 보기만 해도 타입을 알 수 있습니다. 우리가 정적 타입 언어라고 부르는 것은, 대부분의 타입 정보를 코드로부터 알 수 있는 언어입니다. 일단은 정적 타입 언어를 가정해 보겠습니다.
번역기는 이 정적 정보를 이용해 표현식을 어떻게 처리할지 결정합니다. 예를 들어 age + 5라는 표현식이 주어지면, 번역기는 정수 두 개를 더하는 것임을 압니다. 이를 정적으로 알기 때문에 즉시 어떻게 구현해야 하는지 알 수 있습니다. 정적 타입 정보 덕분에 name + “Suffix”가 덧셈이 아니라 문자열 연결(concatenation)이라는 것도 알 수 있습니다.
다르게 말하면, 번역기는 코드를 실행하지 않고도 어떤 함수를 호출해야 하는지 알 수 있습니다. 예를 들어 컴파일러는 정수 age를 위해 8바이트를 할당하고 그곳에 값을 쓸 수 있습니다. 이후에는 특정 함수 add_integer_8( age, 5 )를 호출할 수 있는데, 이 함수는 age가 가리키는 바이트들이 정수를 나타낸다고 가정합니다. 런타임에 age가 정수라는 사실을 명시적으로 추적할 필요가 없습니다 — 컴파일 시점의 정적 타입 지식만으로 올바른 함수를 호출하기에 충분했기 때문입니다.
컴파일러 관점에서 보려면 타입 변환에 대한 글도 참고하세요.
정적 타입에서는 함수와 라이브러리가 특정 타입에 맞춰 만들어집니다. 코드에서는 덧셈 연산자 하나처럼 보이지만, 실제로는 다양한 함수로 매핑될 수 있습니다. 예를 들어 정수의 크기(비트수)마다 하나씩, 부동소수점 타입마다 하나씩 등입니다. 컴파일 단계에서 이미 어떤 함수를 호출할지 결정됐으므로, 이 함수들은 인자의 타입을 검사할 필요가 없습니다. 컴파일 이후에는 이 타입 정보가 필요하지 않으므로 버릴 수 있습니다 — 컴파일된/실행되는 형태의 코드에는 포함되지 않을 것입니다.
정적 언어에서도 실행하기 전에는 알 수 없는 “동적(dynamic)” 타입 정보가 존재합니다. 이는 타입 계층(type hierarchy)과 유니온 타입(union type)에서 나타납니다.
이를 보여주기 위해 간단한 타입 계층을 만들어 보겠습니다.
abstract class SecurityObject:
security: AccessType
id: Integer
abstract function Identify() : String
class User extends SecurityObject:
name: String
function Identify() : String
return name
class Process extends SecurityObject:
executable: String
function Identify() : String
return executable
이제 다음 함수를 보겠습니다.
function Show( actor: SecurityObject ):
print( actor.Identifty() )
Show 함수는 actor를 인자로 받고, 코드를 읽으면 SecurityObject 타입임을 알 수 있습니다. 이것이 정적 타입 정보입니다. 하지만 actor가 User인지 Process인지는 아직 알 수 없습니다. actor의 실제 타입이 바뀔 수 있으므로 “동적”이라고 말합니다.
이 동적 타입 정보는 런타임 타입 정보의 한 종류입니다. 번역기는 함수를 실행하기 전까지 동적 타입을 알 수 없으므로, 실행 중에 어떤 타입인지 알아야 함을 의미합니다. 다만 여기서 주의해야 합니다. 기술적으로는 전체 타입을 다 알 필요는 없고, 호출해야 할 Identify 함수가 무엇인지만 알면 됩니다. 이는 타입을 완전히 알지 않고도 처리할 수 있습니다.
상속을 처리하는 한 가지 방법이 궁금하다면 가상 함수 테이블(virtual function table)을 찾아보세요.
유니온 타입도 런타임 타입 정보를 필요로 합니다. value: Integer | String처럼 인자를 받는 함수가 있다면, value가 정수인지 문자열인지 정적으로 알 수 없습니다. 동적입니다. 값을 출력하려면 그 정보를 알아야 합니다.
type Sample = Integer | String
function print_sample(sample: Sample):
if sample.is<Integer>():
print( sample.as<Integer>() + 5 )
else:
print( "Prefix " + sample.as<String>() )
print_sample 함수는 값을 출력하기 전에 먼저 정수인지 문자열인지 확인해야 합니다. 이를 가능하게 하려면 Sample 타입 변수는 is<Integer> 같은 표현식이 동작할 수 있도록 추가 정보를 함께 가져야 합니다.
상속과 유니온 타입에서는 값을 사용하려면 그 값의 타입에 대한 런타임 정보가 필요하다는 것을 보았습니다. 이제 런타임 타입 정보를 필요로 하는 다른 흔한 언어 연산도 살펴보겠습니다.
if( actor instanceof User ):
...
객체의 타입에 따라 분기한다면, 번역기는 그 조건을 평가할 수 있을 만큼의 타입 정보를 런타임에 추적해야 합니다.
이는 제한적인 형태의 타입 정보로, 결과로는 true/false만 제공합니다. 많은 언어에서 이 검사는 특정 타입에 대해서만, 그리고 해당 타입이 “가능한 경우”에만 제공됩니다. 위 예에서는 actor가 User의 인스턴스인지 묻는 것이 자연스럽습니다. 가능한 타입 중 하나이기 때문입니다. 반면 Integer인지 묻는 것은 말이 되지 않습니다. 그럴 수가 없기 때문입니다.
이 때문에 컴파일러는 이 타입 정보를 어떻게 표현할지에 대해 큰 재량을 갖습니다. 런타임 타입 정보이긴 하지만, 우리가 기대하는 완전한 “타입”일 필요는 없습니다.
age_type_info = typeof age
print( age_type_info.name ) // 'Integer' 출력
function Debug( actor: SecurityObject ):
actor_type_info = typeof actor
print( actor_type_info.name ) // 'User' 또는 'Process'
위 두 경우 모두 런타임 타입 정보를 사용하고 있습니다 — 타입의 이름을 출력하고 있습니다.
get_type는 단순한 instanceof보다 더 많은 타입 정보가 런타임에 उपलब्ध해야 합니다. 그래도 정보가 제한적일 수 있습니다. 보통은 타입을 비교하거나 디버깅을 위해 이름을 얻는 정도면 충분하기 때문입니다.
위 예는 동적 타입 정보와 런타임 타입 정보의 차이를 보여줍니다. age_type_info의 경우 동적인 것이 없습니다. age의 타입은 정적으로 알려져 있으므로, 번역기는 age_type_info의 정확한 타입도 알고 있습니다. typeof를 연산자 문법으로 쓴 것은 이것이 언어의 내장 기능임을 보여주기 위함입니다. 이것이 컴파일 타임 치환으로 구현되든, 런타임 조회로 구현되든, 그것은 번역기의 선택입니다.
하지만 typeof actor는 어떤 형태로든 동적 해석을 해야 합니다. 컴파일 시점에 actor가 User인지 Process인지 알 방법이 없기 때문입니다.
앞에서 번역기가
age_type_info의 타입을 안다고 언급했습니다. 여기서 혼란스러울 수 있는데,typeof age의 결과도 자체 타입이 필요합니다.typeof부분은 특별하지만, 표현식의 결과는 (사용자 정의 클래스를 만든 것과 마찬가지로) 또 하나의 값일 뿐입니다. 여기서 타입을 표현하는 클래스를 보통Type이라고 부릅니다.Type은 매개변수화(parametric) 클래스라고 보는 것이 이해에 도움이 됩니다. 즉age_type_info의 구체 타입은Type<Integer>입니다.age는 논리적으로Integer이지만, 런타임에 이 지식을 나타내는 데이터 구조는Type<Integer>입니다.
일부 언어는 이름으로 객체의 프로퍼티(메서드 포함)에 접근하는 방법을 제공합니다.
id = get_property( actor, "id" )
여기서 번역기가 actor의 타입을 정적으로 알 수 없다고 가정하면, 런타임에 최소한 이름에서 값으로의 매핑이 필요합니다. 종종 프로퍼티의 타입을 얻는 기능(예: get_property_type)과 함께 제공됩니다.
instanceof와 typeof는 런타임에 몇 비트 정도의 타입 정보만 필요했지만, get_property 같은 것은 훨씬 더 많은 정보를 요구합니다. 이 표현식의 존재는 객체에 대한 전체 타입 정보가 런타임에 알려져야 함을 의미할 수 있습니다. 즉 이름, 메서드, 프로퍼티, 기반 클래스 등 더 많은 정보가 필요합니다.
Java에서는 리플렉션(reflection) API가 이 수준의 런타임 타입 정보로 가는 관문입니다. 이런 확장된 수준의 타입 정보를 종종 “타입 인트로스펙션(type introspection)”이라고 부릅니다.
C++에서 instanceof에 해당하는 연산자는 dynamic_cast입니다 — 이름 자체가 동적 연산임을 직접적으로 말해줍니다. C++에는 static_cast도 있는데, 이는 정적 타입 정보만으로 캐스트가 가능한 경우에만 허용합니다.
C++에서 흥미로운 점은 dynamic_cast가 동적 타입에 대해서만 호출될 수 있다는 것입니다. 가장 흔한 동적 타입은 가상 함수를 가진 클래스이며, 이는 타입 계층이 있음을 의미합니다. 가상 함수가 없는 객체에는 dynamic_cast를 사용할 수 없습니다. 하지만 이는 문제가 되지 않습니다. 동적이지 않은 객체를 동적으로 캐스트할 필요가 없기 때문입니다.
동적 타입 언어에서는 번역기가 변수의 타입에 대한 정적 지식을 갖고 있지 않거나, 매우 제한된 정적 지식만 갖습니다.
동적 타입과 타입 추론을 혼동하지 마세요. 여러 정적 타입 언어에서는 변수의 정적 타입을 추론할 수 있습니다 — 코드에 명시적 타입이 없다고 해서 동적이라는 뜻은 아닙니다.
이제 변수에서 타입 정보를 제거하고, JavaScript나 타입이 없는(untyped) Python 같은 동적 언어를 사용한다고 가정해 봅시다.
age = 17
name = "Kesara"
user = User(id=123, security=AccessType.read, name=name)
분명 번역기는 17이 정수라는 것을 파싱할 때 알고, "Kesara"가 문자열이며 User(…)가 User 객체라는 것도 압니다. 하지만 이 정보를 정적으로 추적하지는 않습니다. 따라서 이 값들을 모두 같은 변수에 대입해도 개의치 않습니다.
age = 17
age = "Kesara"
age = User(...)
그렇다면 번역기가 age + 5라는 표현식을 만나면 어떻게 될까요? 정적 타입의 경우 번역기는 타입으로부터 호출해야 할 함수를 알고 있었습니다. age가 정수임을 알고 정수 덧셈 함수를 호출했습니다. 하지만 동적 타입의 경우, 번역기는 코드를 실행해 보기 전까지 age가 어떤 타입인지 알 수 없습니다.
덧셈 연산자는 피연산자가 두 개이므로, 번역기는 런타임에 두 타입을 모두 확인해야 합니다. 첫 번째 타입만으로 어떤 함수를 호출할지 결정하고 두 번째 타입은 적절한 형태로 강제 변환(coerce)할 수도 있습니다. 예를 들어 age + 5에 대해 다음과 같은 로직을 사용할 수 있습니다.
function dynamic_add( a, b ):
if( a instanceof Integer ):
return integer_add( a, coerce_integer( b ) )
if( a instanceof String ):
return string_concatenate( a, coerce_string( b ) )
raise UnsupportedAddType
이것은 가능한 접근 중 하나입니다. 번역기는 대신 Python 방식처럼 모든 값을 클래스로 취급하고, 연산자를 그 클래스의 특수 함수로 볼 수도 있습니다. 그래서 age + 5가 age.add( 5 )가 됩니다. 하지만 이것도 런타임 정보가 필요합니다. 앞에서 가상 함수는 런타임 타입 정보가 필요하다고 했기 때문입니다. 또한 함수 구현은 여전히 두 번째 값을 올바른 타입으로 강제 변환해야 할 수 있습니다.
동적 언어에서는 사실상 거의 모든 표현식이 동적 연산을 사용합니다. 이는 가상 디스패치(virtual dispatch)일 수도 있고, typeof 연산자일 수도 있으며, get_property 함수일 수도 있습니다. 이는 정적 타입 언어에서 특정 연산만 동적 정보를 필요로 하는 것과 대비됩니다.
번역기는 동적 값이 어떻게 사용될지 알 수 없기 때문에, 런타임에 완전한 타입 정보를 추적해야 합니다. 일반적으로 이는 모든 변수가 실제 값과 그 값의 타입 정보를 가리키는 포인터를 함께 가진다는 뜻입니다. 이 포인터는 실제 포인터이며, 가리키는 타입 정보 또한 실제로 존재합니다. 동적 변수의 원시 메모리를 검사할 수 있다면 이런 정보를 볼 수 있을 것입니다 — 다만 최적화되어 있어 해석하기 어려울 수는 있습니다.
실제로 동적 타입을 지원하는 정적 언어들도 메모리 안에 동일한 종류의 정보를 갖는 경향이 있습니다. 여러 컴파일 단위와 라이브러리를 가로질러 컴파일할 때, 어떤 정보가 정확히 필요할지 알기 어렵기 때문입니다. 다만 C++의 가상 함수 같은 것은 빠르게 유지하기 위해 일반적인 타입 정보와는 별도로 저장됩니다.
get_type 연산이 반환하는 값도, 관련된 값이 동적이지 않더라도 어딘가에 저장돼야 합니다. 이것 또한 런타임 타입 정보입니다.
유니온 타입도 런타임에 저장된 타입을 구분해야 합니다. 동적 언어에서는 모든 값이 동적이므로 유니온 타입에 특별한 처리가 필요 없습니다. 하지만 C++의 variant 클래스 같은 것은 동적이지 않은 타입과 함께 동작해야 합니다. 그래서 variant 객체 자체 안에 “현재 어떤 타입인지”를 나타내는 변수를 저장합니다. 이는 기술적으로 런타임 타입 정보이지만, 보통 사람들이 RTTI라고 말할 때의 의미와는 다를 수 있습니다. 언어가 동적/정적 개념을 섞을수록 경계가 흐려집니다.
요약하면 런타임 타입 정보(runtime type information, RTTI)란, 프로그램이 실행되는 동안 런타임에 접근 가능한 “값의 타입에 대한 데이터”입니다. 이는 동적 타입의 동작에 필요한 데이터뿐 아니라, 타입 자체의 구조에 대한 내성적(introspective) 정보도 포함합니다. 동적 타입 언어의 경우 번역기는 대부분의 표현식에서 이 정보에 크게 의존하지만, 정적 타입 언어에서는 특정 경우에만 이 정보에 의존합니다.