-
[TypeScript] 서브타입, 가변성Front-End/TypeScript 2025. 3. 8. 09:32
🍀목차
글의 목적
서브타입
가변성
공변(covariance)
불변(invariance)
반변(contravariance)글의 목적
타입 시스템에서 서브타입과 가변성은 타입 안전성과 코드 유연성의 균형을 이루도록 도와준다.
서브타입에 의한 다형성으로 재사용성과 확장성을, 가변성으로 제네릭 타입 간 서브타입 관계를 정의할 수 있다.
이 글은 «타입으로 견고하게 다형성으로 유연하게» 4장 3 챕터의 내용을 정리하고 추가로 학습한 내용을 담고 있다. 서브타입과 가변성(공변, 불변, 반변)을 이해하는 데 도움이 되었으면 한다.
서브타입
서브타입은 한 타입이 다른 타입의 하위 타입이 되는 관계를 의미한다.
"B는 A다"
가 사실이면 B는 A의 서브타입이다.// A class Person { age: number; // ... } // B class Student extends Person { school: string; // ... }
위 코드를 보자.
Student는 Person인가? 그렇다. Person의 모든 특징을 가지고 있기에 Person이 될 수 있다.
Person은 Student인가? 아니다. Student의 shool 속성을 가지고 있지 않으므로 될 수 없다.
그러므로 Student는 Person의 서브타입이고 Person은 Student의 슈퍼타입이다.
= "Student는 Person이다"가 사실이므로 Student는 Person의 서브타입이다.
학생은 사람이다 (O)
사람은 학생이다 (X)학생이 사람의 서브타입이므로 학생은 사람의 모든 특징을 가질 수 있다.
타입 검사기가 객체 타입의 서브타입 관계를 판단할 때는 크게 두 종류의 규칙이 있다.
이름에 의한 서브타입(nominal subtyping)
과구조에 의한 서브타입(structural subtyping)
이다.전자는 타입이 알려 주는 이름을 바탕으로 서브타입 관계를 파악, 후자는 타입이 알려 주는 구조를 바탕으로 서브타입 관계를 판단한다. TypeScript는 구조에 의한 서브타입을 기반으로 한다.
https://www.typescriptlang.org/docs/handbook/type-compatibility.html 이름에 의한 서브타입은 C#, Java 같은 언어에서 사용된다. 상속을 명시적이고 엄격하게 선언하는 것으로 연결된다. 예를 들어, 클래스 B가 클래스 A를 상속한다면 B는 A의 서브타입이다.
구조에 의한 서브타입은 타입의 실제 구조로 호환성을 결정한다. 위 스크린샷처럼 interface Pet과 class Dog이 별도 키워드로 관계를 맺지 않아도 멤버 name이 동일하므로 구조에 의한 서브타입이 성립된다. 구조에 의한 서브타입은 의도치 않게 타입이 호환될 가능성도 있어 논리적으로는 오류가 생길 수 있으나(User와 Product가 id 만을 가진 인터페이스라면 서로 대체 가능) JavaScript와의 호환성이 좋은 이유기도 하다. 타입 안전성과 유연성 모두를 균형 있게 가지기 때문이다.
위의 class Person과 class Student의 관계도 이해해보자. Student는 Person을 상속받았으며, 이는 Person 클래스에 정의된 모든 필드를 Student도 가지므로 Student는 Person의 서브타입이다.
사실 지금까지의 이야기가 직관적으로 이해할 때는 헷갈릴 수도 있다. 관점에 따라서 벤다이어그램을 그려본다면 아래와 같이 그려지기 때문이다.
집합 관점 집합 관점에서 느껴지는 서브타입 관계는 위처럼 인식된다. 모든 학생은 사람이지만, 모든 사람이 학생은 아니기 때문이다.
필드 관점 그러나, 클래스 안 필드 관점에서 보면 위처럼 인식된다. Person이 할 수 있는 모든 것을 Student도 할 수 있기 때문이다. Student는 Person의 모든 능력(필드)을 가지면서, 추가적인 기능(school)도 가질 수 있다. Student는 Person의 확장된 개념인 것이다.
우리는 서브 타입에 의한 다형성을 통해 기존 타입을 확장하고 재사용할 수 있으며 유연성을 향상시킬 수 있다.
가변성
가변성은 제네릭 타입 사이의 서브타입 관계를 추가로 정의하는 기능이다. 타입 안전성을 유지하면서도 유연성을 제공한다. 서브타입은 타입 간의 관계를 나타낸다. 상속은 Student와 Person처럼 서로 다른 제네릭 타입 사이의 서브타입 관계를 만든다. 반면 가변성은 동일한 제네릭 타입(List)에 다른 타입 인자(A,B)로 얻은 타입들 사이의 서브타입 관계를 만든다.
생각해보자. B가 A의 서브타입일 때, List<B>가 List<A>의 서브타입이 되어도 아무 문제가 없을까?
가변성의 종류인 공변, 불변, 반변을 같이 이해해보며 위의 전제에 대해 생각해보자.
공변(covariance)
그대로 유지되는 성질. B가 A의 서브타입이면,
T<B>
도T<A>
의 서브타입이다.배열은 TypeScript에서 기본적으로 공변성을 가지는 객체이다.
공변성을 가진다는 것은 Student가 Person의 서브타입이라면, Student[]도 Person[]의 서브타입이 된다는 것이다.
그렇기에 아래와 같은 작업이 허용된다.
let people: Person[] = [new Person(14), new Person(16)]; let students: Student[] = [new Student(15,"B 대학")]; // Student[]는 Person[]의 서브타입이다. people = students; // people: [Student: { // "age": 15, // "school": "B 대학" // }]
다른 예시를 들어보자. List1은 오직 들어 있는 요소들을 알려줄 뿐, 요소를 추가하거나 삭제할 수는 없는 리스트이다(읽기 전용).
// List1은 들어있는 요소만 알려줄 뿐이다. class List1<T> { private items: T[] = []; get(idx: number):T {return this.items[idx]}; constructor(items: T[]){ this.items = items; } }
계속해서, List1<Student>는 List1<Person>의 서브타입이어도 될까?
let studentList1:List1<Student> = new List1<Student>([new Student(13, "A 대학")]); let personList1:List1<Person> = studentList1; // List1<Student>를 List1<Person>의 서브타입으로 인식해도 될까? 전혀 문제가 없다. // studentList1에는 여전히 Student들만 있으며, // Student는 Person의 모든 속성을 포함하므로 personList1에서도 타입 안전성은 보장된다.
List1은 읽기 전용 메서드인 get만 존재하기에, 타입 시스템이 안전하다고 판단한다. 그래서 Student가 Person의 서브타입일 때, List1<Student>가 List1<Person>의 서브타입이어도 문제가 없다.
공변 공변성은 이처럼 "읽기 전용"을 생각하면 된다. 서브타입 관계가 유지되어도 타입 시스템에 별다른 위협(삭제되거나 수정되거나 등)이 되지 않기 때문이다.
불변(invariance)
완전히 다른 타입이 되는 성질. B가 A의 서브타입이어도,
T<B>
는T<A>
의 서브타입이 아니며,T<A>
도T<B>
의 서브타입이 아니다.T<B>
와T<A>
는 관련이 없다.List2는 List1의 기능에 추가하여, 요소 추가가 가능한 제네릭 클래스이다.
// List2는 들어있는 요소와 요소 추가도 가능하다. class List2<T> { private items: T[] = []; get(idx: number):T {return this.items[idx]}; add(item: T): void {this.items.push(item)} }
여기서도 Student가 Person의 서브타입일 때, List2<Student>도 List2<Person>의 서브타입이어도 될까?
let studentList2:List2<Student> = new List2<Student>(); studentList2.add(new Student(13,"A 대학")); let personList2:List2<Person> = studentList2; personList2.add(new Person(19)); // List2<Student>가 List2<Person>의 서브타입이어도 될까? // studentList2에는 Student와 Person이 둘 다 있으며 // 이는 타입 안전성을 해치는 일이다. // Student 타입만 허용해야 하는 리스트에 Person 객체가 들어가며 타입 불일치가 발생한 것이다.
personList2는 List2<Person>이기에 타입 검사기는 사람 객체 추가를 허용한다. 그렇게 studentList2는 Student와 Person 이 모두 존재하는 리스트가 되었다. 타입 검사기의 가장 중요한 목표인 타입 안전성이 깨진 것이다. 그렇기에 여기서는 List2<Student>가 List2<Person>의 서브타입일 수가 없다.
List1과 달리 List2는 쓰기 작업이 포함되어 있다. 타입 안전성을 보장하기 위해서는 List1처럼 공변성이 허용되어서는 안 된다. 그렇기에 List2가 불변이도록 강제(
in+out
사용, 후에 설명)하는 것이 좋다.정리해 보면, 어떤 제네릭 타입은 타입 인자의 서브타입 관계를 보존하지만, 어떤 제네릭 타입은 그렇지 않다는 결론이 나온다. 이러한 제네릭 타입과 타입 인자 사이의 관계를 분류할 수가 있는데, 이 분류를 가변성이라고 부른다.
List1과 같이 제네릭 타입이 타입 인자의 서브타입 관계를 보존하는 것이 첫 번째 가변성인
공변(covariance)
이며,List2와 같이 제네릭 타입이 타입 인자의 서브타입 관계를 무시하는 것이 두 번째 가변성인
불변(invariance)
이다.B가 A의 서브타입이더라도 List2<B>와 List2<A> 사이에는 아무 관계가 없다. 서로 다른 타입인 것이다.
List2<Student>와 List2<Person>은 서로 호환되지 않는다.
불변 반변(contravariance)
역전되는 성질. B가 A의 서브타입이면,
T<A>
가T<B>
의 서브타입이 된다.
반변성은 제네릭 타입이 타입 인자와 '반대로 변한다'는 의미를 담는다. 함수 타입의 매개변수 타입이 해당된다. 결과 타입을 C로 고정할 때 B가 A의 서브타입이면B => C
는A => C
의 슈퍼타입이다.반변 정확히 이해가 안가니 코드로 이해해보자.
type CheckPerson = (p: Person) => boolean; type CheckStudent = (s: Student) => boolean; let isPeople:CheckPerson = (p) => p.age > 0; let isAUniversityStudent: CheckStudent = (s) => s.age > 18 && s.school === 'A 대학'; // warn : isPeople은 Student를 처리하지 못함. isPeople = isAUniversityStudent; // 통과. isAUniversityStudent는 People도 처리할 수 있음. isAUniversityStudent = isPeople; // type CheckPerson이 CheckStudent의 서브타입이 되었다. 타입이 역전되었다.
위의 코드에서
(student: Student) => boolean
은(person: Person) => boolean
의 슈퍼타입이 된다.(student: Student) => boolean
이(person: Person) => boolean
보다 더 구체적이기 때문이다. Person은 슈퍼타입이었지만(person: Person) => boolean
은 서브타입이 된 것이다. 이것이 반변이다.제네릭 타입 타입 매개변수 T를 출력에 사용 타입 매개변수 T를 입력에 사용 가변성 List1<T>
O X 공변 List2<T>
O O 불변 Int => T
O X 공변 T => Int
X O 반변 + 정의할 때 가변성 지정하기
타입 검사기가 가변성을 판단하는 방법은 위의 표와는 조금 다르다. 타입 검사기는 개발자가 제네릭 타입을 정의할 때 가변성을 지정하도록 한 뒤 따르거나, 사용할 때 가변성을 지정하도록 한 뒤 따르게 하는 방법으로 서브타입을 판단한다. TypeScript는 정의할 때
in
,out
키워드로 가변성을 지정할 수 있다(4.7+).class List1<out T> { private items: T[] = []; get(idx: number):T {return this.items[idx]}; constructor(items: T[]){ this.items = items; } }
out
: 해당 타입 매개변수가 출력에만 사용된다. 공변 리스트를 정의할 수 있다.
interface Store<in T> { set(value: T): void; } const personStore: Store<Person> = { set: (value) => console.log(value.age), }; const studentStore: Store<Student> = personStore;
in
: 해당 타입 매개변수가 입력에만 사용된다. 반변 리스트를 정의할 수 있다.
class List2<in out T> { private items: T[] = []; get(idx: number):T {return this.items[idx]}; add(item: T): void {this.items.push(item)} }
in+out
: 해당 타입 매개변수가 입력, 출력 모두에 사용된다. 불변 리스트를 정의할 수 있다.
명시적으로 가변성을 지정하는 것은 타입 안전성을 높이고 타입 설계 의도를 더 쉽게 이해할 수 있게 하지만, 제약이 생긴다는 단점이 있다.
공변을 선택하면 서브타입 관계를 추가하는 대신 기능이 빠진 타입이 만들어지고,
불변을 선택하면 기능을 다 갖춘 타입을 만드는 대신 서브타입 관계를 포기한다.
잘 고민하며 사용하자.
참고자료
https://www.typescriptlang.org/docs/handbook/intro.html
"타입으로 견고하게 다형성으로 유연하게: 탄탄한 개발을 위한 씨줄과 날줄, 1쇄, 지은이 홍재민, Copyright 2023 홍재민, (주)도서출판인사이트, 978-89-6626-417-9"
https://product.kyobobook.co.kr/detail/S000210397750
타입으로 견고하게 다형성으로 유연하게 | 홍재민 - 교보문고
타입으로 견고하게 다형성으로 유연하게 | 타입과 다형성 잘 배우고 잘 쓰는 법 최근 몇 년간 프로그래밍 언어 세계를 살펴보면 타입 그리고 타입과 관련된 검사 기능이 강화되는 추세다. 그런
product.kyobobook.co.kr