구조적 타이핑은 타입의 이름보다 그 형태(속성과 메서드)를 우선해 호환성을 판단하는 타입 시스템 개념이다. 이 글에서는 명명적(이름 기반) 타이핑과 대비해 구조적 타이핑의 기본을 설명하고, 인터페이스와의 차이, 파이썬의 덕 타이핑과 Protocol, C++ 템플릿과 제약/컨셉, 그리고 구조적 타이핑이 널리 쓰이는 TypeScript의 특징을 살펴본다.
URL: https://mortoray.com/what-is-structural-typing/
구조적 타이핑(structural typing)은 코딩에서 타입의 이름보다 타입의 형태(shape), 즉 그 타입이 가진 속성(properties)과 메서드(methods)를 우선적으로 고려하는 개념입니다. 이는 타입의 이름이 가장 중요하게 취급되는, 더 널리 알려진 명명적(또는 기명적) 타이핑(nominative typing) 과 대비됩니다.
이 글에서는 특정 언어를 전제로 하지 않고 중립적인 관점에서 구조적 타이핑의 기본을 소개하겠습니다. 먼저 인터페이스와 비교해 보되, 인터페이스가 곧 구조적 타입은 아니라는 점을 짚습니다. 그다음 파이썬을 통해 덕 타이핑(duck typing)을 구조적 타이핑과 비교하고, 이것이 파이썬의 새로운 명명적 타입 시스템과 어떻게 연결되는지 살펴봅니다. 또한 C++에서는 제네릭의 추론된 구조적 타이핑을 보여주고, 제약(constraints)이 그 명시적 형태라는 점을 살펴봅니다. 마지막으로, 아마도 가장 흔한 구조적 타이핑 언어인 TypeScript를 간단히 다룹니다.
명명적 타이핑은 Java, C++, C#, 타입이 있는 Python 등에서 볼 수 있는 익숙한 방식입니다. 대부분의 개발자는 명명적 타이핑을 이해하고 있을 겁니다. 그렇지 않다면 JavaScript 같은 동적 타입 언어나, 타입이 없는 Python 같은 언어를 주로 쓰고 있을 가능성이 큽니다. 덕 타이핑 섹션에서 이것이 구조적 타이핑과 어떻게 다른지 간단히 짚겠습니다.
타입이 무엇인지 간단히 생각해 봅시다. 타입은 이름을 가지며, 그 타입에 연관된 속성과 메서드의 집합을 가집니다. 대부분은 클래스에 익숙하고, 클래스를 타입을 바라보는 꽤 괜찮은 일반적 관점으로 삼을 수 있습니다. 예로 사용자(User)를 의사 코드로 표현하면 다음과 같습니다.
textclass User: String name String email
이 타입의 이름은 User이며, 명명적 타입 시스템에서는 이것이 가장 중요한 부분입니다. 구조적 타입 시스템에서 중요한 것은 타입의 구조(=형태)로, 속성과 메서드의 모음—즉 이름을 제외한 모든 것—입니다. 이 예시는 단순함을 위해 메서드를 포함하지 않았고, 또한 구조적 타이핑의 흔한 사용 사례를 보여주기 위한 것입니다.
명명적 타입과 구조적 타입의 차이를 이해하기 위해 함수 호출 사례를 보겠습니다.
textfunction send_login_reminder( User recipient ): ... tom = load_user("tom") send_login_reminder(tom)
컴파일러 또는 인터프리터가 send_login_reminder 줄에 도달하면, 인자의 타입이 함수 선언에 지정된 요구 타입과 일치하는지 확인합니다. 여기서는 tom이 User 타입과 일치하는지 확인합니다. 이 “일치”가 어떻게 이뤄지는지는 타입 시스템에 따라 크게 달라집니다.
명명적 타이핑에서는 인자가 User 타입이어야 합니다. 상속이 있다면 User를 상속하는 어떤 타입도 될 수 있습니다. 하지만 거기까지입니다. 그 외의 것은 어떤 것도 그 타입과 일치할 수 없습니다.
구조적 타이핑에서는 타입의 형태만 검사합니다. User라는 이름인지 여부는 중요하지 않습니다. 예를 들어 익명 객체로도 이 함수를 호출할 수 있습니다.
textsend_reminder({ name = "Yara" email = "yara@mail.com" })
명명적 타이핑에서는 이것이 User 타입이 아니므로 실패합니다. 구조적 타이핑에서는 타입 검사기가 User와 같은 형태로 보이므로 적절히 일치한다고 판단합니다. 객체에 name과 email이 있는데, 이는 User의 속성과 동일하기 때문입니다.
이 관점에서 구조적 타입에서의 타입 이름은 사실상 별칭(alias)에 가깝습니다. send_login_reminder 함수는 의사 코드로 다음처럼 똑같이 쓸 수 있습니다.
textfunction send_login_reminder( recipient as { String name String email })
어디에도 User라는 기호는 없지만, 함수의 의미는 동일합니다.
타입을 더 깊게 다루고 싶다면, 제 글 컴파일러가 타입 변환을 하는 방식을 참고하세요.
명명적 타입에서는 모든 객체가 그것을 식별하는 특정 타입(또는 타입 집합)을 가집니다. User 객체를 만들면 그 객체는 실제로 User 타입이며 그 외의 것은 아닙니다. User가 어떤 베이스 클래스를 상속한다면, 그 타입이기도 할 수 있습니다. 하지만 그게 전부입니다. 코드베이스에 정의된 다른 타입들 중 어느 것도 아닙니다. 이런 방식에서는 객체가 “특정 타입을 가진다”고 말할 수 있고, instance of 같은 검사가 의미가 있습니다. 명명적 타입에는 실제 정체성(identity)이 있습니다.
구조적 타입에서는 객체를 식별하는 엄격한 타입 집합이 존재하지 않습니다. 어떤 객체든, 우리 코드와 모든 라이브러리 전체에서 구조적으로 호환되는 형태를 가진 어떤 타입과도 일치할 수 있습니다. 예를 들어 다음 타입을 선언했다고 합시다.
textclass HasName: String name
String name 필드를 가진 어떤 객체든—어떻게 생성되었는지, 생성 과정에 어떤 타입들이 관여했는지와 무관하게—이제 HasName 타입과도 일치합니다. 이는 객체가 제한된 타입 집합을 갖지 않으며, 코드베이스 내에서 많은 타입과 일치할 수 있음을 의미합니다. 이런 관점에서는 객체가 “특정 타입을 가진다”고 말하기 어렵고, instance of 같은 검사는 무의미해집니다—구조적 타입에는 정체성이 없습니다.
구조적 타입 시스템은 보통 초과 속성(excess properties) 을 신경 쓰지 않는 경향이 있습니다. 예를 들어 아래처럼 Company 타입이 있다고 합시다.
textclass Company: String name String email Address address Region region
이 타입의 객체로도 send_login_reminder를 호출할 수 있습니다.
textcompany = load_company("highland") send_login_reminder(company)
이게 코드상 의미가 있는지는 회사도 로그인할 수 있는지 여부에 달려 있습니다. 이는 구조적 타입 시스템이 잡아내지 못하는 결함일 수도 있고 아닐 수도 있습니다.
반대로 send_catalog 함수가 있다면, 아마 User와 Company 모두에 대해 동작하길 원할 것입니다. 구조적 타이핑에서는 타입을 바꾸지 않아도 잘 동작합니다. 명명적 타이핑에서는 공통의 name과 email을 노출하는 인터페이스나 공유 베이스 클래스를 도입해야 합니다.
함수 인자 매칭을 타입 매칭의 예로 들었지만, 변수에 할당할 때도 같은 규칙이 적용됩니다. 오른쪽 값이 왼쪽의 타입과 일치하는지 검사합니다.
textjane = load_user('jane') User b = jane
두 번째 줄에서 타입 검사기는 할당될 값이 변수의 명시적 선언 타입과 일치하는지 확인합니다. jane은 User 타입과 일치할까요? 앞서와 마찬가지로 구조적 타이핑에서는 User라는 이름은 중요하지 않고, 오로지 형태만 고려합니다. 따라서 아래도 허용됩니다.
textUser b = { name = "Xiomara" email = "x473@mail.com" }
오른쪽 값의 형태가 왼쪽의 형태와 일치하므로 허용됩니다.
C++, Java, C#에서 정의되는 인터페이스, 그리고 Python의 추상 베이스 클래스(abstract base classes)는 구조적 타입과 동일하지 않습니다. 이런 종류의 인터페이스는 여전히 명명적 타입입니다. 어떤 함수가 인터페이스 타입을 인자로 받는다면, 그 인자의 타입은 그 인터페이스를 구현한다고 선언해야 합니다.
앞서 User와 Company에 모두 동작하는 send_catalog 함수를 원했습니다. 구조적 타입에서는 필요한 필드가 있으니 그냥 된다고 했습니다. 하지만 명명적 타이핑에서는 타입들이 서로 관련되어 있음을 명시해야 합니다. 보통 인터페이스로 이를 합니다.
textinterface EmailRecipient: String name String email class User implements EmailRecipient: ... class Company implements EmailRecipient: ... function send_login_reminder( User recipient ) // User만 받음 function send_catalog( EmailRecipeint recipient ) // User와 Company를 받음
명명적 타입에서 send_catalog 함수는 인자의 타입이 EmailRecipient 인터페이스를 구현할 것을 요구합니다. 예를 들어 아래는 유효하지 않습니다.
textclass LooksLikeRecipient: String name String email kenny = new LooksLikeRecipient( name = 'Kenny', email = 'k273@mail.com' ) send_catalog(kenny) // 실패: kenny는 EmailRecipient가 아님
LooksLikeRecipient가 EmailRecipient 인터페이스와 같은 형태를 갖고 있다는 사실은 중요하지 않습니다. 명명적 타입에서는 그게 핵심이 아닙니다. 타입은 EmailRecipient를 구현한다고 명시적으로 선언해야 합니다. 반면 구조적 타입에서는 name과 email 필드가 있기만 하면 충분합니다. 구조적 타이핑 언어에서는 send_catalog( kenny ) 호출이 유효합니다.
Python은 원래 동적 타입 언어였습니다. 즉 인자나 할당이 일치하는지 확인하는 정적 타입 검사가 없었습니다. Python 3.5에서 타입 힌트(type hints)가 도입되었고, 이것이 Python의 정적 타입 시스템의 기반이 되었습니다.
Python의 동적 타입 방식은 덕 타이핑으로 불렸는데, 이는 일종의 암묵적 구조적 타입 시스템이라 할 수 있습니다—다만 몇 가지 복잡함이 있습니다. 반면 도입된 타입 힌트는 명명적 타입 시스템을 추가했습니다.
덕 타이핑은 동적 타이핑의 대표적 특징입니다. 사실상 정적 타입이 없으며, 코드가 유효한지는 런타임에서 실행되는 문장이 값에 대해 의미가 있는지에만 달려 있습니다. 이런 동적 성격에도 불구하고, 함수가 어떤 타입을 받는지(덜 엄격한 방식으로) 이야기할 수는 있습니다.
예를 들어, 익숙한 함수와 대략적인 구현을 보겠습니다.
textdef send_login_reminder( recipient ): message_raw = load_reminder_template message = message_raw.format( recipient.name ) send_email( recipient.email, message )
함수 본문을 보면 recipient 인자가 name과 email 속성을 둘 다 갖기만 하면 동작한다는 것을 알 수 있습니다. 동적이긴 하지만 recipient 인자의 기대 타입을 이야기할 수 있습니다. 타입이 명시적이냐 추론되냐는 타입 시스템의 근본 동작 방식을 바꾸지 않는 경향이 있습니다. 예를 들어 타입 검사기라면 다음을 추론할 수 있습니다.
textinterface _send_login_reminder_argument_0: String name String email def send_login_reminder( _send_remind_email_argument_0 recipient ): ...
이는 구조적 타입 시스템에서 User 타입이 동작하는 방식과 매우 비슷해 보입니다. 차이를 보려면 조건 분기를 도입해야 합니다.
textdef send_login_reminder( recipient ): message_raw = load_reminder_template message = message_raw.format( recipient.name ) if recipient.email.endswith( ".special.com" ): send_special_email( recipient.email, recipient.token, message ) else: send_email( recipient.email, recipient.priority, message )
이제 recipient의 타입은 어떤 모습이어야 할까요?
textinterface _send_login_reminder_argument_0: String name String email possibly Integer token possibly Integer priority
여기서 possibly라는 의사 코드는 optional 필드와 동일하지 않습니다. 코드 경로 중 하나는 token이 확실히 필요하고, 다른 하나는 priority가 확실히 필요하지만 둘 다 필요하진 않기 때문입니다. 이를 구조적 타입으로 표현할 수는 있지만, 아마 예상한 형태는 아닐 수 있습니다. 이해를 위해 이름을 줄였습니다.
textinterface _base: String name String email interface _variant_0 extends _base Integer token interface _variant_1 extends _base Integer priority interface argument_0 = _variant_0 or _variant_1
여기서 or는 유니언 타입(union type)으로, 타입이 둘 중 하나일 수 있다는 뜻입니다. 덕 타이핑 함수의 추론 타입을 정의하는 구조적 타입을 만들 수는 있지만, 각 분기가 비슷한 타입들의 유니언을 계속 도입하면서 결과 타입이 꽤 복잡해질 수 있습니다. 이런 타입은 유용성이 떨어집니다. 그래서 덕 타이핑에서 구조적 타이핑으로 마이그레이션한다면, token과 priority에 대해 간단한 optional 타입을 택하는 편이 보통입니다.
Python은 3.5에서 명명적 타입 시스템을 채택했습니다. 여전히 덕 타이핑을 사용할 수 있지만, 정적으로 타입이 있는 함수와 변수를 만들 수도 있습니다. 덕 타이핑이 구조적 타이핑에 가깝긴 하지만, Python은 대신 명명적 타이핑을 선택했습니다. 여기엔 여러 이유가 있고, 이는 향후 글에서 다루겠습니다.
타입이 있는 Python은 Protocol 형태로 구조적 타입을 유지합니다. Protocol은 클래스를 구조적 타입처럼 동작하게 만드는 특별한 기호입니다. 먼저 명명적 타입 예제를 봅시다.
pythonfrom dataclasses import dataclass @dataclass class User: name: str email: str def send_login_reminder( recipient: User ): ... jack = load_user('jack') send_login_reminder(jack) # OK lilou = { 'name': 'Lilou', 'email': 'lil8493@mail.com', } send_login_reminder(lilou) # 허용되지 않음
함수의 recipient 인자는 명시적인 User 타입이어야 합니다. 튜플도, 같은 필드를 가진 다른 클래스도 안 됩니다. 실제 User 객체여야 합니다. 하지만 앞서 예로 든 EmailRecipient와 비슷한 것을 Protocol로 만들 수 있습니다.
pythonfrom typing import Protocol class EmailRecipient(Protocol): name: str email: str def send_remind_email( recipient: EmailRecipient ): ...
이제 User 클래스가 선언에서 EmailRecipient를 전혀 언급하지 않더라도, 형태가 같으므로 일치합니다. Protocol은 구조적 타입을 만듭니다. PEP 544에서는 이를 “Structural subtyping(구조적 서브타이핑)” 또는 “static duck typing(정적 덕 타이핑)”이라고 부릅니다. 다만 앞서 말했듯, 직접 정의한 구조적 타입은 덕 타이핑에서 추론되는 구조적 타입보다 더 엄격해지는 경향이 있습니다.
C++에는 template 키워드 뒤에 숨어 있는 구조적 타입이 있습니다. 이것이 C++20에서 제약(constraints)과 컨셉(concepts)이 도입되며 명시적으로 드러났습니다.
C++ 템플릿은 호출 방식에 따라 타입이 달라지는 함수를 정의할 수 있게 합니다. 간단한 예로 두 객체의 나이를 비교하는 함수를 생각해봅시다.
cpptemplate<typename ItemT> bool is_younger(ItemT a, ItemtT b) { return a.age < b.age; }
이 함수는 두 인자가 같은 타입이기만 하면 호출할 수 있으며, 그 타입에 age가 정의되어 있고 그 age를 비교할 수 있어야 합니다. age는 정수일 수도 있고 날짜 객체일 수도 있으며, 무엇이든 less-than 연산자(<)를 정의하기만 하면 됩니다. 인자 a와 b는 추론된 타입을 가지며, 의사 코드로는 ItemT를 명시적 구조적 타입으로 바꿔 쓸 수 있습니다.
textinterface LessThanT: bool operator < (LessThanT a, LessThanT b) interface ItemT: LessThanT age
하지만 C++은 이렇게 동작하지 않습니다. 이런 타입을 추론하지 않습니다. 대신 템플릿이 인스턴스화되는 지점에서 사용된 타입을 치환하고, 그것들이 유효한지 확인합니다. 이는 Python이 덕 타입을 추론하지 않고 코드를 실행해 에러가 나는지 보는 방식과도 비슷합니다.
C++ 템플릿의 문제는 템플릿 코드 내부에서 타입 에러가 발생했을 때 에러 메시지가 길고 읽기 어려울 수 있으며, 템플릿 호출 트리 깊은 곳에 숨어 있을 수 있다는 점입니다. Python의 덕 타이핑에서도 비슷하게, 어떤 라이브러리 코드 깊숙한 곳에서 타입 불일치가 발생할 수 있습니다.
수년 동안 C++ 커뮤니티는 컨셉과 제약 형태의 명시적 구조적 타입 아이디어를 논의해 왔습니다. 안타깝게도 저는 도입 이후의 C++을 사용할 기회가 없었기에, 여기서는 레퍼런스 페이지를 참고하는 것으로 대신하겠습니다.
C++의 정적 타입을 다루기 위해, 컨셉 시스템은 Python보다 더 엄격하고 더 유연합니다—물론 전형적인 C++답게 더 많은 문법을 대가로 합니다.
TypeScript는 기본적으로 구조적 타이핑 언어입니다. 그 타입 기본 요소인 type과 interface는 모두 구조적 타입을 정의합니다. 하지만 enum 타입은 명명적 타입을 정의합니다. 이 글에서 든 구조적 타입의 일반적인 예시는 TypeScript에서도 동작하지만, 초과 속성과 관련된 예외가 있습니다.
TypeScript에서 enum은 다릅니다. enum은 사실상 문자열(또는 숫자)이고, 구조적 타이핑이 모든 문자열을 동일하게 취급한다면 enum 타입은 상대적으로 쓸모가 없어집니다. TypeScript는 구조적 타이핑에서 벗어나 enum이 명명적으로 타이핑된다고 규정했습니다. 한 enum 타입은 다른 enum 타입과 일치하지 않습니다. 둘 다 문자열(또는 숫자)이라도, 같은 키와 값들을 공유하더라도 마찬가지입니다.
기본 타입의 구조적 성격은 설명하기가 더 어렵고, 그래서 이 글에서는 클래스 타입에 집중했습니다. 예를 들어 간단한 “정수(integer)” 타입을 생각해 봅시다. 이 타입의 이름과 형태는 무엇일까요? 여기서 “integer”라는 말이 이름과 형태를 동시에 가리켜 혼란을 줄 수 있습니다.
대신 TypeScript의 number 타입을 이용한 별칭을 보겠습니다.
tstype AgeT = number function is_younger(a: AgeT, b: AgeT) { ... }
AgeT는 number의 또 다른 이름입니다. 구조적 타입은 이름을 신경 쓰지 않으므로, 이는 중요하지 않아야 합니다. is_younger 함수는 어떤 숫자든 받아 호출될 수 있고, AgeT 대신 number로 선언해도 됩니다. AgeT는 number와 서로 바꿔 쓸 수 있는 별칭입니다.
어떤 언어는 강한 별칭(strong alias)을 제공하는데, 위 예시에서
number를AgeT에 넘길 수 없고 명시적으로 변환해야 합니다. Python에서는 TypeVar로 이를 자연스럽게 할 수 있습니다.
이 점이 TypeScript에서 enum이 다른 이유입니다. 구조적으로 동일한 두 enum은, 키와 값이 완전히 같아도 서로 구분됩니다. 구조적이 아니라 명명적으로 타이핑됩니다.
tsenum CallResponse { no = 0 yes = 1 } enum OtherResponse { no = 0 yes = 1 } function Accept( r: CallResponse ) { ... } Accept( CallResponse.no ) // 유효 Accept( OtherResponse.no ) // 무효
이를 알면, 약간의 꼼수를 통해 enum을 이용해 TypeScript에서 명명적 타입을 흉내낼 수 있습니다. Python처럼
AgeT에 대해 구분되는 타입을 만들 수도 있지만, 보기 좋진 않습니다.
구조적 타입을 한 언어의 정적 타입 시스템 기반으로 삼으면 몇 가지 문제가 생깁니다. TypeScript에서의 이런 문제는 후속 글에서 다룰 예정이지만, 여기서는 흔한 사례 하나만 보겠습니다: 초과 속성입니다.
앞서 구조적 타입은 필요한 속성이 있는지만 보고, 너무 많은 속성이 있는지는 보지 않는다고 했습니다. 이는 흔한 오류로 이어지기 때문에, TypeScript는 쉽게 감지할 수 있는 곳에서는 초과 속성을 금지합니다. 예를 들어 앞서의 send_login_reminder를 TypeScript로 옮기면 다음과 같습니다.
tstype User = { name: String email: String } function send_login_reminder( recipient: User ) { ... } send_login_reminder({ name: 'Maxine', email: 'max4775@mail.com', token: 123, })
이 send_login_reminder 호출은 유효하지 않습니다. TypeScript가 추가 token 속성을 발견하고, 그것이 사용되지 않을 것임을 알아차리기 때문입니다. TypeScript는 (JavaScript처럼) 명명된 인자(named arguments)가 없어서, 인자 타입을 이용해 “이름 붙은 인자”처럼 넘기는 패턴이 흔합니다. TypeScript의 이 추가 검사는 함수가 아는 인자만 넘기도록 보장해줍니다.
이 검사는 제한적이며, 예전에는 인자를 변수에 할당하면 사라지기도 했습니다. 예를 들어 User 객체를 기대하는 곳에 Company 객체를 넘기는 것이 동작할 수 있었습니다. 초과 속성 검사는 구조적 타이핑 규칙에 대한 좁은 예외입니다. 이후 as const가 도입되어 여러 문장에 걸쳐 이 속성 검사를 유지하려는 시도가 있었습니다.
여러 언어가 구조적 타이핑 기능을 제공하며, 어떤 언어는 기본적으로 구조적 타이핑을 채택합니다. 구조적 타이핑을 지원하는 다른 언어로 Ocaml, Go, Haxe 등이 있는 것으로 알고 있지만, 저는 사용해본 적이 없어 구체적으로 같은 방식으로 동작하는지는 확실히 말하기 어렵습니다.
구조적 타입과 명명적 타입의 핵심 차이는 정체성(이름), 즉 이름이 의미가 있느냐 없느냐입니다. 명명적 타입에서는 이름이 핵심 정의 특성이며, 객체는 그 이름을 명시적으로 사용하고 있을 때(직접 또는 상속을 통해서만)만 타입과 일치할 수 있습니다. 반면 구조적 타입에서는 이름은 무관하며, 객체의 형태(속성과 메서드)만이 관련됩니다.