올리브영은 뷰티 상품을 중심으로 각기 다른 특징을 가진 상품을 고객에게 전달하고 있습니다. 최근엔 W CARE(여성 건강 관리 서비스), 라이브 방송, 매거진 등 전통적인 커머스 기능을 넘는 새로운 도메인도 제공하고 있습니다.
이렇듯 올리브영 프론트엔드에서 다뤄야 하는 값, 자료구조, API 응답은 고도화되고 있으며 프로그램의 복잡도는 올라가고 있습니다.
이렇게 복잡해지는 시스템에서 개발자는 수많은 값과 함수의 타입, 그리고 그 흐름을 모두 기억하기 어렵습니다. 이젠 그 책임을 컴퓨터에 위임을 할 때가 되었습니다. 그렇지않으면 예측하지 못한 에러와 버그, 특히 브라우저 환경에서 런타임 에러로 이어져 개발 생산성을 크게 저하시킬 것입니다.
올리브영 프론트엔드에선 위임을 위해 타입스크립트를 도입하였습니다. 타입스크립트의 타입 시스템은 타입을 이용해 작성된 프로그램이 모순을 일으키진 않는지 확인하는 타입 검사기를 검사원으로 두고 있습니다. 타입 검사기는 값, 자료구조, API 응답 등에 대한 타입 정보를 받고 정적 분석을 진행합니다. 타입 검사기는 에러를 컴파일 단계에서 미리 검출하거나 자동완성 기능으로 코드 작성을 돕습니다. 또한, 정의되지 않은 타입을 추론해주는 등 개발자를 도와줍니다.
이렇듯 타입스크립트에 위임하면서 얻는 유용성이 몹시 커 대중적으로 사용하고 있습니다. 그러나 많은 프론트엔드 개발자들은 타입스크립트를 능숙하게 다루기 어려워합니다. 앞서 말한 유용성도 제대로 누리지 못하는 경우가 많습니다.
인간의 직관에 타입이 늘 맞진 않고 타입스크립트의 기반인 자바스크립트가 태생적으로 타입의 중요성이 낮은 언어인 것도 그 이유가 되겠지요.
이 글에선 타입과 다형성을 이해할 수 있도록, 타입스크립트로 보다 안전하고 효율적인 코드를 작성할 수 있도록 덜 엄밀하지만 간단한 멘탈 모델을 제공하는 것을 목적으로 합니다. 사실 라이브러리를 만드는 것이 아닌 한 엄밀한 수학적 이해가 꼭 필요한 경우는 적을 것입니다.
이해를 돕기 위해 올리브영의 사례로 만든 예제를 통해 설명하도록 하겠습니다.
타입이란?
타입스크립트는 자바스크립트에서 타입을 사용하기 위해 씁니다. 그러면 타입이란 무엇일까요? 아주 간단한 정의만 알고 갑시다.
기능에 따라 분류된 값(value)의 집합
예시를 볼까요?
42
, 3.14
는 숫자입니다. 우리 인간은 이들이 숫자임을 직관적으로 알 수 있습니다. 하지만 컴퓨터는 아닙니다. 이들이 숫자라는 것을 어떻게 알릴 수 있을까요?
컴퓨터에게 이 값의 기능을 보라고 합시다. 이 숫자로 산술 연산(곱셈, 덧셈, 뺄셈, 나눗셈)을 할 수 있습니다. 그리고 toString
과 같은 메서드도 사용할 수 있지요.
const sum1 = 42 + 3.14 // 덧셈 연산
const diff1 = 42 - 3.14 // 뺄셈 연산
...
sum1.toString() // "45.14"
const sum2 = "42" + 3.14 // 산술 연산이 아니라 문자열로 이어붙여짐 "423.14"
값이 숫자의 기능을 갖고 있으니 숫자 타입의 값으로 간주하라는 겁니다. 타입 공간에는 이렇게 만들어진 수많은 타입의 집합으로 구성됩니다.
타입의 종류
타입의 정의를 알았으니 타입의 종류도 알아봅시다. 기본적으로 타입스크립트에는 원시타입(primitive type) 6개가 있습니다
number string boolean undefined null symbol
이러한 원시타입 값은 서로 간 타입이 호환되지 않으며 우리 프로그램의 기초적인 부품 역할을 합니다. 값 간의 연산, 할당, 전달 등으로 더 큰 구조를 만듭니다.
결국 원시타입 값을 함수, 객체, 배열과 같은 자료구조와 함께 사용하는 경우가 많습니다. 함수, 객체, 배열 타입 또한 있습니다.
// 함수
type Sum = (num1: number, num2: number) => number
const sum: Sum = (num1: number, num2: number) => {
return num1 + num2
}
// 객체
type Foo = {
x : string;
y : boolean;
z? : number
}
const foo: Foo = {
x: "olive",
y: true
}
자료구조의 구체적인 타입은 어떻게 알 수 있는 걸까요? 3.14
, 42
와 같은 원시타입 값은 값의 기능만 보고 비교적 쉽게 타입을 알아낼 수 있었습니다.
기본적인 원리는 같습니다. 다만, 자료구조는 여러 값이 결합하여 있는 좀 더 복잡한 구조이므로 이를 분해, 결합하는 과정을 거칩니다.
이는 가장 작은 부품부터 검사, 조립해가며 복잡한 기계를 만드는 것과 유사합니다.
타입 검사의 원리
서두에서 말한 것처럼, 타입 시스템은 복잡한 프로그램에서 타입적 모순이 있는지 검사하기 위해 타입 검사기를 사용합니다.
저는 환원주의자는 아닙니다만, 환원주의적 관점이 타입 검사의 원리를 이해하는 데 도움을 줍니다.
환원주의에선 복잡한 것은 더 단순한 것의 조합입니다. 단순한 것이 모두 올바르다면 그 조합인 더 복잡한 것 또한 올바릅니다.
타입 검사기는 단순한 것부터 시작해서 더 복잡한 것을 검사하는 방식으로 프로그램을 검사합니다. 단순한 것이 타입 적으로 올바르다면, 복잡한 것도 그러할 것입니다.
type Sum = (num1: number, num2: number) => number
const sum: Sum = (num1: number, num2: number) => {
return num1 + num2
}
sum(42 + 20, 3.14) // 1
sum(42 + 20, "3.14") // 2
sum
함수의 1, 2번 호출에 대해 각각 타입 검사를 해봅시다. 먼저 1번 호출입니다.
먼저 함수 호출은 세 부분으로 분해해 볼 수 있습니다. sum
, 42 + 20
, 3.14
입니다.
sum
은 더는 작은 부분으로 분해될 수 없습니다. 그렇다면 sum
을 실행해 봅시다. 각 부분의 타입은 실행했을 때 결과로 나오는 값의 타입입니다.
sum
을 실행하면 두 개의 숫자를 인자로 받는 함수임을 알 수 있습니다.
42 + 20
은 더 작은 부분인 42
, 20
으로 분해할 수 있습니다. 그렇다면 각 부분을 실행해봅시다.
42
를 실행했을 때 결과는 숫자 42
입니다. 그러니 42
의 타입은 숫자입니다. 20을 실행했을 때 결과는 숫자 20
입니다. 그러니 20
의 타입은 숫자입니다.
3.14
는 더는 작은 부분으로 분해될 수 없습니다. 그렇다면 3.14
를 실행해 봅시다. 3.14
를 실행했을 때 결과는 숫자 3.14
입니다. 그러니 3.14
의 타입은 숫자입니다.
함수 호출을 최대한 작은 부분으로 분해해보았습니다. 그리고 각 부분을 실행함으로써 타입을 알 수 있었습니다.
지금까지 위에서 아래 방향으로 계속 분해하기만 했다면(top-down) 이번엔 아래에서 윗 방향으로 다시 결합해보도록 하겠습니다.(bottom-up)
먼저 그래프(graph)의 가장 말단인 숫자 42
, 숫자 20
을 살펴봅시다. 이 두 숫자는 함수 호출에서 덧셈 연산(+)으로 결합하여 있습니다.
덧셈 연산은 숫자끼리 결합하여 숫자를 만들어냅니다. 이전 과정에서 알아낸 것처럼 결합하는 두 부분은 모두 숫자입니다. 결합 결과 42 + 20
은 숫자 62
입니다. 어떤 문제도 발견되지 않습니다.
이젠 함수 sum
, 숫자 62
(42
+ 20
), 숫자 3.14
을 살펴봅시다.
함수 sum
을 호출하는 형태로 나머지 부분을 결합할 때에는 함수가 인자로 요구하는 타입과 인자로 들어올 값의 타입이 일치해야 합니다.
함수 sum
은 모두 숫자 타입인 두 개의 인자를 요구하고 숫자 62
(42 + 20
)과 숫자 3.14
의 타입은 알다시피 숫자이므로 문제없습니다.
이처럼 다시 sum(42 + 20, 3.14)
으로 조립되고 해당 함수 호출은 타입검사를 통과합니다.
2번 호출의 경우도 비슷한 타입검사의 과정을 겪습니다. 다만, 함수 sum
은 모두 숫자 타입인 두 개의 인자를 요구하나 인자로 들어갈 값 중 하나인 "3.14"
의 타입은 문자열이므로 타입검사를 통과하지 못할 것입니다.
이렇듯 타입 검사기는 복잡한 구조를 검사할 때 작은 부분에서 부분으로 확장해가며 검사합니다. 다소 간단한 sum
함수의 호출을 예시로 들었지만, 매우 복잡한 함수 호출 등에서도 마찬가지입니다.
그러니 타입스크립트의 정신없이 긴 에러 문으로 고통받을 때 타입 검사의 원리를 생각하며 차근차근 에러 문을 파악해보는 게 어떨까요?
타입 간 관계, 타입 간 대입 가능성
현재 타입은 타입 공간에서 타입에 해당하는 값들을 품은 독자적인 집합으로 존재합니다.
하지만 각 타입이 갈라파고스의 섬처럼 서로 고립되어있다면 과연 타입이란 어떤 유용성이 있을까요? Foo
타입의 값은 영원히 Bar
타입의 값이 될 수 없다면요? 방금 살펴본 것처럼 타입 검사기는 아주 깐깐해서 타입 간 일치하지 않으면 타입 검사를 통과시켜주지 않습니다.
그러나 올리브영같은 프로그램은 내부의 값을 옮겨가며 작동합니다. 가장 기초적인 값 옮김은 바로 대입입니다. 타입 간 어떠한 관계도 없는 별세계의 다른 존재라면 타입 검사를 통과하지 못해 값의 대입이 불가능하니 타입이란 프로그램을 작성하는데 방해만 될 것입니다.
이제는 타입 검사기에 다형성이라는 개념으로 타입 간 관계, 대입 가능성을 알려줘야 할 때입니다.
타입 간의 관계를 정의해봅시다. 우리는 인간의 직관을 최대한 활용해볼 예정입니다.
A는 B이다. 그러면 A는 B의 서브타입이다.
우리의 직관과 완전히 같습니다. 학생은 사람이다. 그러면 학생은 사람에 속한다. 거슬리지 않죠? 이를 슈퍼타입 - 서브타입 관계라고 합니다. 앞으론 서브타입 관계라고 하겠습니다.
type Person = {
gender : 'M' | 'F';
age: number;
}
type Student = {
gender : 'M' | 'F';
age : number;
school : string;
score : number;
}
서브타입 관계를 이렇게도 말할 수 있습니다.
B 타입의 속성을 모두 가진 A 타입은 B 타입의 서브타입이다.
사실 저는 이 명제가 더 와 닿아서 더 좋아합니다. Student
타입은 Person
타입의 gender
, age
를 갖고 있고 school
, score
를 추가로 가지고 있습니다.
이는 Student
타입이 Person
타입의 더 구체적인 타입임을 나타냅니다. (Student
타입은 Person
타입의 서브타입)
const student: Student = {
gender : 'M',
age: 15,
school: 'Olive Young Middle School',
score : 95
}
const person: Person = student // OK
서브타입 관계가 성립되니, 타입 검사기도 대입을 문제 삼지 않습니다. 값 옮김이 가능해짐으로써 값의 흐름을 구성하는 게 가능해졌습니다.
우리는 직관으로든, 명제에 따라 논리를 쌓든 서브타입 관계를 알 수 있었습니다. 그렇다면 타입 검사기는 서브타입 관계를 어떻게 알 수 있을까요? 우리는 명시적 힌트를 준 적이 없습니다.
코틀린과 같이 명목적 타이핑(nominal typing)을 채택하는 언어에선 서브타입 관계가 성립되려면 명시적 힌트를 요구됩니다.
enum class Gender {
M, F
}
open class Person(
val gender: Gender,
val age: Int
)
class Student(
gender: Gender,
age: Int,
val school: String,
val score: Int
) : Person(gender, age)
Person
타입이 Student
타입에 상속되었음을 명시적으로 나타내어 서브타입 관계를 나타냅니다.
하지만 타입스크립트는 구조적 타이핑(structural typing)을 채택합니다. 타입 간 구조를 공유한다면 서브타입 관계가 성립됩니다. 이 구조적 타이핑은 보통 값의 대입 시 암시적으로 이루어집니다.
타입스크립트는 구조적 타이핑을 채택함으로써 타입의 유연성을 높였습니다만, 구조적 타이핑이 모든 상황에 정답이라곤 할 수 없습니다.
서브타입에 의한 다형성
타입 간 관계를 얘기하며 서브타입 관계에 대해 간단히 얘기해봤습니다.
이전의 타입은 각기 외따로 있던 개념이며 큰 쓸모가 없었으나 서브타입 관계 덕분에 비로소 쓸모가 있어졌습니다. 서로 다른 타입이더라도 서브타입 관계하에 있다면 타입 검사기도 값의 대입을 문제 삼지 않게 되었습니다.
서브타입은 딱딱하고 엄격한 타입에 유연성을 부여하는 다형성(polymorphism)을 구현하기 위한 하나의 수단입니다.
다형성이란 하나의 값이 여러 타입에 속할 수 있는 것을 의미합니다.
그 예시로 Student
타입에만 해당하는 값이 서브타입 관계 덕분에 Person
타입에 해당할 수 있게 되었습니다.
서브타입에 의한 다형성의 필요성을 느끼기 위해 좀 더 실무에서 있을 법한 예시를 더 살펴봅시다.
객체 타입
올리브영은 수많은 상품을 판매합니다.
판매 상품의 간략한 정보(이름, 썸네일 이미지, 가격, 할인율 등)를 확인하고 상품이 마음에 들면 클릭하여 상품 상세 페이지로 이동할 수 있게끔 하는 상품카드가 필요합니다.
우리는 PO(Product Owner)로부터 이러한 상품카드를 만들어달라고 요청받았습니다. 우리는 상품 객체(Product 타입의 객체)를 주입받는 간단한 상품카드 컴포넌트를 만들었습니다.
interface Product {
title: string;
price: number;
discountRate: number;
imageUrl: string;
detailUrl: string;
}
/**
* 상품 카드 컴포넌트
* @description 판매하는 상품의 정보를 보여주는 카드 컴포넌트. 클릭 시 상품 상세 페이지로 이동한다.
*/
export const ProductCard = ({ title, price, discountRate, imageUrl, detailUrl }: Product) => {
const discountPrice = () => price - price * (discountRate / 100);
return (
<div
onClick={() => {
// detailUrl을 사용하여 상품 상세 페이지로 이동하는 로직 ....
}}
>
<img src={imageUrl} alt={title} />
<h3>{title}</h3>
<p>{price.toLocaleString()}원</p>
<p>할인된 가격 {discountPrice.toLocaleString()}원!</p>
</div>
);
};
상품카드 컴포넌트를 배포하고 잘 사용하고 있었습니다. 그러나 곧 올리브영은 로켓보다 빠른 당일 배송인 오늘드림 서비스를 출시하였습니다.
상품카드 컴포넌트는 이제 오늘드림 상품도 취급할 수 있어야 됩니다.
interface TodayProduct {
title: string;
price: number;
discountRate: number;
imageUrl: string;
detailUrl: string;
isTodayDelivery: boolean;
}
const Page = () => {
// 외부로 부터 받아온 오늘드림 상품 데이터
const todayProducts: TodayProduct[] = [
{
title: "탈모용 샴푸",
price: 10000,
discountRate: 10,
imageUrl: "...",
detailUrl: "...",
isTodayDelivery: true,
},
// ... 더 많은 상품들
];
return (
<div>
{todayProducts.map((product, index) => (
// error : TodayProduct 타입의 값은 ProductCard 타입의 값이 아닙니다.
<ProductCard key={index} {...product} />
))}
</div>
);
};
그러나 상품카드 컴포넌트는 TodayProduct
타입의 값을 받아들이지 못합니다. 컴포넌트는 Product 타입의 값을 요구하므로 TodayProduct
타입의 값 전달은 타입 검사를 통과하지 못합니다.
정말 답답한 노릇입니다. 우리로선 당연히 오늘드림 상품도 결국 상품인데 타입 검사를 통과하지 못하는 게 이해가 되지 않습니다.
타입 검사를 통과하기 위해선 어떻게 해야 할까요? TodayProduct
타입의 값도 Product
타입의 값이라고 간주하도록 해야 합니다. 여기서 서브타입
에 의한 다형성이 필요합니다.
서브타입에 의한 다형성은 오늘드림 상품을 TodayProduct
타입에 속하면서 동시에 Product
타입에도 속하게 만듭니다. 이젠 TodayProduct
타입의 값을 컴포넌트에 전달할 수 있습니다.
const Page = () => {
// ...
return (
<div>
{todayProducts.map((product, index) => (
// 사실은 타입스크립트는 서브타입에 의한 다형성을 지원하기 때문에
// ProductCard 컴포넌트에 TodayProduct 타입의 값을 전달할 수 있다.
<ProductCard key={index} {...product} />
))}
</div>
);
};
타입스크립트는 구조적 타이핑을 지원하고 두 타입은 구조를 공유하므로 타입 간 관계를 명시적으로 알리지 않아도 되지만, 타입의 재사용성과 쉬운 관리 같은 장점들을 누리기 위해 extends
키워드를 사용하여 타입을 연계하여 확장합니다.
interface TodayProduct extends Product {
isTodayDelivery: boolean;
}
서브타입에 의한 다형성 덕분에 이제 우리의 상품카드로 수정 없이 오늘드림 상품도 취급할 수 있게 되었습니다.
함수 타입
양질의 상품 리뷰가 많이 쌓이도록 탑 리뷰어 대상으로 이벤트를 진행하기로 하였습니다.
그 결과 우리는 PO로 부터 이벤트 당첨자에게 당첨을 알리는 푸쉬 메시지를 보내는 기능을 만들어 달라고 요청받았습니다. 탑 리뷰어에게 간단한 푸쉬 메시지를 보내는 함수를 만들었습니다.
// 유저
interface User {
id: string;
name: string;
reviewCount: number;
}
// 탑 리뷰어 유저
interface TopReviewer extends User {
rank: number;
}
const sendPushMessages = (needMessage: (user: TopReviewer) => boolean, message: string) => {
users.forEach((user) => {
if (needMessage(user)) {
sendPushMessage(user, message);
}
});
};
함수를 잘 배포하고 이벤트는 성공적으로 끝났습니다. 그러나 지난 이벤트가 호응이 좋아 모든 사용자를 대상으로 하는 새로운 이벤트를 진행하게 되었습니다.
우리는 모든 사용자를 대상으로 하는 새로운 함수를 개발해야 할까요? 논의 끝에 새로운 판별함수를 주입하는 방식으로 기존 함수를 재활용하기로 하였습니다.
const sendPushMessages = (needMessage: (user: TopReviewer) => boolean, message: string) => {
users.forEach((user) => {
if (needMessage(user)) {
sendPushMessage(user, message);
}
});
};
const isWon = (user: User) => wons.includes(user.id);
sendPushMessages(isWon, "이벤트 당첨을 축하드립니다! 경품을 받아가세요!")
여기서 잠깐, 이 코드는 타입검사를 통과할 수 있을까요? 우리의 직관으로 비추어 볼 때, 통과 못 할 이유는 없습니다. 다시 'A는 B이다. 그러면 A는 B의 서브타입이다.' 라는 명제를 떠올려 봅시다.
(user: TopReviewer) => boolean
과 (user: User) => boolean
은 반환 타입은 같으니 매개변수 타입에만 집중하겠습니다.
매개변수 타입이 User인 함수는 적어도 사용자는 인자로 받을 수 있는 함수입니다. 매개변수 타입이 TopReviewer
인 함수는 탑 리뷰어를 인자로 받을 수 있는 함수입니다.
이 두 함수의 서브타입 관계를 보기 위해선 다음과 같은 명제가 참인지 확인하면 됩니다.
'적어도 사용자를 인자로 받을 수 있는 함수(A)는 탑 리뷰어를 인자로 받을 수 있는 함수(B)이다.' 해당 명제는 참입니다.
즉 (user: User) => boolean
타입은 (user: TopReviewer) => boolean
타입의 서브타입임이 확인되었습니다.
그 결과 서브타입에 의한 다형성으로 위 예시는 타입검사를 통과하게 됩니다.
이해가 잘 안 간다면, 타입 안전성을 위해서 어떤 함수가 다른 함수로 대체되더라도 기존 인자를 받는데 문제가 없어야 되기 때문이라고 이해하셔도 좋습니다.
(user: User) => boolean
타입의 함수는 TopReviewer
인 인자를 언제든 받을 수 있으니까요.
좀 더 일반화해서 얘기해보겠습니다.
A가 B의 서브타입일 때 B => C 타입은 A => C 타입의 서브타입이다.
또한,
함수 타입은 매개변수 타입의 서브타입 관계를 뒤집고 결과 타입의 서브타입 관계는 유지한다.
라고 할 수 있을 것입니다. 이러한 정리는 인간의 직관에 쉽게 들어맞지 않을 수 있으나, 익숙해질 필요가 있습니다.
이와 같은 함수 타입의 특징은 이후에 변성(variance)이란 개념에 관해 얘기할 때 다시 살펴보게 될 것입니다.
이번 글에서 타입과 타입시스템이란 무엇이며 타입 검사기는 어떻게 작동하는지 간단히 살펴볼 수 있었습니다. 그리고 타입에 유연성을 제공하여 비로소 쓸모 있게 만드는 다형성에 대해서도 알 수 있었습니다.
서브타입에 의한 다형성은 다형성을 구현하는 방법의 하나입니다. 다음 글에선 우리에게 제네릭 문법(Generics)으로 친숙한 매개변수에 의한 다형성에 대해 알아보도록 하겠습니다.
Reference
- https://blog.hjaem.info/46
- https://d2.naver.com/helloworld/3713986
- https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html
- https://en.wikipedia.org/wiki/Structural_type_system
- https://en.wikipedia.org/wiki/Nominal_type_system
- https://toss.tech/article/typescript-type-compatibility