거의 모든 웹 애플리케이션에는 데이터 직렬화가 필요합니다. 이러한 필요성은 다음과 같은 상황에서 발생합니다.
많은 경우 데이터 손실이나 손상은 심각한 결과를 초래할 수 있으므로 개발 단계에서 가능한 한 많은 오류를 감지하는 데 도움이 되는 편리하고 안전한 직렬화 메커니즘을 제공하는 것이 필수적입니다. 이러한 목적을 위해 JSON을 데이터 전송 형식으로 사용하고 개발 중 정적 코드 확인을 위해 TypeScript를 사용하는 것이 편리합니다.
TypeScript는 JSON.stringify
및 JSON.parse
와 같은 기능을 원활하게 사용할 수 있도록 지원하는 JavaScript의 상위 집합 역할을 합니다. 그렇죠? 모든 이점에도 불구하고 TypeScript는 JSON이 무엇인지, JSON으로의 직렬화 및 역직렬화에 안전한 데이터 유형을 자연스럽게 이해하지 못하는 것으로 나타났습니다.
이를 예를 들어 설명하겠습니다.
예를 들어 일부 데이터를 LocalStorage에 저장하는 함수를 생각해 보세요. LocalStorage는 객체를 저장할 수 없으므로 여기서는 JSON 직렬화를 사용합니다.
interface PostComment { authorId: string; text: string; updatedAt: Date; } function saveComment(comment: PostComment) { const serializedComment = JSON.stringify(comment); localStorage.setItem('draft', serializedComment); }
LocalStorage에서 데이터를 검색하는 함수도 필요합니다.
function restoreComment(): PostComment | undefined { const text = localStorage.getItem('draft'); return text ? JSON.parse(text) : undefined; }
이 코드에 어떤 문제가 있나요? 첫 번째 문제는 주석을 복원할 때 updatedAt
필드에 대해 Date
대신 string
유형을 얻게 된다는 것입니다.
이는 JSON에 배열 및 객체뿐 아니라 네 가지 기본 데이터 유형( null
, string
, number
, boolean
)만 있기 때문에 발생합니다. 함수, 맵, 설정 등 JavaScript에 있는 다른 개체뿐만 아니라 JSON에 Date
개체를 저장할 수 없습니다.
JSON.stringify
JSON 형식으로 표현할 수 없는 값을 발견하면 유형 캐스팅이 발생합니다. Date
객체의 경우 Date 객체가 Date
객체 대신 문자열을 Date
하는 toJson() 메서드를 구현하기 때문에 문자열을 얻습니다.
const date = new Date('August 19, 1975 23:15:30 UTC'); const jsonDate = date.toJSON(); console.log(jsonDate); // Expected output: "1975-08-19T23:15:30.000Z" const isEqual = date.toJSON() === JSON.stringify(date); console.log(isEqual); // Expected output: true
두 번째 문제는 saveComment
함수가 날짜 필드가 Date
유형인 PostComment
유형을 반환한다는 것입니다. 하지만 우리는 Date
대신 string
유형을 받게 될 것이라는 것을 이미 알고 있습니다. TypeScript는 이 오류를 찾는 데 도움이 될 수 있지만 왜 그렇지 않습니까?
TypeScript의 표준 라이브러리에서 JSON.parse
함수는 (text: string) => any
로 입력됩니다. any
사용으로 인해 유형 검사가 기본적으로 비활성화됩니다. 우리의 예에서 TypeScript는 함수가 Date
객체를 포함하는 PostComment
반환할 것이라는 우리의 말을 단순히 받아들였습니다.
이 TypeScript 동작은 불편하고 안전하지 않습니다. 문자열을 Date
객체처럼 처리하려고 하면 애플리케이션이 중단될 수 있습니다. 예를 들어 comment.updatedAt.toLocaleDateString()
호출하면 중단될 수 있습니다.
실제로, 우리의 작은 예에서는 Date
객체를 숫자 타임스탬프로 간단히 대체할 수 있는데, 이는 JSON 직렬화에 적합합니다. 그러나 실제 애플리케이션에서는 데이터 개체가 광범위할 수 있고 유형이 여러 위치에서 정의될 수 있으며 개발 중에 이러한 오류를 식별하는 것은 어려운 작업일 수 있습니다.
JSON에 대한 TypeScript의 이해를 향상시킬 수 있다면 어떨까요?
먼저 TypeScript가 JSON으로 안전하게 직렬화할 수 있는 데이터 유형을 이해하도록 하는 방법을 알아봅시다. TypeScript가 입력 데이터 형식을 확인하여 JSON 직렬화 가능 여부를 확인하는 safeJsonStringify
함수를 생성한다고 가정해 보겠습니다.
function safeJsonStringify(data: JSONValue) { return JSON.stringify(data); }
이 함수에서 가장 중요한 부분은 JSON 형식으로 표현할 수 있는 모든 값을 나타내는 JSONValue
유형입니다. 구현은 매우 간단합니다.
type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; };
먼저, 모든 기본 JSON 데이터 유형을 설명하는 JSONPrimitive
유형을 정의합니다. 또한 직렬화할 때 undefined
값을 가진 키가 생략된다는 사실을 기반으로 undefined
유형도 포함합니다. 역직렬화 중에는 이러한 키가 객체에 나타나지 않습니다. 이는 대부분의 경우 동일합니다.
다음으로 JSONValue
유형을 설명합니다. 이 유형은 자신을 참조하는 유형인 재귀 유형을 설명하는 TypeScript의 기능을 사용합니다. 여기서 JSONValue
JSONPrimitive
, JSONValue
배열 또는 모든 값이 JSONValue
유형인 객체일 수 있습니다. 결과적으로 이 유형의 JSONValue
의 변수는 무제한 중첩이 가능한 배열 및 객체를 포함할 수 있습니다. 이들 내의 값은 JSON 형식과의 호환성도 확인됩니다.
이제 다음 예제를 사용하여 safeJsonStringify
함수를 테스트할 수 있습니다.
// No errors safeJsonStringify({ updatedAt: Date.now() }); // Yields an error: // Argument of type '{ updatedAt: Date; }' is not assignable to parameter of type 'JSONValue'. // Types of property 'updatedAt' are incompatible. // Type 'Date' is not assignable to type 'JSONValue'. safeJsonStringify({ updatedAt: new Date(); });
모든 것이 제대로 작동하는 것 같습니다. 이 함수를 사용하면 날짜를 숫자로 전달할 수 있지만 Date
개체를 전달하면 오류가 발생합니다.
그러나 함수에 전달된 데이터가 변수에 저장되고 설명된 유형을 갖는 보다 현실적인 예를 고려해 보겠습니다.
interface PostComment { authorId: string; text: string; updatedAt: number; }; const comment: PostComment = {...}; // Yields an error: // Argument of type 'PostComment' is not assignable to parameter of type 'JSONValue'. // Type 'PostComment' is not assignable to type '{ [key: string]: JSONValue; }'. // Index signature for type 'string' is missing in type 'PostComment'. safeJsonStringify(comment);
이제 상황이 좀 까다로워지고 있습니다. TypeScript에서는 PostComment
유형의 변수를 JSONValue
유형의 함수 매개변수에 할당할 수 없습니다. "'string' 유형에 대한 인덱스 서명이 'PostComment' 유형에 누락되어 있기 때문입니다."
그렇다면 인덱스 서명이란 무엇이며 왜 누락되었습니까? JSON 형식으로 직렬화할 수 있는 개체를 어떻게 설명했는지 기억하시나요?
type JSONValue = { [key: string]: JSONValue; };
이 경우 [key: string]
이 인덱스 서명입니다. 이는 "이 객체는 문자열 형식의 모든 키를 가질 수 있으며 그 값은 JSONValue
유형을 갖습니다"를 의미합니다. 따라서 PostComment
유형에 색인 서명을 추가해야 한다는 사실이 밝혀졌습니다. 그렇죠?
interface PostComment { authorId: string; text: string; updatedAt: number; // Don't do this: [key: string]: JSONValue; };
그렇게 하면 주석에 임의의 필드가 포함될 수 있으며 이는 일반적으로 애플리케이션에서 데이터 유형을 정의할 때 원하는 결과가 아닙니다.
인덱스 서명 문제에 대한 실제 솔루션은 매핑된 유형 에서 나옵니다. 이를 통해 인덱스 서명이 정의되지 않은 유형의 경우에도 필드를 반복적으로 반복할 수 있습니다. 제네릭과 결합된 이 기능을 사용하면 모든 데이터 유형 T
를 JSON 형식과 호환되는 다른 유형 JSONCompatible<T>
로 변환할 수 있습니다.
type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; type NotAssignableToJson = | bigint | symbol | Function;
JSONCompatible<T>
유형은 지정된 유형 T
JSON으로 안전하게 직렬화할 수 있는지 여부를 검사하는 매핑된 유형입니다. 이는 T
유형의 각 속성을 반복하고 다음을 수행하여 수행됩니다.
T[P] extends JSONValue ? T[P] : ...
조건부 유형은 속성 유형이 JSONValue
유형과 호환되는지 확인하여 JSON으로 안전하게 변환할 수 있는지 확인합니다. 이 경우 속성 유형은 변경되지 않습니다.T[P] extends NotAssignableToJson ? never : ...
조건부 유형은 속성 유형을 JSON에 할당할 수 없는지 확인합니다. 이 경우 속성의 유형은 never
로 변환되어 최종 유형에서 속성을 효과적으로 필터링합니다.
unknown extends T ? never :...
시작 부분의 확인은 unknown
유형이 본질적으로 any
유형과 동일한 빈 객체 유형 {}
으로 변환되는 것을 방지하는 데 사용됩니다.
또 다른 흥미로운 측면은 NotAssignableToJson
유형입니다. 이는 두 가지 TypeScript 기본 요소(bigint 및 기호)와 가능한 기능을 설명하는 Function
유형으로 구성됩니다. Function
유형은 JSON에 할당할 수 없는 값을 필터링하는 데 중요합니다. 이는 JavaScript의 모든 복잡한 객체가 Object 유형을 기반으로 하고 프로토타입 체인에 적어도 하나의 함수(예: toString()
)를 갖고 있기 때문입니다. JSONCompatible
유형은 이러한 모든 함수를 반복하므로 함수를 확인하는 것만으로도 JSON으로 직렬화할 수 없는 모든 항목을 필터링할 수 있습니다.
이제 직렬화 함수에서 이 유형을 사용해 보겠습니다.
function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); }
이제 함수는 일반 매개변수 T
사용하고 JSONCompatible<T>
인수를 허용합니다. 이는 JSON 호환 유형이어야 하는 T
유형의 인수 data
취한다는 것을 의미합니다. 이제 인덱스 서명 없이 데이터 유형과 함께 함수를 사용할 수 있습니다.
이제 이 함수는 JSONCompatible<T>
유형에서 확장되는 일반 매개변수 T
사용합니다. 이는 JSON 호환 유형이어야 하는 T
유형의 인수 data
허용한다는 것을 의미합니다. 결과적으로 인덱스 서명이 없는 데이터 유형으로 함수를 활용할 수 있습니다.
interface PostComment { authorId: string; text: string; updatedAt: number; } function saveComment(comment: PostComment) { const serializedComment = safeJsonStringify(comment); localStorage.setItem('draft', serializedComment); }
이 접근 방식은 네트워크를 통한 데이터 전송, HTML에 데이터 삽입, localStorage에 데이터 저장, 작업자 간 데이터 전송 등과 같이 JSON 직렬화가 필요할 때마다 사용할 수 있습니다. 또한 toJsonValue
도우미는 형식이 엄격하지 않은 개체인 경우 사용할 수 있습니다. 인덱스 서명은 JSONValue
유형의 변수에 할당되어야 합니다.
function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } const comment: PostComment = {...}; const data: JSONValue = { comment: toJsonValue(comment) };
이 예에서 toJsonValue
사용하면 PostComment
유형에서 누락된 인덱스 서명과 관련된 오류를 우회할 수 있습니다.
역직렬화의 경우 수신된 데이터 형식에 대한 정적 분석 검사와 런타임 검사가 모두 포함되므로 문제는 더 간단하면서도 동시에 더 복잡합니다.
TypeScript의 유형 시스템 관점에서 보면 문제는 매우 간단합니다. 다음 예를 고려해 보겠습니다.
function safeJsonParse(text: string) { return JSON.parse(text) as unknown; } const data = JSON.parse(text); // ^? unknown
이 경우에는 any
반환 유형을 unknown
유형으로 대체합니다. unknown
선택하는 이유는 무엇입니까? 기본적으로 JSON 문자열에는 수신할 것으로 예상되는 데이터뿐만 아니라 무엇이든 포함될 수 있습니다. 예를 들어 데이터 형식은 서로 다른 애플리케이션 버전 간에 변경될 수 있거나 앱의 다른 부분이 동일한 LocalStorage 키에 데이터를 쓸 수 있습니다. 따라서 unknown
가장 안전하고 정확한 선택입니다.
그러나 unknown
유형으로 작업하는 것은 단순히 원하는 데이터 유형을 지정하는 것보다 덜 편리합니다. 유형 캐스팅 외에도 unknown
유형을 필요한 데이터 유형으로 변환하는 방법에는 여러 가지가 있습니다. 그러한 방법 중 하나는 Superstruct 라이브러리를 활용하여 런타임 시 데이터의 유효성을 검사하고 데이터가 유효하지 않은 경우 자세한 오류를 발생시키는 것입니다.
import { create, object, number, string } from 'superstruct'; const PostComment = object({ authorId: string(), text: string(), updatedAt: number(), }); // Note: we no longer need to manually specify the return type function restoreDraft() { const text = localStorage.getItem('draft'); return text ? create(JSON.parse(text), PostComment) : undefined; }
여기서 create
함수는 유형 가드 역할을 하여 유형을 원하는 Comment
인터페이스로 좁힙니다 . 결과적으로 더 이상 반환 유형을 수동으로 지정할 필요가 없습니다.
안전한 역직렬화 옵션을 구현하는 것은 이야기의 절반에 불과합니다. 프로젝트의 다음 작업을 처리할 때 이를 사용하는 것을 잊지 않는 것도 마찬가지로 중요합니다. 대규모 팀이 프로젝트에 참여하는 경우 이는 특히 어려운 일이 됩니다. 모든 합의와 모범 사례를 준수하는 것이 어려울 수 있기 때문입니다.
Typescript-eslint가 이 작업에 도움을 줄 수 있습니다. 이 도구는 안전하지 않은 any
사용 사례를 식별하는 데 도움이 됩니다. 구체적으로 JSON.parse
의 모든 사용법을 확인할 수 있으며, 수신된 데이터의 형식이 확인되는지 확인할 수 있습니다. 코드베이스에서 any
유형을 제거하는 방법에 대한 자세한 내용은 Make TypeScript를 진정한 "강력한 유형"으로 만들기 문서에서 읽을 수 있습니다.
안전한 JSON 직렬화 및 역직렬화를 지원하도록 설계된 최종 유틸리티 함수 및 유형은 다음과 같습니다. 준비된 TS Playground 에서 이를 테스트할 수 있습니다.
type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; }; type NotAssignableToJson = | bigint | symbol | Function; type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); } function safeJsonParse(text: string): unknown { return JSON.parse(text); }
JSON 직렬화가 필요한 모든 상황에서 사용할 수 있습니다.
나는 이 전략을 내 프로젝트에서 몇 년 동안 사용해 왔으며 애플리케이션 개발 중에 잠재적인 오류를 즉시 감지함으로써 그 효과를 입증했습니다.
이 기사가 여러분에게 새로운 통찰력을 제공하였기를 바랍니다. 읽어 주셔서 감사합니다!