- Published on
 
JSON.stringify 만들어보기
- Author
 
- Name
 - yceffort
 
Table of Contents
JSON이 지원하는 타입
JSON 무려 공식 홈페이지가 존재하는데, 여기서 어떤 데이터 타입을 지원하는지 나와있다. JSON은 우리가 매일 쓰고 또 그다지 어렵지 않기 때문에 그렇게 복잡하게 생각해본적이 없는데, 공식 문서의 그래프를 보면 살짝 어지러워진다. 이래저래 읽는게 귀찮고 복잡하므로, 타입스크립트로 간단하게 요약해보자면 다음과 같다.
type JSONType =
  | null
  | boolean
  | number
  | string
  | JSONType[]
  | {[key: string]: JSONType}
JSON은 언어에 종속적이지 않기 때문에, 자바스크립트에만 있는 고유의 타입, undefined Symbol BigInt 등과 Function Class Map 등도 지원하지 않는다.
현기증 나는 JSON.stringify
JSON.stringify를 계속 쓰다보면, 이 함수의 동작은 참 일관적이지 않다는 것을 깨닫게 된다.
JSON.stringify(1) // '1'
JSON.stringify(null) // 'null'
JSON.stringify('foo') // '"foo"'
JSON.stringify({foo: 'bar'}) // '{"foo":"bar"}'
JSON.stringify(['foo', 'bar']) // '["foo","bar"]'
JSON이 지원하지 않는 타입은 undefined
여기까지는 우리가 모두 이해하는 수준이다. 그러나 앞서 언급했던, JSON이 지원하지 않는 일부 타입에 대해서는 다음과 같이 반환된다.
JSON.stringify(undefined) // undefined
JSON.stringify(Symbol('foo')) // undefined
JSON.stringify(() => {}) // undefined
모두 undefined가 나온다면 그래도 행복할 것 같다. 그러나
Map, Regex, Set은 빈 JSON
JSON.stringify(/foo/) // '{}'
JSON.stringify(new Map()) // '{}'
JSON.stringify(new Set()) //'{}'
....?
Array와 Object 내부에 지원하지 않는 타입이 있는 경우
더 골 때리는 것은 serialize가 가능한 값, 예를 들어 array나 object에서 더 일관성 없이 동작한다는 것이다. undefined Symbol Function 이 배열안에 있으면 'null'로 변환된다. 그리고 객체 안에 속성이 있다면 그 속성 전체는 완전히 무시되고 빈 객체 (정확히는 빈 JSON) 가 된다.
JSON.stringify([undefined]) // '[null]'
JSON.stringify({foo: undefined}) // '{}'
JSON.stringify([Symbol()]) // '[null]'
JSON.stringify({foo: Symbol()}) // '{}'
JSON.stringify([() => {}]) // '[null]'
JSON.stringify({foo: () => {}}) // '{}'
이와 다르게, Map Set Regex가 배열이나 객체 내부에 있다면, 이들은 모두 일관되게 {}으로 변환된다. 그리고, 당연히 값도 날아간다.
JSON.stringify([/foo/]) // '[{}]'
JSON.stringify({foo: /foo/}) // '{"foo":{}}'
JSON.stringify([new Set()]) // '[{}]'
JSON.stringify({foo: new Set()}) // '{"foo":{}}'
JSON.stringify([new Map()]) // '[{}]'
JSON.stringify({foo: new Map()}) // '{"foo":{}}'
BigInt와 순환참조는 throw error
여기에 추가로, BigInt가 내부에 오게 되면 TypeError를 리턴하게 된다.
bigint = BigInt(9007199254740991)
JSON.stringify(bigint) //  Uncaught TypeError: Do not know how to serialize a BigInt
그리고 우리가 잘 알고 있는 것 처럼, 순환참조를 하는 객체의 경우에도 에러가 난다.
const foo = {}
foo.a = foo
JSON.stringify(foo) // Uncaught TypeError: Converting circular structure to JSON
한가지 유념에 두어야 할 것은, BigInt와 Cyclic Object 이 딱 두가지 경우에만 error를 던진다. JSON.stringify는 우리가 아는 함수 중에서 가장 관대한 편에 속한다.
NaN과 Infinity는 null
숫자 중에서도 NaN과 Infinity는 null로 리턴된다.
JSON.stringify(NaN) // null
JSON.stringify(Infinity)
날짜는 ISO String
Date의 경우에는 ISO string으로 변환된다. 그 이유는 Date.prototype.toJSON의 동작 때문이다.
JSON.stringify(new Date()) // '"2022-06-18T03:43:12.133Z"'
열거불가능, Symbol 키는 무시
JSON.stringify는 오직 열거 가능한, 비 심볼키 속성에 대해서만 처리한다. 즉, 심볼키로 되어 있거나, 열거 불가능한 속성은 무시하게 된다.
const foo = {}
foo[Symbol('p1')] = 'bar'
Object.defineProperty(foo, 'p2', {value: 'baz', enumerable: false})
JSON.stringify(foo) // '{}'
이 코드 조각을 보고 나니 왜
JSON.parse와JSON.stringify로 객체를 깊은 복사하는 것이 불가능한지 이해할 수 있게 되었다.
요약
| UnSupported type | pass directly | array | object | 
|---|---|---|---|
| undefined | undefined | 'null' | omitted | 
| symbol | undefined | 'null' | omitted | 
| function | undefined | 'null' | omitted | 
| NaN | 'null' | 'null' | 'null' | 
| Infinity | 'null' | 'null' | 'null' | 
| Regex | '{}' | '{}' | '{}' | 
| Map | {} | '{}' | '{}' | 
| Set | '{}' | '{}' | '{}' | 
| WeakMap | '{}' | '{}' | '{}' | 
| WeakSet | '{}' | '{}' | '{}' | 
| BigInt | TypeError | TypeError | TypeError | 
| Cyclic objects | TypeError | TypeError | TypeError | 
구현해보기
가장 먼저 해야할 것은 순환 참조인지 확인하는 함수를 만든 것이다.
function isCyclic(input: unknown): boolean {
  const seen = new Set()
  function dfs(obj: unknown) {
    if (typeof obj !== 'object' || obj === null) {
      return false
    }
    seen.add(obj)
    return Object.entries(obj).some(([key, value]) => {
      const result = seen.has(value) ? true : isCyclic(value)
      seen.delete(result)
      return result
    })
  }
  return dfs(input)
}
이제 본격적으로 stringify를 구현해보자.
function isCyclic(input: unknown): boolean {
  const seen = new Set()
  function dfs(obj: unknown) {
    if (typeof obj !== 'object' || obj === null) {
      return false
    }
    seen.add(obj)
    return Object.entries(obj).some(([key, value]) => {
      const result = seen.has(value) ? true : isCyclic(value)
      seen.delete(result)
      return result
    })
  }
  return dfs(input)
}
function JSONStringify(data: unknown): string {
  if (isCyclic(data)) {
    throw new TypeError('순환참조 객체는 stringify 할 수 없습니다.')
  }
  if (typeof data === 'bigint') {
    throw new TypeError('Bigint는 stringify로 변환할 수 없습니다.')
  }
  if (data === null) {
    return String(null)
  }
  if (typeof data !== 'object') {
    if (Number.isNaN(data) || data === Infinity) {
      return String(null)
    } else if (['function', 'undefined', 'symbol'].includes(typeof data)) {
      return undefined
    } else if (typeof data === 'string') {
      return `"${data}"`
    } else {
      return String(data)
    }
  } else {
    if (data instanceof Date) {
      return JSONStringify(data.toJSON())
    } else if (data instanceof Array) {
      const result = data.map((item) => {
        if (
          typeof item === 'undefined' ||
          typeof item === 'function' ||
          typeof item === 'symbol'
        ) {
          return String(null)
        } else {
          return JSONStringify(item)
        }
      })
      return `[${result}]`.replace(/'/g, '"')
    } else {
      const result = Object.entries(data).reduce((result, [key, value]) => {
        if (
          value !== undefined &&
          typeof value !== 'function' &&
          typeof value !== 'symbol'
        ) {
          result.push(`"${key}":${JSONStringify(value)}`)
        }
        return result
      }, [] as string[])
      return `{${result}}`.replace(/'/g, '"')
    }
  }
}
테스트 해보기
const test = [
  1,
  null,
  'foo',
  {'foo': 'bar'},
  ['foo', 'bar'],
  undefined,
  new Map(),
  new Set(),
  [undefined],
  {foo: undefined},
  [Symbol()],
  {foo: Symbol()},
  [() => {}],
  {foo: () => {}},
  [/foo/],
  {foo: /foo/},
  [new Set()],
  {foo: new Set()},
  [new Map()],
  {foo: new Map()},
]
for (const tc of test) {
  const result1 = JSON.stringify(tc)
  const result2 = JSONStringify(tc)
  if (result1===result2) {
    console.log(tc, 'TRUE')
  } else if (result1 === undefined && result2 === undefined) {
    console.log(tc, 'TRUE')
  } else {
    console.log(tc, 'FALSE')
  }
}
/**
 1 TRUE
null TRUE
foo TRUE
{ foo: 'bar' } TRUE
[ 'foo', 'bar' ] TRUE
undefined TRUE
Map {} TRUE
Set {} TRUE
[ undefined ] TRUE
{ foo: undefined } TRUE
[ Symbol() ] TRUE
{ foo: Symbol() } TRUE
[ [Function] ] TRUE
{ foo: [Function: foo] } TRUE
[ /foo/ ] TRUE
{ foo: /foo/ } TRUE
[ Set {} ] TRUE
{ foo: Set {} } TRUE
[ Map {} ] TRUE
{ foo: Map {} } TRUE
 * /