paint-brush
TypeScript에서 유형 안전 JSON 직렬화 마스터하기by@nodge
12,445
12,445

TypeScript에서 유형 안전 JSON 직렬화 마스터하기

Maksim Zemskov11m2024/02/26
Read on Terminal Reader

이 문서에서는 JSON 형식을 사용할 때 TypeScript의 데이터 직렬화 문제를 살펴봅니다. 특히 JSON.stringify 및 JSON.parse 함수의 단점에 중점을 둡니다. 이러한 문제를 해결하기 위해 JSONCompatible 유형을 사용하여 T 유형을 JSON으로 안전하게 직렬화할 수 있는지 확인하는 것이 좋습니다. 또한 JSON에서 안전한 역직렬화를 위해 Superstruct 라이브러리를 권장합니다. 이 방법은 유형 안전성을 향상시키고 개발 중에 오류 감지를 가능하게 합니다.
featured image - TypeScript에서 유형 안전 JSON 직렬화 마스터하기
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


거의 모든 웹 애플리케이션에는 데이터 직렬화가 필요합니다. 이러한 필요성은 다음과 같은 상황에서 발생합니다.


  • 네트워크를 통한 데이터 전송(예: HTTP 요청, WebSocket)
  • HTML에 데이터 삽입(예: 수분 공급)
  • 영구 저장소(예: LocalStorage)에 데이터 저장
  • 프로세스 간 데이터 공유(예: 웹 작업자 또는 postMessage)


많은 경우 데이터 손실이나 손상은 심각한 결과를 초래할 수 있으므로 개발 단계에서 가능한 한 많은 오류를 감지하는 데 도움이 되는 편리하고 안전한 직렬화 메커니즘을 제공하는 것이 필수적입니다. 이러한 목적을 위해 JSON을 데이터 전송 형식으로 사용하고 개발 중 정적 코드 확인을 위해 TypeScript를 사용하는 것이 편리합니다.


TypeScript는 JSON.stringifyJSON.parse 와 같은 기능을 원활하게 사용할 수 있도록 지원하는 JavaScript의 상위 집합 역할을 합니다. 그렇죠? 모든 이점에도 불구하고 TypeScript는 JSON이 무엇인지, JSON으로의 직렬화 및 역직렬화에 안전한 데이터 유형을 자연스럽게 이해하지 못하는 것으로 나타났습니다.


이를 예를 들어 설명하겠습니다.


TypeScript의 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 유형의 각 속성을 반복하고 다음을 수행하여 수행됩니다.


  1. T[P] extends JSONValue ? T[P] : ... 조건부 유형은 속성 유형이 JSONValue 유형과 호환되는지 확인하여 JSON으로 안전하게 변환할 수 있는지 확인합니다. 이 경우 속성 유형은 변경되지 않습니다.
  2. T[P] extends NotAssignableToJson ? never : ... 조건부 유형은 속성 유형을 JSON에 할당할 수 없는지 확인합니다. 이 경우 속성의 유형은 never 로 변환되어 최종 유형에서 속성을 효과적으로 필터링합니다.
  3. 이러한 조건 중 어느 것도 충족되지 않으면 결론을 내릴 수 있을 때까지 유형을 재귀적으로 확인합니다. 이렇게 하면 유형에 색인 서명이 없더라도 작동합니다.


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 직렬화가 필요한 모든 상황에서 사용할 수 있습니다.


나는 이 전략을 내 프로젝트에서 몇 년 동안 사용해 왔으며 애플리케이션 개발 중에 잠재적인 오류를 즉시 감지함으로써 그 효과를 입증했습니다.


이 기사가 여러분에게 새로운 통찰력을 제공하였기를 바랍니다. 읽어 주셔서 감사합니다!

유용한 링크