TypeScript에 대한 저의 관점과 생각을 정리하고 공유하기 위해 이 글을 작성하게 되었어요. 그리고 대 카테고리로 Safety Type Zone이라는 이름을 짓게 되었고 이 카테고리 안에서 TypeScript를 개념적으로, 논리적으로 잘 쓰는 방법에 대해서 설명해 보려고 해요. 그럼 그 첫번째로 우리가 코드를 작성하면서 흔히 소실되는 값들에 관한 이야기를 해볼게요.
최근에 코드의 복잡도를 줄이는 방법에 대한 고민이 굉장히 많았어요. 복잡한 연산을 어떻게 줄이고, 어떠한 사고로 접근해야 할까? 왜 늘 결국에 나중에 손도 대기 힘든 코드가 되어가는걸까? 싶은 생각들에 빠져있던 중에 검증하지 말고 파싱하라, 3.좋은함수 만들기 - Null을 다루는 방법 이 두 글을 읽고서 제가 했던 고민들에 대한 나름대로의 답을 찾아서 이 내용을 다시 한번 정리하고 공유하기 위해 이 글을 작성하게 되었어요. 물론 이 내용은 복잡한 연산을 줄이는 방법에 대한것을 완벽히 다루진 않지만 그것을 위한 첫번째 단계라고 생각해요.
JavaScript를 다뤄보았다면 null과 undefined는 굉장히 친숙할거에요. 그리고 때때로 마주치는 이 값들에 대해서 머리가 아프기도하고 내가 짠 코드의 복잡도를 올리기도 해요. 그런데 우리는 이것을 원래부터 그런것이라고 그냥 자연스럽게 받아들이고 있지 않았나요?
(저는 그랬거든요!)
그러면 이것을 다루는 방법을 알기 전에 먼저 이 둘의 정체에 대해서 알아보는것부터 시작할게요.
MDN에서의 null에 대한 설명이에요.
null
은 리터럴로서 null
이라 씁니다. null
은 undefined
과 같이 글로벌 객체의 속성에 대한 식별자가 아닙니다. 대신 null
은 식별되지 않은 것을 표현합니다. 즉, 변수가 아무런 객체를 가리키지 않음을 표현합니다. API에서는 null
을 종종 관련된 객체가 존재하지 않을 때 그 객체 대신 사용합니다.
MDN에서의 undefined에 대한 설명이에요.
undefined
는 전역 객체의 속성입니다. 즉, 전역 범위에서의 변수입니다.최신 브라우저에서 undefined
는 설정 불가, 쓰기 불가한 속성입니다. 그렇지 않더라도 덮어쓰는건 피하는게 좋습니다.값을 할당하지 않은 변수는 undefined
자료형입니다. 메서드나 선언도 평가할 변수가 값을 할당받지 않은 경우에 undefined
를 반환
합니다. 함수는 값을 명시적으로 반환하지 않으면 undefined
를 반환합니다.
MDN에서의 null과 undefined의 설명을 요약해보면 null은 명시적으로 없고 undefined는 할당(선언)되지 않아서 없다고 하고있어요. 그런데 직관적으로 바라본다면 null이나 undefined나 둘다 없는 값인데 우리는 왜 이것을 구분지어서 사용할까요? null 은 없다고 한 값이고, undefined는 없을 수도 있는 값이라서요?
아뇨, 사실은 둘다 없을수도 있는 값이라고 가정하고 사용하고 있지 않나요? 즉, 우리는 말장난같은 코드를 짜고있는거죠!
TypeScript가 아닌 JavaScript레벨의 type guard는 말장난을 그럴듯하게 바꿔주는 요소에요. 왜냐면 type guard는 null과 undefiend를 다루는게 아니라 회피하는법이기 때문이에요!
typeof 연산자는 원시타입을 판별할 수 있는 방법중 하나에요. 보통 어떨 때 사용할까요? 아마도 현재 함수나 scope에 대해서 인자에 대한 신뢰성이 부족할 때 사용하는 경우가 많을거에요. 그런데 typeof null
은 'object'
라는 리터럴을 돌려줘요. 뭔가 이상하지 않나요? (behind는 여기에서 확인하세요!(링크))
그래서.. (아래의 내용으로 이어져요)
그래서 이를 회피하기 위해 null과 undefined를 boolean값처럼 판별하는 케이스를 같이 사용하기도 하죠. (실제로 우리가 신뢰하는 MDN에서도 boolean 조건식에 들어가면 false로 판별한다고 하죠!)
그렇다면 없을 수도 있는 값이 false라면 있을 수도 있는 값은 true일까요? 그런데 간단하게 보면 아래 조건은 모두 false에요.
헉..! 우리가 general 하게 생각하는 boolean의 법칙이 깨져버렸어요!
// false 로 비교하기
console.log(null === false); // false
console.log(undefined === false); // false
// true 로 비교하기
console.log(null === true); // false
console.log(undefined === true); // false
그렇다면 이 nullish한 값을 조건문에 넣어서 if (null)
또는 if (undefined)
로 사용하는게(boolean 연산을 하는것이) 정말로 신뢰성이 있는 코드일까요?
굳이 용어로 써서 복잡해 보이지만 이 둘은 우리가 또 흔히 사용했던 ?
연산에 대한 것들이에요. 흔히 사용하지만 사실은 굉장히 위험한 코드에요. 알게 모르게 우리는 코드에 폭탄을 많이 심어두고 있었을지도 몰라요. 그럼 이것이 왜 폭탄인지 설명해볼게요.
JavaScript에서 내장으로 제공하는 nullish한 값을 optional하게 다루는 연산을 활용해서 코드를 한번 작성해봤어요. 그중에 typeof도 한번 끼얹어서 기똥찬 코드를 작성했어요!
// nullish coalescing operator
const iPhone = null;
const CellPhone = iPhone ?? null; // null
// optional chaining
const MyCellPhone = CellPhone?.iPhone?.fifteen; // undefined
typeof MyMyCellPhone !== 'undefined' ? // type of 의 함정. 선언되지 않은 변수를 그대로 쓸수있어요.
console.log(MyCellPhone) :
console.log("i don't have a cell phone (" + MyCellPhone?.galaxy + ")");
// "i don't have a cell phone (undefined)"
뭔가뭔가 이상하지 않나요? 저는 이것을 기똥차게 회피하는 코드라고 부르기로 했어요.
optional의 optional, 그리고 거기에 또 optional하게. 도대체 어디까지가 없을 수도 있는건가요? 물론 여기에 TypeScript를 끼얹는다면 type으로 선언하지 않은 요소들에 대한 것을 컴파일 레벨에서 체크할 수도 있겠죠. 그렇다고 해도 그것이 optional하지 않을까요? 상위 레벨의 optional한 값을 막기 위해서 하위 요소까지 이어지는 부분까지 모두 nullish 연산을 다 넣어서 guard한다면 side-effect를 막아낼 수도 있겠죠. 그렇다면 만약 그 gurad가 뚫렸을 때 어디서부터 잘못되었는지 찾아내는건 어떻게 해야할까요? 처음으로 돌아가서 하나하나 nullish연산을 벗겨내며 찾아가야 하는걸까요? (벌써부터 머리가 아프네요!)
네, 결론적으로 fail-fast가 되지 않는 코드는 복잡도가 높아질 수 밖에 없어요. 잘못된 곳을 찾기 어렵고, 연쇄적으로 발생하는 에러들을 모두 찾아내야해요. 실제 error에서 계속해서 멀어지는거죠.
자, 그러면 이제 null을 회피하지 않고 다루는 방법을 알아보도록 해요.
위의 내용에서 null과 undefined 그리고 type guard에 대해 설명했었는데 그 목적은 이것들의 위험성에 대해서 설명하는 것이었어요. 그래서 이 위험성을 인지하고 올바르게 다뤄서 fail-fast가 가능한 코드를 작성하는것이 목적이에요.
실제로 JavaScript에서 null과 undefined는 구분되어 있지만, 우리가 작성하는 코드에서는 이 둘을 없음 이라는 개념으로 합쳐봐요. 애초에 둘의 본질은 없음이니까 그냥 None 이라고 생각해봐요.
없을 수도~ 대신에 없음! 이라고 확정짓는것이 필요해요. 이를 위해 먼저 null과 undefined를 구분하지 않고 null만을 사용하는거에요. 그리고 그것을 위해서 필요한건 undefined를 다루지 않는것이 첫번째에요. 할당하지 않은 변수와 nullish 연산을 사용하지 않는것이죠.
// fail-fast 해버리기
const iPhone = null;
const MyCellPhone = iPhone.fifteen;
// Error: Cannot read properties of null (reading 'fifteen')
없는것을 사용하려고 했으니 error가 떨어지는것은 올바른 상황이에요. 그럼 이제 null로 선언한 값을 제대로 선언하던지. null checking을 사용해서 이 코드를 올바르게 수정하면 되는거에요!
이제 undefined를 다루지 않음으로서 TypeScript도 더 기분좋게 사용할수 있어요.
type iPhoneType = {
fourteen: {
mini: string;
pro: string;
} | null;
// do not use! "fourteen?:"
fifteen: {
normal: string;
pro: string;
};
}
type GalaxyType = {
zFilp3: {
normal: string;
};
sTwentyTwo: {
normal: string;
ultra: string;
};
}
type CellPhone = {
iPhone: iPhoneType;
galaxy: GalaxyType;
}
const MyCellPhone: CellPhone = {
iPhone: {
fourteen: null,
fifteen: {
normal: 'iPhone 15',
pro: 'iPhone 15 pro',
},
},
galaxy: {
zFilp3: {
normal: 'Galaxy Z-Flip 3',
},
sTwentyTwo: {
normal: 'Galaxy S22',
ultra: 'Galaxy S22 Ultra',
},
},
};
if (MyCellPhone.iPhone.fourteen === null) {
console.log("i don't have a iPhone Fourteen");
}
무엇이 다르냐구요? 이제 optional property를 선언하지 않음으로서 우리는 null checking만 가져가면 돼요. 자연스럽게 optional chaining도 쓸 필요가 없어지죠. type iPhoneType
에서 fourteen
을 union null
로 선언하지 않고 optional property로 선언했다면 우리는 null일수도 undefined일수도 있는지 모두 확인해봐야 할거에요. TypeScript로 Type을 선언했는데도 불구하구요.. 그래서 저는 optional Property를 불행한 코드라고 생각해요.
그럼 여기서 그냥 null로 선언한것 뿐이지 null의 null의 null도 얼마든지 생길 수 있는거 아니에요? 라는 의문이 생길수도 있을거에요. 네, 안타깝게도 그런 상황은 무조건 발생할거에요. 그저 우리는 undefined와의 이별을 선언했을 뿐이죠. 그래서 이 의문에 대한 해답은 다음 부분에서 설명해볼게요.
null의 null의 null은 어떻게 하나요? 그 답은 검증하지 말고 파싱하는것이에요. 우리는 이제 undefined와의 이별을 선언했으니 null에 대한 검증만 하면될거에요. 그런데 이 검증마저도 더 쉽게 풀어낼 수 있다니 놀랍지 않나요?
제가 이 글을 통해서 설명하고싶은 가장 핵심적인 내용이에요. 함수를 작성할 때 optional arguments를 받게 되면 우리는 이 값을 검증하고 싶을거에요. 그런데 만약 초기부터 시작된 값이 optional하다면 이어진 모든 함수를 검증해야하는 함정에 빠지지 않을까요?
그럼 예시로 TypeScript로 Array의 head값을 가져오는 간단한 함수를 작성해볼게요.
const head = <T>([head, _tail]: Array<T>) => head;
console.log(head<number>([])); // undefined
이런.. TypeScript를 사용했기에 argument에 Array가 아닌 값이 들어오면 컴파일 에러가 발생하겠지만 빈 Arrary에는 대응을 할수가 없어요. 그러면 자연스럽게 이것을 보완할 검증 코드를 아래의 코드처럼 추가하게 되지 않을까요?. 그렇게 되면 검증의 검증을 계속해서 진행해야 할거에요.
const head = <T>([head, _tail]: Array<T>) => {
if (head === undefined) throw 'Empty Array';
return head;
}
console.log(head<number>([])); // error "Empty Array"
사실 이것은 일종의 파서로 볼 수 있어요. 파서란 덜 구조화된 입력을 받아서 더 구조화된 출력을 만드는 함수이고, 정의상 부분함수이므로(정의역의 어떤 값은 공역의 어떤 값과도 대응되지 않음), 그리고 모든 파서는 어떻게든 실패한 경우를 나타낼 수 있어야 해요. 변경한 head
함수는 이러한 부분을 만족시켰기에 파서라고 볼수 있어요. 그런데 뭔가 아쉽지 않나요? 이 함수에서 받는 인자가 Array임은 TypeScript를 사용했기에 정의할 수 있었지만 빈 Array임은 정의할 수 없었어요. 만약 여기서 throw대신 null과 같은 값을 돌려준다면 이것은 또 없음을 회피하는 코드가 되어버려요. 그런데 이것을 더 보완하면(조금 더 파서처럼 만든다면) 어떻게 될까요?
type MoreThanOneArray<T> = [T, ...T[]];
const head = <T>([head, _tail]: MoreThanOneArray<T>) => head;
console.log(head<number>([]));
// ^?
// Argument of type '[]' is not assignable to parameter of type 'MoreThanOneArray<number>'.
// Source has 0 element(s) but target requires 1.
console.log(head<string>(['apple'])); // "apple"
Array에 하나 이상의 원소를 가지는 MoreThanOneArray
라는 type을 선언해서 이것을 인자로 만들었을 때 우리는 이 함수가 실행된 결과를 보지 않아도 error를 만날 수 있게돼요. 그리고 이로 인해 fail-fast가 가능해졌기에 우리는 검증하지 않고 파싱하는것만으로도 코드를 더 쉽게 작성할 수 있게 될거에요.
그리고 회피되는 구간이 없기 때문에 안전하게 없음을 다뤄서 null의 null의 null을 파서로 혼내주는거에요.
사용자에게 값을 직접 입력받거나 서버에서 데이터를 전달받거나 하는 경우와 같이 현실세계와 코드가 연결될 때 우리는 예상치 못한 error를 만날수도 있어요. 그리고 이건 위에서 설명했던 파서를 통해 해결할 수 있어요.
사용자에게 값을 직접 입력받거나 서버에서 데이터를 전달받거나 하는 경우에 우리는 런타임 환경에서야 이 코드의 error를 올바르게 마주할 수 있게 되어요. 그럼 이 런타임 환경에서의 파싱을 어떻게 해야할까요?
const iPHONE_VERSION = ['8', 'X', 'XS', '11', '12', 'SE', '13', '14'] as const;
type iPhoneVersion = (typeof iPHONE_VERSION)[number];
type iPhone = {
id: string;
version: iPhoneVersion;
};
const updatePhone = (phone: iPhone) => {
if (typeof phone.id !== 'string' || phone.id.length < 36)
throw new Error("Invalid id");
if (!iPHONE_VERSION.includes(phone.version))
throw new Error("Invalid Version");
console.log("id: " + phone.id);
console.log("version: " + phone.version);
}
아앗.. TypeScript를 사용하지만 외부세계에서 오는 데이터를 검증하기 위해 type guard를 추가해버리고 말았어요. 사실 이는 런타임환경에서 동작하는 언어의 한계이기도 해요. 우리는 이를 최대한 빠르게 증명해서 회피하고 하위로 퍼져나갈때는 유효하지 않음이 전파되지 않도록 파싱해야해요. 그렇지 않으면 하위로 퍼져나갔을 때에도 이 값이 유효한지를 확인하기 위해 nullish연산을 활용하게 될 것이고, fail-fast가 이뤄지지 않게되어요. (코드의 복잡도를 올린다는 이야기에요!)
그런데 방대한 JavaScript의 세상이기 때문에 이를 대신 해결해주는 zod
와 같은 좋은 라이브러리들도 존재해요. zod
로 현실세계의 파싱에 도움을 받는 예제도 한번 작성해볼게요.
import { z } from 'zod';
const iPHONE_VERSION = ['8', 'X', 'XS', '11', '12', 'SE', '13', '14'] as const;
type iPhoneVersion = (typeof iPHONE_VERSION)[number];
const iPhone_TYPE = z.object({
id: z.string().uuid(),
// id: z.string().uuid({ message: "Invalid uuid" }),
version: z.enum(iPHONE_VERSION),
// version: z.enum(iPHONE_VERSION, { message: "Invalid version" }),
});
type iPhone = z.infer<typeof iPhone_TYPE>;
const updatePhone = (phone: iPhone) => {
iPhone_TYPE.parse(phone);
// iPhoneType.safeParse(phone); // { success: true }
console.log("id: " + phone.id);
console.log("version: " + phone.version);
}
property가 많아질수록 zod
와 같은 검증 라이브러리의 도움을 받는게 엄청 큰 도움이 될거에요.
그럼 서버에서의 데이터를 받을 때 어떻게 파싱해야 할까요? 사용자와는 약속하지 않았지만 API서버와는 약속을 했을거에요. 그럼 그 약속안의 도메인 타입을 통해 optional한 값을 파싱해볼 수 있을거에요.
const iPHONE_VERSION = ['8', 'X', 'XS', '11', '12', 'SE', '13', '14'] as const;
type iPhoneVersion = (typeof iPHONE_VERSION)[number];
const iPhoneVersionAlias = {
'8': '8',
'X': 'X',
'XS': 'XS',
'11': '11',
'12': '12',
'SE': 'SE',
'13': '13',
'14': '14',
} as const;
type iPhone = {
id: string;
version: iPhoneVersion;
};
const makeiPhone = (id: string, version: iPhoneVersion): iPhone => ({
id,
version,
});
makeiPhone('1', iPhoneVersionAlias.XS);
이렇게하면 t의 make를 통해 파싱된 값으로만 로직을 재구성할 수 있게돼요. 이것을 활용해서 서버에서 값을 받아오고 그 즉시 make로 값을 증명하는 파싱을 실행한다면 우리는 벌써 서버 데이터의 증명과 로직의 증명을 분리할 수 있게 돼요. 이는 관심사의 분리(Separation of Concerns - 줄여서 SoC)를 이뤄낸거에요. 그리고 fail-fast까지도요.
곁들여서 여기에 한번 ts-belt
로 샘플코드도 작성해볼게요. 논외일수도 있지만 굳이 설명을 추가해본다면 ts-belt
의 타입중에 Option<T>
라는 없음(None)을 다루는 좋은 타입이 있어요. 이를 활용하면 우리는 null, undefined를 Option<T>
라는 타입으로 바라볼 수 있게되어요. 이것은 코드상에서 굉장히 많은 이점을 가져다줘요. 없음을 검증하거나 회피하지 않고 성공/실패의 two-track model을 가져갈 수 있게 함으로서 선형적인 구조를 가질 수 있게 해주는데요. 이 내용은 이 글에서 바로 다루기엔 적절치 않으니 자세한 설명은 추후에 이어서 하도록 할게요!
pipe(
getUserInfoLazyQuery(),
O.map(({ data }) => makeUserInfomation(data)),
O.tap(flow(setGlobalStore)),
O.tapError((e) => console.error(e.message)),
);
여기서 사용한 makeUserInfomation
는 depth depth를 각각의 make를 통해 값을 파싱하는 함수에요. 서버에서 받는 데이터의 depth가 깊어지더라도 즉시 파싱하면 이후의 코드 안정성은 보장될 수 있어요.
이렇게 즉시 파싱을 실행한다면 fail-fast를 통해 어디가 잘못되었는지를 빠르게 찾아낼 수 있겠죠? 그리고 이 파싱을 통해 undefined를 사용하지 않고 null값만을 다룬다면 올바르게 없음을 인지하고 그 값만 바라볼 수 있게 돼요. 그리고 저는 이 일련의 과정을 null을 다루는 기술이라고 설명하고 싶었어요.
결론적으로 얘기하자면 null을 다루는 기술이 파싱하는법과 같은 이야기는 아니에요. 우리는 정적타입이 아닌 동적타입의 세상에서 살고있기 때문에 파싱을 하니 null이 다뤄진게 아니라 더 상위레벨에서 컨트롤하는 없음(None)이라는 개념으로 한번 감싼 값을 파싱을 함으로서 올바르게 null을 다루고 fail-fast가 가능한 코드를 작성할 수 있게 되는것이에요. 조금 어렵게 얘기해본다면 파싱은 논리를 만드는 방법이에요.
지식의 저주란 어떤 개인이 다른 사람들과 의사소통을 할 때 다른 사람도 이해할 수 있는 배경을 가지고 있다고 자신도 모르게 추측하여 발생하는 인식적 편견이다. 라고 해요. 즉 align이 맞춰지지 않은 상태에서의 커뮤니케이션은 서로가 불편할 뿐이에요.
제가 생각하는 null을 다루는 기술을 저 혼자만 생각하고 혼자서 작성한다면 정말 슬픈 커뮤니케이션이 많이 일어나지 않을까요? 그래서 이 내용을 공유하고 이 기술을 같이 사용하고 싶은게 목적이에요.
코드는 논리적이야해요. 추상적이거나 문학적이면 마치 과거의 저처럼 정말 바보같은 코드를 작성할거에요. 예를 들어서 blue는 blue일뿐 blue like purple 같은 코드를 작성하면 안돼요. 이렇게 되는 원인중 하나가 없음을 올바르게 다루지 않고 회피했기 때문이라고 생각해요.
nullish 연산을 통해 없음을 회피한다면 정말 blue like purple 같은 코드를 짜고있을거에요. maybe blue is purple 이런 시구 같은 코드를요. 그리고 이를 도와주는 TypeScript의 타입 추론은 정말 소중해요.
이것을 유사하게 코드로 구현해본다면 아래와 같은데요. 타입 추론이 없는 JavaScript의 세상에서는 이 코드는 컴파일 에러를 보여주지 않아요. 필요없는 코드인데 말이죠. 그런데 TypeScript에서 이 코드를 작성하면 if문에서 바로 컴파일 에러가 발생해요. (This comparison appears to be unintentional because the types '"blue"' and '"purple"' have no overlap.)
const blue = 'blue';
if (blue === 'purple') console.log('why?');
올바르게 null을 다루고 TypeScript를 잘 활용하면서 파싱한다면 위의 시구같은 코드처럼 불필요한 것들이 줄어들고 한결 보기좋은 코드가 될거에요. 만약 그렇지 않고 많은 검증을 필요로 하게 되면 샷건 파싱이라는 현상을 일으키기도 해요.
샷건 파싱이란 파싱과 입력 검증 코드가 뒤섞여서 데이터를 처리하는 코드에 흩뿌려진 프로그래밍 안티패턴으로, 입력 데이터를 주먹구구로 검증하고 체계적인 정당화 없이 이 정도면 "나쁜" 입력이 전부 걸러지겠지 하고 바라는 것을 의미한다.
샷건 파싱은 필연적으로 프로그램이 잘못된 입력을 처리하지 않고 거부하는 능력을 저해한다. 입력 스트림에서 오류를 뒤늦게 발견할 경우 잘못된 입력의 일부분은 이미 처리된 상태로, 프로그램의 상태를 정확하게 예측하기 어려워지는 결과를 낳는다.
파싱에 기반한 접근은 프로그램을 파싱과 실행의 두 단계로 분리하고 잘못된 입력에 의한 오류는 첫 단계에 격리함으로써 문제를 회피합니다. 나머지 실행 단계에서 실패할 경우의 수는 비교적 적고, 필요한 만큼 세심한 관심을 쏟아 처리할 수 있습니다.
정말 필요한 곳에서 바로 회피하고, 나머지 코드는 안전하게 처리하세요. 놀라울 정도로 깔끔해질거에요.
정말 좋은 내용이네요 :) 잘 읽고 가요!