타입스크립트에서의 Array 타입
이 포스트는 Dominik Dorfmeister가 자신의 블로그에 올린 Array types in TypeScript 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.
올해(23년) 초에 있었던 일입니다. Total TypeScript의 저자이자 과거 vercel에서도 근무하던 개발자 Matt Pocock 씨가 트위터를 통해 한 가지 설문 조사를 진행하더군요. 그런데 설문 결과가 저를 꽤 당혹스럽게 했습니다.
해당 설문은 타입스크립트에서 Array 타입을 지정할 때 Array<string> 과 string[] 중 어떤 방식을 선호하는지에 대한 것이었습니다. 전자는 제네릭 구문이고 후자는 Array 구문이죠. 그리고 그 결과는 아래와 같았습니다.
What do you use more often in TS?
— Matt Pocock (@mattpocockuk) July 4, 2022
분명히 해둡시다. 두 방식 사이에 기능적인 차이는 전혀 없습니다. 어떤 방식을 선택하는지는 전적으로 개개인의 취향에 달려있다고 봐야겠죠. 당신이 어떤 방식을 선호하는지와 관계 없이 하나의 방식을 일관적으로 유지하는 것이 더 중요합니다. 그리고 이 경우 eslint의 array-type 룰을 사용하면 도움이 됩니다.
그렇기는 하지만, 저는 트위터에서 string[]에 투표한 78%의 사람들이 완전히 틀렸다고 생각합니다. 일반적으로 저는 이런 식으로 무언가를 단언하거나 하지는 않습니다. 하지만 이 경우라면 제네릭 표기법을 사용하는 경우가 (무조건 더 좋다고는 하기 어렵지만서도) 훨씬 더 낫다고 확신합니다.
누군가가 "나는 string[]과 같은 Array 표기법이 더 좋은뎁쇼?"라고 말할 때마다 저는 그렇지 않다는 사실을 사례를 들어 설명합니다. 제 이야기를 들으면 보통은 제네릭 구문을 사용하는 편이 더 좋다는 사실을 납득하더라고요. 오늘은 제네릭 표기법을 사용하는 편이 더 좋다는 사례를 설명하기에 앞서, 사람들이 Array 표기법을 선호하는 이유 한 가지를 살펴보겠습니다.
더 짧다!
사실 더 짧다는 건 확실히 장점이라 볼 수 있습니다. 키보드 두드리는 횟수가 더 적잖아요. 코드가 짧다면 유지 관리에 이점이 있기도 하고요. 그런데 let은 const보다 짧습니다. 여러분은 오만군데에 let을 쓰시는 분이신가요? 하하, 저도 이 논쟁을 알고 있지만 깊게 파고들지는 않겠습니다.
하지만 진지하게 생각해봅시다. 무언가 짧다고 해서 언제나 더 좋아지는 것은 아닙니다. i는 index보다 짧고 d는 dashboard보다 짧지만, 우리는 코드를 쓰는 것보다 훨씬 더 자주 읽는다는 것을 기억해야 합니다. 코드를 짧게 쓰는 것에 집중하기보다는 쉽게 읽히도록 하는 것에 집중하는 것이 더 낫습니다. 그리고 이로부터 제네릭 표기법의 장점의 첫 번째 장점이 드러납니다.
가독성
우리는 보통 왼쪽에서 오른쪽으로 글을 읽습니다. 따라서 중요한 것을 앞 쪽에 배치하는 것은 어쩌면 당연하다고도 할 수 있겠습니다. 우리는 "array of strings"나 "array of numbers"와 같은 방식으로 말하니까요.
* 한국 사람은 문자 배열이나 숫자 배열과 같은 방식으로 말하니까 string[]이 더 가독성 좋은 게 아닐까?
// ✅ reads nice from left to right
function add(items: Array<string>, newItem: string)
// ❌ looks very similar to just "string"
function add(items: string[], newItem: string)
이는 배열의 유형이 다소 긴 (가령 타입이 어딘가에서 추론된) 경우에 특히 중요합니다. IDE는 일반적으로 Array 표기법으로 배열 유형을 표시하므로, 때로는 객체 배열 위로 마우스를 가져가면 다음과 같은 결과가 나타납니다.
const options: {
[key: string]: unknown
}[]
저에게는 이게 options 객체처럼 읽혀집니다. 맨 끝의 []를 확인해야 비로소 이게 배열이라는 걸 알 수 있으며, 슬쩍 보고 넘어가면 큰 실수를 할 수도 있게 됩니다. 객체에 속성이 많을 수록 상황은 더욱 악화됩니다. 콘텐츠가 길어지면 팝오버에 스크롤 막대가 생기고, 마지막에 []이 있다는 걸 보는 게 거의 불가능해지기 때문입니다. 물론 이것은 IDE에 따라 다를 수 있습니다. 하지만 처음부터 Array라는 것을 보여주면 생기지 않을 문제이기도 합니다.
const options: Array<{
[key: string]: unknown
}>
이렇게 되면 여러 줄에 걸쳐 있어도 Array 타입임을 쉽게 알 수 있습니다. 어쨌든, 이것이 제네릭 표기법의 유일한 장점은 아니기 때문에 넘어가겠습니다.
Readonly Arrays
현실을 직시해봅시다. 우리가 함수의 아규먼트로 받는 대부분의 배열은 실수로 변경하지 않도록 readonly 처리를 해주어야 합니다. 이미 별도의 포스트에서 해당 주제에 대해 다루고 있으므로 관심 있으신 분들은 한 번씩 읽어보시기 바랍니다. 아무튼 제네릭 표기법을 사용하는 경우 Array를 ReadonlyArray로 일괄 변경해주는 것만으로도 끝날 문제입니다. 하지만 Array 표기법을 사용하면 두 파트로 나누어 작성해야 하죠.
// ✅ prefer readonly so that you don't accidentally mutate items
function add(items: ReadonlyArray<string>, newItem: string) {}
// ❌ "readonly" and "Array" are now separated
function add(items: readonly string[], newItem: string) {}
물론 이렇게 나누어 작성하는 게 대단히 어렵거나 하다는 건 아닙니다. 다만, 같은 일을 하는 built-in utility 타입이 존재하기 때문에 readonly를 배열과 튜플에서만 작동하는 예약어로써 받아들이가 쉽지 않습니다. 특히 readonly와 []가 나누어져있기 때문에 읽을때 흐름이 끊긴다니까요?
물론 이것도 그렇게까지 대단한 문제는 아닙니다. 이제부터 진짜 짜증나는 문제들을 살펴보겠습니다.
Union types
앞서 살펴본 add 함수가 숫자도 허용하도록 하려면 어떻게 해야할까요? items가 숫자 배열을 받게 해야하는데, 만약 제네릭 표기법을 사용하고 있었다면 문제가 없습니다.
// ✅ works exactly the same as before
function add(items: Array<string | number>, newItem: string | number) {}
그러나 Array 표기법을 사용하면 상황이 조금 달라집니다.
// ❌ looks okay, but isn't
function add(items: string | number[], newItem: string | number) {}
오류를 즉시 발견할 수 없다면 그것은 그것대로 문제가 될 수 있습니다. 그리고 이 경우 오류가 너무 숨겨져있어서 알아차리는데 시간이 좀 걸립니다. 해당 함수를 실제로 구현하여 어떤 오류가 나타나고, 왜 이것이 문제인지 살펴보겠습니다.
// ❌ why doesn't this work 😭
function add(items: string | number[], newItem: string | number) {
return items.concat(newItem)
}
위 함수는 'string' 형식은 'ConcatArray<number> & string' 형식에 할당할 수 없습니다.ts(2769) 라는 오류가 발생합니다. 문제를 해결하기 위해서는 연산자 우선순위에 대해 알아야 합니다. [] 연산자가 유니온 연산자보다 강력하므로, 우리는 사실 items가 string과 number[] 타입을 받도록 한 셈입니다.
(string | number)[]처럼 작성하면 의도한대로 동작하게 됩니다. 제네릭 표기법을 사용하면 꺽쇠 괄호를 사용해 이를 구분하기 때문에 문제가 되지 않습니다.
아직도 제네릭 표기법이 더 좋다고 확신을 못하겠다고요? 좋습니다. 마지막 사례를 확인해봅시다.
keyof
자바스크립트 개발자들이 자주 사용하는 pick 또는 omit 같은 함수를 구현하려면 객체와 이 객체의 가능한 key 배열을 파라미터로 함수에 전달해야 합니다.
const myObject = {
foo: true,
bar: 1,
baz: 'hello world',
}
pick(myObject, ['foo', 'bar'])
만약 두 번째 파라미터가 가능한 key만을 받기를 원한다면 keyof 연산자를 사용하여 구현할 수 있습니다.
function pick<TObject extends Record<string, unknown>>(
object: TObject,
keys: Array<keyof TObject>
) {}
이를 Array 표현법으로 변경하면 아래와 같을 겁니다.
function pick<TObject extends Record<string, unknown>>(
object: TObject,
keys: keyof TObject[]
) {}
놀랍게도 이렇게 바꾼다고 해서 에러가 발생한다거나 하지는 않습니다. 그러나 에러가 없다는 것은 문제가 됩니다. 왜냐면 여기에는 에러가 있기 때문입니다! 함수를 선언하는 시점에는 확인할 수 없지만, 이를 호출하면 'string[]' 형식의 인수는 'keyof TObject[]' 형식의 매개 변수에 할당될 수 없습니다.ts(2345) 라는 에러가 발생하기 때문입니다.
실제 코드베이스에서 이 오류를 처음 봤을 때, 못해도 5분 정도는 그냥 보고 있기만 했던 거 같습니다. 왜 제 key들이 string이 아니라는 것인지 이해할 수 없었습니다. 왼쪽과 오른쪽을 바꿔보고, 타입을 추출해서 별칭type aliases을 달아주면 좀 더 이해할 수 있는 오류를 얻지 않을까 싶었는데, 실패했습니다. 그러다가 문득 그런 생각이 들었습니다. 또 괄호 문제인가?
네, 또 괄호 문제였습니다. 그리고 저는 좀 슬퍼지더군요. 왜 이딴 것까지 신경을 써야 하는 거지? 오늘날까지도 저는 keyof TObject[]가 무얼 나타내는지 알지 못합니다. 그저 우리가 원하는 방식으로 동작하게 하는 방법이 (keyof TObject)[]라는 것만 확인했습니다.
function pick<TObject extends Record<string, unknown>>(
object: TObject,
keys: (keyof TObject)[]
) {}
고오오오오오맙다, ㅈ같은 표현 방식아.
아무튼 이것들이 Array 표기법을 사용할 때 직면했던 문제들입니다. 안타깝게도 Array 표기법은 eslint 규칙의 기본 설정이고, 아직 많은 사람들이 이를 선호한다는 게 안타깝습니다. 몇몇 IDE와 타입스크립트 플레이그라운드에서 "명백히 제네릭 표기법을 사용했음에도" Array 표기법을 사용한 것처럼 타입을 표시해주는 것도 좀 그렇습니다.
어쩌면 이 포스트가 제네릭 표기법이 더 낫다는 것을 커뮤니티에 납득시키는 데 도움이 될 수도 있겠습니다. 어쩌면 이 포스트를 읽고 많은 분들이 제네릭 표기법을 실제로 사용하게 될 지도 모르지요. 그렇다면, 어쩌면, 정말 어쩌면, IDE와 타입스크립트 플레이그라운드와 같은 도구들이 뒤따를지도요.
블로그의 정보
Ayden's journal
Beard Weard Ayden