localStorage에 저장한 객체를 다시 읽을 때 메서드가 사라지는 문제를 TypeScript 타입 조작으로 해결하는 방법을 설명합니다.
localStorage에 객체를 써 본 적이 있다면, 다시 읽어 왔을 때 관련 메서드까지 포함된 완전한 객체가 즉시 반환되지 않는다는 불쾌한 놀라움을 아마 겪어 보셨을 것입니다. 모든 버그가 그렇듯, 다시는 이런 일이 일어나지 않게 하겠다고 다짐했겠지만, 우리는 같은 돌에 몇 번이나 걸려 넘어질까요? 저는 최근 이 버그가 마지막으로 발생한 순간, 이제는 정말 한 번에 끝내야겠다고 결심했습니다. 타입 조작의 길로 함께 떠나 봅시다...
저는 Db 클래스를 가지고 있습니다. Storage 인터페이스와 통신하며 JSON 형식으로 데이터를 저장하는 단순한 컨트롤러입니다:
export class Db {
private db: Storage;
constructor(db: Storage) {
this.db = db;
}
public get<T>(key: string, mapFn: DbMapFn<T>|null = null): T {
const valueStr = this.db.getItem(key);
if (valueStr == null) {
throw new Error(`Could not get value from key "${key}"!`);
}
return JSON.parse(valueStr);
}
public set<T>(key: string, value: T): void {
this.db.setItem(key, JSON.stringify(value));
}
// ...and other goodies!
}
보기에 단순하지만, 처음에는 잘 드러나지 않는 치명적인 결함이 있습니다. 짧은 코드 예제로 재현해 보겠습니다:
class Widget {
constructor(public readonly value: number) { }
public isEven(): boolean {
return this.value % 2 == 0;
}
}
(() => {
const db = new Db(localStorage);
const widget = new Widget(10);
console.log(widget.isEven()); // true, as expected
db.set("widget", widget);
const widget2 = db.get<Widget>("widget");
console.log(widget2.isEven()); // ERROR: widget2.isEven is not a function
})()
이건 컴파일러 버그처럼 보이지만, 그렇지 않습니다! 여기서 문제는 JSON.parse가 any를 반환한다는 점입니다. 그래서 타입 검사기는 그것을 다른 어떤 타입으로든 기꺼이 캐스팅하고, 우리가 스스로 무엇을 하는지 아는 어른이라고 믿어 버립니다. 멍청한 컴파일러 같으니, 저도 저녁으로 뭘 먹을지 모르겠는데요!
이제 Widget 객체 대신, Widget의 프로퍼티는 가지고 있지만 메서드는 하나도 없는 객체가 생겼습니다. widget2.value % 2로 짝수 여부를 수동으로 검사할 수는 있겠지만, 여러분과 저는 그 코드가 리뷰를 통과하지 못하리라는 걸 압니다. 이제 문제는 저장소에서 가져온 값을 Widget 객체로 매핑해야 한다는 것입니다. 하지만 이 과정이 지나치게 번거로운 의식이 되는 것도 원치 않습니다. 우리 코드베이스에는 정기적으로 많은 배교자들이 드나드니까요. 즉, 이런 방식은 곤란합니다:
const widget2Data = db.get<{ value: number }>("widget");
const widget2 = new Widget(widget2Data.value);
console.log(widget2.isEven()); // true, but at what cost?
모든 프로퍼티 선언과 그 타입을 일일이 중복해서 작성하는 일은 금방 지겨워집니다. 첫 번째 해결 접근법을 살펴봅시다.
약간의 의식은 필요합니다. 그건 분명합니다. 하지만 데이터베이스 API가 이 일을 쉽게 만들어 주길 원합니다. 사용자가 모든 객체를 직접 변환하리라고 믿는 대신, get 함수가 매핑 전략을 받을 수 있도록 확장할 수 있습니다:
type DbMapFn<T> = (obj: T) => T;
class Db {
// ...
public get<T>(key: string, mapFn: DbMapFn<T>|null = null): T {
const valueStr = this.db.getItem(key);
if (valueStr == null) {
throw new Error(`Could not get value from key "${key}"!`);
}
const value: T = JSON.parse(valueStr);
return (mapFn != null) ? mapFn(value) : value;
}
}
이렇게 하면 문제가 해결됩니다! 거의 그렇지만, 완전히 맞는 것은 아닙니다. 런타임에서는 동작하지만 타입은 기껏해야 불안정합니다. 그래도 아이디어 자체는 있습니다. mapFn 인자를 받으면, 사용자가 제공한 지침으로 대상 객체를 구성하는 데 그것을 사용할 수 있습니다. Widget에 적용하면 이렇게 됩니다:
const widget2 = db.get<Widget>("widget", obj => new Widget(obj.value));
console.log(widget2.isEven()); // true, but I'm not satisfied yet
여기서 문제는 컴파일러가 obj를 여전히 Widget으로 취급한다는 점입니다. 실제로는 함수가 없는 객체일 뿐인데 말이죠. 그래서 무심한 사용자는 우리가 Widget 객체를 괜히 다시 만드는 것처럼 보인다고 생각하며, 메서드를 호출하려다 프로그램을 터뜨릴 수도 있습니다. 파싱된 타입을 any로 캐스팅하고 사용자가 프로퍼티 이름을 제대로 쓰기를 믿을 수도 있겠지만, 우리는 사용자를 믿고 싶지 않습니다! 그 사용자가 미래의 우리 자신일 수도 있고, 저는 일주일 전에 자기가 뭘 썼는지도 기억 못 하는 그 사람을 믿지 않거든요!
그렇다면 이 딜레마를 어떻게 해결할 수 있을까요? 약속했듯, 타입으로 재미 좀 봅시다!
우리가 원하는 것은 다음과 같습니다. Widget(혹은 다른 어떤 객체든)의 역직렬화 직후 상태를 나타내는 타입입니다. 즉, 메서드는 제거되고 공개 데이터 프로퍼티에는 접근할 수 있는 타입이죠. 사실 이 글을 쓰는 시점에는 타입을 매핑할 때 private 프로퍼티에 접근하는 방법이 없습니다. 그리고 아마 이렇게 유지되는 편이 좋은 생각일 것입니다. 전체 타입 변환을 먼저 보여 드리고, 하나씩 설명하겠습니다:
type NonMethodPropertiesOf<T> = {
[K in keyof T]: T[K] extends Function ? never : K
}[keyof T];
이것은 제네릭 타입 맵입니다. 제네릭 타입을 매핑하죠. 즉, 타입 T를 받아 각 프로퍼티에 변환을 적용하고, 그것들로 새로운 타입(NonMethodPropertiesOf<T>)을 구성합니다. 이 타입 문법에서 가장 눈에 띄는 부분은 제네릭 <T>인데, 이는 이 맵을 어떤 타입에도 적용할 수 있다는 뜻입니다.
다음으로 keyof T가 있습니다. 보셨겠지만 두 번 등장합니다. 이것은 TypeScript 연산자로, 제네릭 T의 키들, 즉 프로퍼티 이름들로부터 유니온 타입을 만들어 냅니다. 예시로 이 단순한 클래스를 보겠습니다:
class XYZTuple {
constructor(
public x: number,
public y: string,
public z: boolean) { }
}
여기서 keyof XYZTuple은 "x"|"y"|"z"입니다. 객체를 이 세 문자열로 인덱싱할 수 있기 때문이죠. 그리고 우리는 keyof T를 맵의 본문에 전달해, 어떤 키들을 순회할지 지정합니다.
이제 이것을 바탕으로 타입 맵의 본문을 읽어 보면, T의 각 프로퍼티 K에 대해 그 타입이 함수류가 아닐 때만 유지한다 는 뜻이 됩니다. 첫 번째 [K in keyof T]는 우리가 keyof T의 일부라고 기대하는, 제네릭하게 K라고 이름 붙인 어떤 키들을 검사하고 있다는 사실을 명시합니다. 따라서 T[K]는 T 안의 멤버 K의 타입입니다. XYZTuple의 경우, 맵이 x를 볼 때 T[K]는 number, y를 볼 때는 string, z를 볼 때는 boolean이 됩니다.
타입을 변환하기 위해, 그것이 extends Function인지 묻습니다. 즉 함수류 타입인지 확인하는 것입니다. 만약 그렇다면 제거하고 싶으므로, 인스턴스화할 수 없는 타입인 never로 바꿉니다. 그렇지 않으면 그대로 유지합니다.
이걸로 충분해 보이지만, 이 맵은 단지 T에서 함수가 아닌 프로퍼티를 나열할 뿐입니다. 새로운 타입을 만들지는 않죠. 그러려면 이 변환을 적용해야 합니다:
type WithoutMethods<T> = Pick<T, NonMethodPropertiesOf<T>>;
Pick<T,U> 타입은 T의 부분집합으로부터, 유니온 타입 U에 나열된 프로퍼티만 포함하는 새 타입을 만듭니다. 마침 NonMethodPropertiesOf<T>가 반환하는 것이 정확히 유니온 타입입니다. 함수류가 아닌 모든 프로퍼티를 나열해 준 다음, Pick을 사용해 그 프로퍼티들만 담긴 새 타입을 만들어 변환을 적용하는 것이죠.
하지만 여기에는 작은 문제가 있습니다. T가 배열이라면 어떨까요? 배열이 담고 있는 타입이 아니라, 배열 자체에서 메서드 프로퍼티를 제거하게 됩니다. 분명 우리가 원하는 바는 아니죠. 그래서 변환기를 이렇게 업데이트해야 합니다:
type WithoutMethods<T> = T extends any[]
? Pick<T[0], NonMethodPropertiesOf<T[0]>>[]
: Pick<T, NonMethodPropertiesOf<T>>;
바로 이겁니다! 이제 T가 배열, 즉 extends any[]라면 메서드가 제거된 타입들의 배열로 매핑됩니다. 여기서 T[0]은 배열의 첫 번째 요소 타입이 될 것을 가져옵니다. 실제 요소에 접근하는 것이 아니라, 표현식 T[0]의 타입, 즉 T의 기반 타입이 무엇인지 묻고 있다는 점을 기억하세요.
이제 드디어 이 새로운 타입을 사용하도록 데이터베이스 드라이버 클래스를 업데이트할 수 있습니다!
type DbMapFn<T> = (obj: WithoutMethods<T>) => T;
class Db {
// ...
public get<T>(key: string, mapFn: DbMapFn<T>|null = null): T {
const valueStr = this.db.getItem(key);
if (valueStr == null) {
throw new Error(`Could not get value from key "${key}"!`);
}
const value: WithoutMethods<T> = JSON.parse(valueStr);
return (mapFn != null) ? mapFn(value) : value as T;
}
}
이제 사용자는 무슨 일이 일어나고 있는지 분명히 알 수 있습니다. 더 이상 T의 메서드에 접근할 수 없기 때문입니다. 또한 mapFn 함수 시그니처에서 WithoutMethods<T> 타입을 보게 되므로, 할 수 있는 일에 제약이 있다는 점도 자연스럽게 드러납니다.
참고로 mapFn을 받지 않는다면, 반환하는 객체가 메서드를 갖춘 형태로 구성되기 위해 별도의 매핑이 필요 없다고 가정하는 것입니다. 이는 number나 string[] 같은 원시 타입에 해당합니다. 이런 타입들의 메서드는 JavaScript 코어 엔진의 일부이기 때문입니다.
이 기법에는 반드시 알아야 할 두 가지 한계가 있습니다. 첫째, 앞서 말했듯 private 프로퍼티에는 동작하지 않습니다. keyof T가 그것들을 나열하지 않기 때문이죠. 다른 하나는 조금 더 미묘합니다. TypeScript는 getter를 데이터 프로퍼티로 취급합니다. 즉, 다음 코드는 실패합니다:
class WithGet {
constructor (public x_: number) { }
get x(): number { return this.x_; }
}
// ERROR: obj.x is not a function!
db.get<WithGet>("with-get", obj => new WithGet(obj.x));
이 패턴은 데이터베이스에 저장하는 불변 데이터 객체와 함께 사용할 때 가장 잘 맞습니다. 그런 객체들은 private 타입을 가질 일이 거의 없어서, getter 함수의 존재가 사용 사례에서 중요하지 않기 때문입니다.
저는 타입 시스템을 사용해, 원래는 주석과 암묵적 팀 지식으로만 표현할 수 있었던 제약을 컴파일러가 강제하게 만드는 것을 좋아합니다. 이것은 아주 적은 양의 배관 작업만으로도 코드의 신뢰성을 크게 높이고 확장도 훨씬 쉽게 만들 수 있다는 좋은 예시입니다. 타입 매핑에 대한 이 짧은 장광설을 즐기셨길 바라며, 이제 여러분의 프로젝트에 적용할 준비가 되셨기를 바랍니다!
이 글을 읽어 주셔서 감사합니다! 저는 언제나 새로운 프로젝트를 환영하는 프리랜스 소프트웨어 엔지니어입니다. 구체화하고 싶은 프로젝트가 있다면, 주저하지 말고 연락해 주세요. 함께 기술 이야기를 나눠 봅시다!