[kotlin] JSON parser를 구현해보자.

개요top

순수 kotlin으로 JSON 파서를 만들어보면서 코틀린의 언어의 다양한 특성을 공부해보도록 하죠.

JSON파서를 구현하는 전략은 여러가지가 있습니다만 최대한 정규식을 적극적으로 활용하고 일부만 전진용 토크나이저를 이용하도록 하는 것입니다.
특히 재귀구조를 제거하여 대규모 JSON에 대해서도 파싱할 때 스택오버플로우가 일어나지 않게 하는 것도 목표죠.

(참고로 본 예제는 코틀린 언어의 여러 특성을 알기 위해 다양한 구성요소 만들어진 파서입니다. 성능면에서는 일반적인 스택구조의 파서가 훨씬 빠릅니다 ^^)

JSON의 구조 및 특성top

JSON은 다음과 같은 특성을 갖고 있습니다.

  • 등장할 수 있는 타입
  • 숫자 – 정수형 또는 실수형(e+, e-, E+, E- 표현을 포함할 수도 있다!)
  • 문자열 – 일반적인 문자열이지만 개행문자를 포함할 수는 없다.
  • 불린 – 반드시 소문자로 기술되는 true, false
  • 널 – 놀랍게도 null이 가능하다.
  • Object – {..}를 다시 넣어서 재귀적인 포함관계를 설정할 수 있다.
  • Array – [..]도 재귀적으로 포함관계를 설정할 수 있다.

즉 JSON파싱의 어려운 점은 바로 재귀적인 정의가 가능하게 하는 Array, Object의 존재입니다. 자료구조가 재귀적이므로 재귀적인 파서를 짜도 되지만 이러면 뎁스가 깊어질 때 스택오버플로우가 발생하게 됩니다. 가장 쉬운 전략은 치환입니다. Array나 Object가 내부에 다른 Array나 Object를 포함하지 못하게 치환하는 것입니다.

1. 문자열 치환단계top

첫번째 단계로 떠오르는 건 어떤 object나 array안에 다른 array나 object가 존재하지 않게 처리하는 것입니다.
즉 […[…]..] 라는 형태를 만나면 감싸고 있는 배열은 내부에 배열이 있으니 대상이 되지 않고 그 안에 있는 배열만 내부에 배열이나 오브젝트가 없는 대상이 될 것입니다.

그런데 이걸 감지하는 방법은 의외로 쉽지 않습니다. 문자열 안에도 배열이나 객체가 포함될 수 있기 때문입니다. 예를 들어 아래와 같은 상황이죠.

{
  "a":"{...[..[..{..}..]..]..}"
}

이 json은 내부에 객체나 배열을 포함하지 않지만 {}쌍이나 []만 갖고 파싱을 하려고 들면 틀림없이 걸려들 것입니다.
그래서 문자열 내부는 아닌 형태에서 객체나 배열을 포함하지 않는 정규식이 필요하게 됩니다.
그럴려면 이렇게 문제가 될 소지가 있는 문자열을 미리 치환해두는게 상책입니다. 그럼 문제가 될 문자열을 찾는 정규식을 작성해보죠.

(\s|:|,|^)(\"((?:\\"|[^"])*(?:[\{\}\[\]])(?:\\"|[^"]|[\{\}\[\]])*)\")

이런 정규식이 됩니다. 복잡하니 한단계씩 보겠습니다.

  1. (\s|:|,|^)

    처음 시작이 공백, :콜론, 컴마, 아니면 처음 시작 중 하나여야 한다는 조건입니다. json은 값이거나 처음시작부분을 나타낸다고 생각하시면 되겠습니다.

  2. \"((?:\\"|[^"])*

    이건 뭐냐면 큰 따옴표로 시작해서 \”이거나 “가 아닌 문자열이 이어지는 즉 문자열 값의 시작과 몸통을 의미합니다.

  3. (?:[\{\}\[\]])

    이건 {}[]중 하나의 문자열이 나오는걸 의미하죠.

  4. (?:\\"|[^"]|[\{\}\[\]])*

    요건 \”이거나 “아니거나 {}[]중에 하나가 나오는 문자열 몸통의 진행입니다.

  5. \"

    그리고 나서 최종 문자열을 닫아주는 곳입니다.

즉 해석하자면 문자열 중에 {}[]를 포함하는 문자열을 감지하는 정규식인 셈입니다. 이 문자열들은 중첩된 객체와 배열을 감지할 때 문제를 일으키므로 미리 치환해두려고 하는 것이죠.
이제 정규식을 이용한 코틀린 코드를 작성해보죠.

//그 정규식
private val sstr = """(\s|:|,|^)(\"((?:\\"|[^"])*(?:[\{\}\[\]])(?:\\"|[^"]|[\{\}\[\]])*)\")""".toRegex()

fun parseJSON(txt:String):Value{ //Value대수타입은 아래쪽에서 다루죠 ^^

  //빈 문자열은 빈 값으로 반환
  if(txt.isBlank()) return Value.EMPTY

  //치환될 문자열을 보관할 map
  val ss = mutableMapOf<String, String>()

  var idx = 0//치환할 문자열마다 부여할 id

  //문자열을 치환한 결과를 v로
  var v = sstr.replace(txt){

    //이런 안전한 형태로 치환하자!
    val k = "<!#${idx++}#$>" 

    //원본 문자열은 map에 담아두고
    ss[k] = it.groupValues[2]

    //문자열을 치환한다.
    it.groupValues[1] + k
  }
}

아래와 같은 예제를 생각해보죠.

{
 "a":"{...[..].}",
 "b":"abc",
 "c":"[..]",
 "d":{"c":1, "d":[1,2,3]},
 "e":[1,2,[3,4]]
}

그럼 위의 로직을 돌리고 나면

v = "{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":{"c":1, "d":[1,2,3]},
 "e":[1,2,[3,4]]
}"

ss["<!#$0#$>"] = "\"{...[..].}\""
ss["<!#$1#$>"] = "\"[..]\""

이런 상태가 될 것입니다. 이것으로 v에는 더 이상 문제가 될 문자열이 없는 것이죠.

2. 중첩된 객체와 배열 치환 단계top

이제 문제가 되는 문자열을 다 치환했으니 본격적으로 중첩된 객체와 배열을 치환할 차례입니다.
이미 문자열 치환에서 원리를 봤으니 손쉽게 할 수 있겠….죠 ^^
이번 정규식은 아래와 같습니다.

\s*(\{[^\{\}\[\]]*\}|\[[^\{\}\[\]]*\])\s*

이것도 복잡하니 나눠서 보죠.

  1. \s*

    언제나 앞 뒤의 공백을 염두해줘야죠 ^^

  2. \{[^\{\}\[\]]*\}

    {로 시작해서 내부에 {}[]가 안나오는 }를 의미합니다.

  3. \[[^\{\}\[\]]*\]

    [로 시작해서 내부에 {}[]가 안나오는 ]를 의미합니다.

이걸 통해 내부에 객체나 배열을 포함하지 않는 순수한? 객체와 배열을 찾을 수 있습니다. 이걸 치환하는 거죠.

private val sstr = """(\s|:|,|^)(\"((?:\\"|[^"])*(?:[\{\}\[\]])(?:\\"|[^"]|[\{\}\[\]])*)\")""".toRegex()

//새 정규식
private val atom = """\s*(\{[^\{\}\[\]]*\}|\[[^\{\}\[\]]*\])\s*""".toRegex(RegexOption.MULTILINE)

fun parseJSON(txt:String):Value{
  if(txt.isBlank()) return Value.EMPTY
  val ss = mutableMapOf<String, String>()
  var idx = 0
  var v = sstr.replace(txt){
    val k = "<!#${idx++}#$>" 
    ss[k] = it.groupValues[2]
    it.groupValues[1] + k
  }

  //순수 객체와 배열을 저장할 map
  val kv = mutableMapOf<String, String>()

  //아이디 부여를 위해 idx초기화
  idx = 0

  //더이상 찾을 수 없을 때까지 라운드를 반복하며 v를 갱신함.
  while(atom.find(v) != null) v = atom.replace(v) {
 
    //문자열 치환 때와는 다른 형태의 모양으로 치환
    val k = "<@#${idx++}!$>"

    //map에 원본을 담아두고
    kv[k] = it.groupValues[1]

    //치환한다
    k
  }
}

이제 여기까지 로직을 전개해보면 1번 문자열 치환단계에 있던 v값이 어떻게 변할지 예상해볼 수 있죠.
먼저 문자열 치환까지 완료한 v값을 다시 보죠.

v = "{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":{"c":1, "d":[1,2,3]},
 "e":[1,2,[3,4]]
}"

여기서 추가된 로직을 굴리면 라운드를 반복할 때마다 다음과 같이 될 것입니다. 첫번째 라운드부터 볼까요.

v = "{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":{"c":1, "d":<@#${0}!$>},
 "e":[1,2,<@#${1}!$>]
}"

kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"

첫 번째 라운드에서 순수한 객체와 배열은 두 개였기 때문에 우선 두 개가 치환되었습니다. 치환에 의해 while을 통한 다음 라운드에 돌입하면 다시 순수한 객체와 배열이 발견되게 됩니다.
두 번째 라운드를 수행하고 나면 다음과 같이 치환될 것입니다.

v = "{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"

kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""

이제 같은 원리로 마지막 라운드에 돌입하게 됩니다.

v = "<@#${4}!$>"

kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

바로 이게 최종 라운드를 거치고 나면 수렴되는 문자열인거죠. 묶는건 끝났으니까 이제 풀어주기만 하면 됩니다. 하지만 그 전에 대수타입을 정의하고 갈까 합니다.

3. 대수타입의 정의top

Algebraic data type 대수타입은 쉽게 말해 “다른 타입을 갖을 수 있는 타입”이라고(…쉬울리가 없나..) 말할 수 있습니다.
합타입과 곱타입이 있는데 보통 개발 컨텍스트에서는 합타입 즉 or타입이라 할 수 있습니다.

수학적으로 완전한 대수타입이 되려면 enum이나 코틀린의 sealed를 사용해야 하는 데 굳이 이러한 완전성을 추구할 필요가 없다면 인터페이스로도 흉내낼 수 있습니다.
사실 인터페이스인 편이 확장에 열려있어서 완전한 수학적 대수타입집합보다 유연하기도 하고..=.=

json은 내부에 다양한 타입의 형을 갖고 있는데 이 타입을 하나로 묶는 추상클래스 값타입을 정의한다고 생각하시면 편합니다(괜히 어렵게 설명해서 손해봤다..)

이를 아우르는 타입을 Value라고 합시다.

interface Value{
  val v:Any
  fun stringify() = "$v"
}

코틀린 인터페이스는 훨씬 편한 문법으로 java8이후의 스펙을 흡수하고 있는데

  1. default 구현은 그냥 구현하면 반영되고 특별한 키워드가 필요없습니다.
  2. 또한 val은 내부적으로 getter/setter 이므로 손쉽게 인터페이스상에서 정의할 수 있습니다.

그 외엔 자바의 시그니쳐의 상속관계를 따르므로 인터페이스상의 반환 타입은 구상 시그니처에서 공변을 적용받습니다.
v를 Any로 해뒀기 때문에 구상 클래스에서는 구상형을 사용할 수 있다는 뜻이죠.

이제 이걸 바탕으로 json이 요구하는 다양한 구상형을 구현합니다. 우선 문자열부터 볼까요.

inline class VString(override val v:String):Value{
  override fun stringify() = "\"${v.replace("\"", "\\\"")}\""
}

우선 v값을 LSP에 따라 String형으로 지정했습니다. 또한 stringify에 대응하기 위해 내부 문자열중 “가 있으면 \”로 치환하고 앞뒤로는 “를 감싸는 형태로 출력하게 구현했죠.
비슷하게 다른 타입들도 구현해봅시다.

근데 이런 대수타입을 만들면 굉장히 고민스러운게 단순한 값인데 래퍼 클래스가 생겨서 부하가 더 걸리지 않을까 하는 점입니다.
코틀린은 이런 점에서 inline class라는 대안을 제시합니다. 즉 개발시에는 래핑 클래스가 존재하지만 실제 컴파일 시점에는 단순한 값으로 치환해서 컴파일되는 기능입니다.
이 기능을 이용하면 래핑 클래스를 부담없이 만들 수 있죠.
참고로 1.3.6x까지는 실험실 기능이라 그래이들에 추가적인 설정을 해줘야합니다.

kotlin{
  languageSettings.enableLanguageFeature("InlineClasses")
}

이제 개념적으로 안정화? 되었으니 다른 필요한 타입들도 신속하게 정의하겠습니다.

inline class VLong(override val v:Long):Value
inline class VDouble(override val v:Double):Value
inline class VBoolean(override val v:Boolean):Value
inline class VNull(override val v:Boolean = false):Value

이 넘들은 껌이죠. 저는 귀찮아서 파싱시에 숫자는 Long과 Double로만 구분해서 파싱할 생각이므로 다른 타입은 무시했습니다. Null은 존재하는데 실제 값은 필요없으니 걍 간단히 false로 대체해두고..

문제는 객체와 배열도 정의해야한다는 거죠. 이때 대수타입의 위력이 발휘되는 셈인데 객체와 배열도 Value타입이고 그 원소도 전부 Value타입으로 하면 중첩된 객체와 배열도 다 Value로 처리가 되는 식입니다.

이를 이용하면 간단히 VObject는 MutableMap<String, Value>로 VArray는 MutableList로 처리할 수 있습니다.

class VObject:Value, MutableMap<String, Value> by mutableMapOf(){
  override val v:MutableMap<String, Value> = this
  
  override fun stringify():String{
    var r = ""
    forEach{(k, v)->r += ""","$k":${v.stringify()}"""}
    return "{${if(r.isNotBlank()) r.substring(1) else ""}}"
  }
}

실제 구현을 볼까요. VObject는 Value이면서 MutableMap<String, Value>인터페이스를 따르고 있죠. 이 구현은 간단히 클래스 위임인 by를 통해 직접 인스턴스를 건내줌으로서 해소할 수 있습니다.
이때 override된느 v도 간단히 자신을 할당하는 것으로 갈음할 수 있죠.
이로서 VObject는 외부에서는 map처럼 보이게 될 것입니다. 단지 받아들일 수 있는 값의 타입이 Value로 한정될 뿐이죠.

stringify의 구현도 굉장히 쉽습니다.
앞 뒤 중괄호로 감싼 문자열 안에는 k, v를 돌면서 키는 따옴표로 감싸 콜론을 붙여주고 값은 그 값의 stringify를 불러주면 됩니다. 모든 Value는 stringify메소드를 갖고 있으므로 위임시켜서 결합하는 데코레이터 + 컴포지트 패턴을 응용할 수 있습니다.

마지막으로 대동소이하게 VArray를 구현하면 json이 실제 파싱되면서 나올 모든 타입을 해결하게 됩니다.

class VArray:Value, MutableList<eValue> by mutableListOf(){
  override val v:MutableList<eValue> = this
  override fun stringify():String{
    val v= fold(""){r, v->r + ",${v.stringify()}"}
    return "[${if(v.isNotBlank()) v.substring(1) else ""}]"
 }
}

구현 상의 큰 차이는 없으므로 설명은 생략합니다.

4. 순수객체배열 치환 순으로 객체화 시키기top

잠깐 대수타입으로 빠졌습니다만 2번까지 진행된 상황을 다시 점검해보죠.

우선 원문 json입니다.

{
 "a":"{...[..].}",
 "b":"abc",
 "c":"[..]",
 "d":{"c":1, "d":[1,2,3]},
 "e":[1,2,[3,4]]
}

그리고 1번 문자열 치환과 2번 순수 객체배열 치환을 끝낸 상황은 다음과 같죠.

v = "<@#${4}!$>"

//문자열 치환
ss["<!#$0#$>"] = "\"{...[..].}\""
ss["<!#$1#$>"] = "\"[..]\""

//순수 객체배열 치환
kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

여기서 문자열은 가장 마지막에 치환해주면 되니까 무시하고, kv의 첫 번째인 kv[“<@#${0}!$>”] = “[1,2,3]” 부터 차근차근 객체화 시켜줘야합니다.

왜냐면 kv의 앞에 있을 수록 순수 객체이기 때문에 완전히 파싱할 수 있고 그 다음에 오는 kv는 앞에 있는 객체배열이 이미 실제 객체화 되었으므로 대체해서 넣을 수 있기 때문이죠.
이렇게 kv를 전진하면서 객체화 시킨 결과는 map에 잡아주도록 합시다.

코틀린의 map은 기본적으로 LinkedHashMap에 해당되므로 입력된 순서를 보장합니다. 그냥 forEach를 돌리면 입력순으로 돌기 때문에 안전합니다.
이제 코드의 줄 수 마다 주석을 걸어 로직을 설명하죠. 그런 후 각 부분을 밑에서 자세히 살펴보겠습니다.

private val sstr = """(\s|:|,|^)(\"((?:\\"|[^"])*(?:[\{\}\[\]])(?:\\"|[^"]|[\{\}\[\]])*)\")""".toRegex()
private val atom = """\s*(\{[^\{\}\[\]]*\}|\[[^\{\}\[\]]*\])\s*""".toRegex(RegexOption.MULTILINE)

//ss map으로부터 문자열을 부활시키기 위해 필요한 정규식
private val sKey = """<!#([0-9]+)#\$>""".toRegex()

//kv map으로부터 문자열을 부환시키기 위해 필요한 정규식
private val atomKey = """<@#([0-9]+)!\$>""".toRegex(RegexOption.MULTILINE)

//json객체의 키부분을 찾는 정규식
private val jsonKey = """^\s*(?:([^:,\s"]+)|"([^"]*)")\s*:""".toRegex(RegexOption.MULTILINE)

//json객체의 값부분을 찾는 정규식
private val regString = """"((?:\\"|[^"])*)""""
private val regDouble = """(-?(?:0|[1-9]\d*)(?:\.\d+)(?:[eE][-+]?\d+)?)"""
private val regLong = """(-?(?:0|[1-9]\d*))"""
private val regBool = "(true|false)"
private val regNull = "(null)"
private val regValue = """^\s*(?:$regString|$regDouble|$regLong|$regBool|$regNull)\s*""".toRegex()

fun parseJSON(txt:String):Value{
  if(txt.isBlank()) return Value.EMPTY
  val ss = mutableMapOf<String, String>()
  var idx = 0
  var v = sstr.replace(txt){
    val k = "<!#${idx++}#$>" 
    ss[k] = it.groupValues[2]
    it.groupValues[1] + k
  }
  val kv = mutableMapOf<String, String>()
  idx = 0
  while(atom.find(v) != null) v = atom.replace(v) {
    val k = "<@#${idx++}!$>"
    kv[k] = it.groupValues[1]
    k
  }
  //여기까지 앞의 내용

  //kv로 부터 실 객체화된 대상을 넣을 map
  val map = mutableMapOf<String, Value>()

  //루프를 돌기 전에 마지막 map의 값을 알기 위해 last를 자유변수로 잡아준다.
  var last:Value = Vnull()

  //kv를 루프돌며 순서대로 처리한다.
  kv.forEach {(k, v)->

    //1. 우선 순수객체, 배열을 구성하는 문자열에 포함된 ss맵의 내용을 치환한다.
    var body = sKey.replace(v.substring(1, v.length - 1)){
      ss[it.groupValues[0]] ?: ""
    }

    //2. 그렇게 복원된 문자열이 {로 시작하는 경우는 객체다!-----------
    last = if(v[0] == '{') {

      //객체이므로 VObject를 생성한다.
      val obj = VObject()
      do{

        //우선 정규식을 이용해 {"key":"v"} 에서 "key": 부분을 발견해야 한다. 없으면 중지
        val findKey = jsonKey.find(body)?.groupValues ?: break

        //이제 키이름을 얻었다.
        val objK = findKey[1]

        //키 부분만큼 body문자열을 잘라낸다.
        body = jsonKey.replaceFirst(body, "")

        //2.1 값을 탐색한다.
        regValue.find(body)?.groups?.let{

          //정규식에 걸린 타입에 따라 
          obj[objK] = it[1]?.run{VString(value.replace("\\\"", "\""))} ?:
                      it[2]?.run{VDouble(value.toDouble())} ?:
                      it[3]?.run{VLong(value.toLong())} ?:
                      it[4]?.run{VBoolean(value.toBoolean())} ?:
                      it[5]?.run{VNull()} ?: VNull()

          //값만큼 또 body문자열을 잘라낸다.
          body = regValue.replaceFirst(body, "")
    
        //2.2 값이 아니라면 map을 탐색한다(이미 객체로 치환된 부분을 대입한다)
        } ?: atomKey.find(body)?.groupValues?.get(0)?.let{
          
          //이미 등장하는 순수객체배열 치환문자열은 map에 객체화된 이후이므로 반드시 map에서 찾을 수 있다.
          obj[objK] = map[it] ?: VNull()

          //해당 문자열만큼 body를 잘라낸다.
          body = atomKey.replaceFirst(body, "")

        //2.3 값도 아니고 map도 아니면 중지
        } ?: break
   
        //2.4 값을 잘라낸 이후에도 뒤에 컴마가 있다면 컴마를 잘라내고 루프를 계속 돈다.
        if(body.isNotBlank() && body[0] == ',') body = body.substring(1) else break
      }while(true)

      //파싱이 완성된 obj를 반환한다.
      obj

    //3. 아니라면 배열이다!---------------------------------------------
    }else{
      
      //우선 배열 객체를 만들자
      val arr = VArray()

      //객체와 달리 값만 탐색하면 된다. 그외엔 동일
      do{
        regValue.find(body)?.groups?.let{
          arr += it[1]?.run{VString(value.replace("\\\"", "\""))} ?:
                 it[2]?.run{VDouble(value.toDouble())} ?:
                 it[3]?.run{VLong(value.toLong())} ?:
                 it[4]?.run{VBoolean(value.toBoolean())} ?:
                 it[5]?.run{VNull()} ?: VNull()
          body = regValue.replaceFirst(body, "")
        } ?: atomKey.find(body)?.groupValues?.get(0)?.let{
          arr += map[it] ?: VNull()
          body = atomKey.replaceFirst(body, "")
        } ?: break
        if(body.isNotBlank() && body[0] == ',') body = body.substring(1) else break
      }while(true)
      arr
    }

    //이렇게 완성된 객체를 map에 다시 넣어준다.
    map[k] = last
  }

  //마지막에 만들어진 객체가 바로 최종 파싱된 객체다!
  return last
}

코드가 좀 많습니다. 차근차근 살펴보도록 하죠(원래 현실세계의 코드가 짧고 간단할리가 없죠 ^^)

4.1 우선 새로 등장하는 정규식들입니다.

//1. ss map으로부터 문자열을 부활시키기 위해 필요한 정규식
private val sKey = """<!#([0-9]+)#\$>""".toRegex()

//2. kv map으로부터 문자열을 부활키기 위해 필요한 정규식
private val atomKey = """<@#([0-9]+)!\$>""".toRegex(RegexOption.MULTILINE)

//3. json객체의 키부분을 찾는 정규식
private val jsonKey = """^\s*(?:([^:,\s"]+)|"([^"]*)")\s*:""".toRegex(RegexOption.MULTILINE)

//4. json객체의 값부분을 찾는 정규식
private val regString = """"((?:\\"|[^"])*)""""
private val regDouble = """(-?(?:0|[1-9]\d*)(?:\.\d+)(?:[eE][-+]?\d+)?)"""
private val regLong = """(-?(?:0|[1-9]\d*))"""
private val regBool = "(true|false)"
private val regNull = "(null)"
private val regValue = """^\s*(?:$regString|$regDouble|$regLong|$regBool|$regNull)\s*""".toRegex()

하나씩 살펴보죠.

<!#([0-9]+)#\$>

1번 정규식은 ‘1번 문자열치환’ 시 치환되었던 문자열을 다시 원문 문자열로 되돌리기 위해 ss맵에 있는 키를 찾기 위한 정규식입니다.

현재 메모리의 상황은 아래와 같은데,

v = "<@#${4}!$>"

//문자열 치환
ss["<!#$0#$>"] = "\"{...[..].}\""
ss["<!#$1#$>"] = "\"[..]\""

//순수 객체배열 치환
kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

kv의 첫 번째부터 객체화하다보면 세 번째 있는

kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""

를 만나게되고 이때

<@#${0}!$>

라는 문자열을 만나면 이를 원문 문자열로 치환해야하기 때문에 필요한 정규식이라고 할 수 있죠.

<@#([0-9]+)!\$>

2번 정규식은 반대로 kv키를 인식하는 정규식입니다. 마찬기자로 kv세번째만 봐도

kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""

가 포함되어 0번째 객체를 가리키고 있습니다.
이를 찾아 치환하기 위한 정규식입니다.

^\s*"([^"]*)"\s*:

3번 정규식은 json객체에서 키부분을 찾는 정규식입니다. json객체의 포멧을 보면 다음과 같은 형태로 되어있죠.

{"키":값, "키":값...}

여기서 {를 제거하고 젤 앞 부분부터 키를 찾는다고 가정하면 최초의 “키”가 등장하는 앞 부분에 콜론,컴마,공백같은 게 없는 상태로 “로 감싸진 문자열과 그 뒤에 콜론이 붙은 것을 찾는 정규식이라 할 수 있습니다.

private val regString = """"((?:\\"|[^"])*)""""
private val regDouble = """(-?(?:0|[1-9]\d*)(?:\.\d+)(?:[eE][-+]?\d+)?)"""
private val regLong = """(-?(?:0|[1-9]\d*))"""
private val regBool = "(true|false)"
private val regNull = "(null)"
private val regValue = """^\s*(?:$regString|$regDouble|$regLong|$regBool|$regNull)\s*""".toRegex()

마지막으로 json의 값을 찾는 정규식입니다.

{"키":값, "키":값...}

형태에서 이미 “키”: 부분까지 전진해서 앞을 다 잘라버렸다면

값, "키":값...}

이렇게 되었을 것입니다. 이 때 값 부분을 인식하는 정규식이죠. 객체와 배열을 문자열로 치환되어있으므로 인식해야할 값은 문자열, 숫자, 불린, 널입니다. 각각을 따로 정의해서 합쳤습니다.
특히 Double이 먼저 해석되어야 Long과 분리할 수 있다는 점이 유의할 점입니다.

이제 정규식을 다 살펴보았으니 대략적인 전략을 도출할 수 있겠죠.

  1. kv를 처음부터 돌면서 {..}인 문자열이라면 객체로 키, 값을 차례대로 인식하며 파싱한다.
  2. [..]인 문자열이면 배열로서 값을 차례대로 인식하며 파싱한다.
  3. 이렇게 kv의 앞부터 객체로 환원하면서 최종적으로 마지막 kv문자열을 파싱하면 그게 최종 산출물이 된다.

결국 kv안에 있는 문자열을 순서대로 map에 옮겨주는 전략이라 할 수 있죠.

4.2 kv를 루프돌며 map 넣어주기

우선 처음에 제시된 코드 중에 kv루프에 돌입하는 부분을 살펴보죠.

//kv로 부터 실 객체화된 대상을 넣을 map
val map = mutableMapOf<String, Value>()

//루프를 돌기 전에 마지막 map의 값을 알기 위해 last를 자유변수로 잡아준다.
var last:Value = Vnull()

//kv를 루프돌며 순서대로 처리한다.
kv.forEach {(k, v)->

  //1. 우선 순수객체, 배열을 구성하는 문자열에 포함된 ss맵의 내용을 치환한다.
  var body = sKey.replace(v.substring(1, v.length - 1)){
    ss[it.groupValues[0]] ?: ""
  }

설명에 나온 그대로 kv를 map에 옮기기 위한 녀석입니다. map은 마지막에 넣은 대상을 찾기 위해 쓸데없이 루프를 돌아야하므로 넣을 때 계속 last를 갱신하게 하여 최종적으로 그 last만 반환하려는 거죠.

이렇게 준비하고 kv루프로 돌입합니다.

kv루프의 첫번째에서 하는 일은 v값에 포함된 문자열 치환을 복원하는 것입니다.
이젠 순수객체, 배열 파싱단계가 끝나있으므로 이 시점에 복원해줘도 괜찮은 것이죠.
이렇게 문자열 치환을 복원한 깨끗한 문자열을 body라고 정의합니다.

우리는 메모리상에 있는 다음의 상태에서

v = "<@#${4}!$>"

//문자열 치환
ss["<!#$0#$>"] = "\"{...[..].}\""
ss["<!#$1#$>"] = "\"[..]\""

//순수 객체배열 치환
kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

이제 kv의 첫번째인

kv["<@#${0}!$>"] = "[1,2,3]"

가 루프에 돌입하게 되고 body는 “[1,2,3]”이 된 것을 알 수 있습니다.

다음 코드는 좀 길지만 문자열이 대괄호([)로 시작한다는 점을 고려해서 크게 살펴보면 다음과 같습니다.

kv.forEach {(k, v)->
  var body = ...

  last = if(v[0] == '{') {//객체인 경우
    //VObject생성 및 반환
  }else{ //배열인 경우
    //VArray생성 및 반환
  }
  map[k] = last
}

즉 “[1,2,3]”의 경우 else항에 들어오게 됩니다. 그럼 이 else 부분만 집중적으로 보죠.

}else{
  //1.우선 배열 객체를 만들자
  val arr = VArray()

  do{
    //2. 값타입인식하기
    regValue.find(body)?.groups?.let{
      arr += it[1]?.run{VString(value.replace("\\\"", "\""))} ?:
             it[2]?.run{VDouble(value.toDouble())} ?:
             it[3]?.run{VLong(value.toLong())} ?:
             it[4]?.run{VBoolean(value.toBoolean())} ?:
             it[5]?.run{VNull()} ?: VNull()
      //그만큼 잘라낸다.
      body = regValue.replaceFirst(body, "")

    //3. kv에 있는 값이라면 map에서 찾아 대체한다.
    } ?: atomKey.find(body)?.groupValues?.get(0)?.let{
      arr += map[it] ?: VNull()

      //여기서도 그만큼 잘라낸다.
      body = atomKey.replaceFirst(body, "")
    } ?: break

    //4. 값만큼 잘라낸뒤 여전히 컴마가 남아있다면 컴마를 잘라내고 루프를 계속한다.
    if(body.isNotBlank() && body[0] == ',') body = body.substring(1) else break
  }while(true)
  arr
}
  1. 처음에 Varray()를 만들고 마지막에 이 arr을 반환하는 것을 알 수 있습니다.
  2. 우선 해당값이 문자열, 숫자, 불린, 널에 해당되면 3번 정규식에 잡히게 되고 그룹으로 캡쳐됩니다. 각 캡쳐그룹해당사항에 따라 그에 맞는 대수타입으로 전환되어 arr에 추가됩니다. 그렇게 처리된 후엔 다시 body에서 해당 부분만큼 잘라내게 됩니다.
  3. 만약 값이 atomkey형태라면 kv가 이미 map 실체화된 객체를 만들어 넣어둔 것입니다. 왜냐면 kv가 순차적으로 처리되고 있기 때문에 앞에 등장한 모든 kv는 map에 실체화되어있습니다. 따라서 뒤에 오는 kv값은 반드시 map에 있는 객체로 대체할 수 있죠. 따라서 map에서 그 키에 해당되는 객체를 꺼내 추가해주면 끝입니다. 여기서도 그만큼 body를 잘라냅니다.
  4. 2, 3번에서 값을 인식하면 인식된 값만 문자열을 잘라내는데, 그렇게 값을 잘라내고 여전히 뒤에 원소가 남아있다면 계속 루프를 돕니다.

이와 같은 과정의 반복으로 [1,2,3]은

  1. 1이 VLong으로 치환되어 arr에 추가되고 body는 “2,3]”이 되고
  2. 2가 VLong으로 치환되어 arr에 추가되고 body는 “3]”이 되고
  3. 3이 VLong으로 치환되어 arr에 추가되어 루프가 종료됩니다.

이렇게 생성된 arr은 kv의 forEach루프 마지막의

map[k] = last

를 통해 map이 들어오므로 이 시점의 메모리 상태는 다음과 같을 것입니다.

v = "<@#${4}!$>"

//문자열 치환
ss["<!#$0#$>"] = "\"{...[..].}\""
ss["<!#$1#$>"] = "\"[..]\""

//순수 객체배열 치환
kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

//map에 실객체 생성!
map["<@#${0}!$>"] = VArray[VLong(1L), VLong(2L), VLong(3L)]

즉 마지막에 있는 map에 kv와 동일한 키로 문자열이 실제 VArray객체가 되어 생성된 것입니다.
kv에 두 번째에 있는

kv["<@#${1}!$>"] = "[3,4]"

도 마찬가지로 루프를 돌고 나면

kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

map["<@#${0}!$>"] = VArray[VLong(1L), VLong(2L), VLong(3L)]
map["<@#${1}!$>"] = VArray[VLong(3L), VLong(4L)]

두 번째 kv도 손쉽게 map에 실객체 되었다는 것을 알 수 있습니다. 세 번째는 좀 다른데 우선 {로 시작하는 객체이므로 앞의 코드에서 if부분을 보겠습니다.

//그렇게 복원된 문자열이 {로 시작하는 경우는 객체
last = if(v[0] == '{') {

  //1. 객체이므로 VObject를 생성한다.
  val obj = VObject()

  do{

    //2. 우선 정규식을 이용해 {"key":"v"} 에서 "key": 부분을 발견해야 한다. 없으면 중지
    val findKey = jsonKey.find(body)?.groupValues ?: break

    //이제 키이름을 얻었다.
    val objK = findKey[1]

    //키 부분만큼 body문자열을 잘라낸다.
    body = jsonKey.replaceFirst(body, "")

    //3. 값을 탐색한다.
    regValue.find(body)?.groups?.let{
      obj[objK] = it[1]?.run{VString(value.replace("\\\"", "\""))} ?:
                  it[2]?.run{VDouble(value.toDouble())} ?:
                  it[3]?.run{VLong(value.toLong())} ?:
                  it[4]?.run{VBoolean(value.toBoolean())} ?:
                  it[5]?.run{VNull()} ?: VNull()
      body = regValue.replaceFirst(body, "")
    
    //4. 값이 아니라면 map을 탐색한다
    } ?: atomKey.find(body)?.groupValues?.get(0)?.let{
      obj[objK] = map[it] ?: VNull()
      body = atomKey.replaceFirst(body, "")

    //5. 값도 아니고 map도 아니면 중지
    } ?: break
   
    //6. 값을 잘라낸 이후에도 뒤에 컴마가 있다면 컴마를 잘라내고 루프를 계속 돈다.
    if(body.isNotBlank() && body[0] == ',') body = body.substring(1) else break
  }while(true)

  //7. 파싱이 완성된 obj를 반환한다.
  obj
}
  1. 우선 객체이므로 VObject를 만들어줍니다.
  2. 객체는 {“키”:값, “키”:값..} 형태로 되어있으므로 처음 키를 찾고 그만큼 문자열을 잘라냅니다. 그 후 body는 앞 부분이 잘리고 값, “키”:”값”…} 형태가 될 것입니다.
  3. 그럼 이제 값부분을 찾아낼 수 있겠죠. 배열 때와 마찬가지로 우선 값타입인지 찾아서 처리하고 body를 값만큼 잘라내어 , “키”:”값”…} 로 만듭니다.
  4. 아니면 map의 키를 찾습니다. map에는 이미 만들어진 실객체가 있으므로 가져와 넣어주면 되고 해당 길이만큼 잘라냅니다.
  5. 이도 저도 아니면 이상한 문자열이라고 간주하고 파싱을 멈춥니다.
  6. , “키”:”값”…} 형태에서 컴마가 있다는 뜻은 뒤로 더 파싱할 내용이 있다는 뜻입니다. 컴마를 잘라 “키”:”값”…}로 만든 뒤 다시 루프를 돕니다.

이 로직을 이용해 현재 메모리에 있는 kv의 세 번째 요소를 객체화 할 수 있습니다.

kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

map["<@#${0}!$>"] = VArray[VLong(1L), VLong(2L), VLong(3L)]
map["<@#${1}!$>"] = VArray[VLong(3L), VLong(4L)]
map["<@#${2}!$>"] = VObject{"c":VLong(1L), "d":map["<@#${0}!$>"]}

마지막에 생성된 세번째 map요소를 보면 “d”항목이 이미 생성된 map의 첫번째 요소를 참조하게 됨을 알 수 있습니다.

이제 마지막까지 차근차근 전개하면 다음과 같이 메모리가 구성될 것입니다.

ss["<!#$0#$>"] = "\"{...[..].}\""
ss["<!#$1#$>"] = "\"[..]\""

kv["<@#${0}!$>"] = "[1,2,3]"
kv["<@#${1}!$>"] = "[3,4]"
kv["<@#${2}!$>"] = """{"c":1, "d":<@#${0}!$>}"""
kv["<@#${3}!$>"] = """[1,2,<@#${1}!$>]"""
kv["<@#${4}!$>"] = """{
 "a":<!#$0#$>,
 "b":"abc",
 "c":<!#$1#$>,
 "d":<@#${2}!$>,
 "e":<@#${3}!$>
}"""

map["<@#${0}!$>"] = VArray[VLong(1L), VLong(2L), VLong(3L)]
map["<@#${1}!$>"] = VArray[VLong(3L), VLong(4L)]
map["<@#${2}!$>"] = VObject{"c":VLong(1L), "d":map["<@#${0}!$>"]}
map["<@#${3}!$>"] = Varray[VLong(1L), VLong(2L), map["<@#${1}!$>"]]
map["<@#${4}!$>"] = VObject{
 "a":VString("{...[..].}"),
 "b":VString("abc"),
 "c":VString("{...[..].}"),
 "d":map["<@#${2}!$>"],
 "e":map["<@#${3}!$>"]
}

문자열 치환용 ss와 kv를 이용해 훌륭하게 map이 완성되었습니다 이제 그 마지막 객체인 map[“<@#${4}!$>”]가 last에 할당되어있으므로 이를 반환하면 최종 파싱된 VObject를 얻게 되는 것이죠.

5. 보완할 점top

실은 json의 경우 숫자, 문자, 불린 등도 정당한 값입니다. 즉 오브젝트나 배열이 아니라도 된다는 것이죠. 이걸 초반에 필터링하는 것이 좋습니다.

fun parseJSON(txt:String):Value{

  //빈 값거름
  if(txt.isBlank()) return Value.EMPTY

  //단순 값의 형태도 미리 처리해버림
  val v = regValue.find(txt)?.groups
  if(v != null){
    return v[1]?.run{VString(value.replace("\\\"", "\""))} ?:
           v[2]?.run{VDouble(value.toDouble())} ?:
           v[3]?.run{VLong(value.toLong())} ?:
           v[4]?.run{VBoolean(value.toBoolean())} ?:
           VNull()
  }
  ...

이렇게 초반에 단순한 값은 즉시 Value로 변환해서 반환할 수 있습니다.

그 외에는 이 파서는 전진 전용 파서로 객체나 배열로 파싱하므로 전체적인 json의 정합성을 판정하지 않고 바르게 작성되어 파싱할 수 있는데까지 파싱하다가 안되면 중지시키는 스타일입니다.
즉 json이 올바른 문서다라는걸 믿고 있다고 볼 수 있죠. 하지만 엄격하게 하려면 제가 부드럽게 VNull() 같은 걸로 퉁친 부분들에 Throw를 걸면 됩니다.
그럼 보다 엄격한 파서가 되겠죠.

6. 전체 코드top

이제 여태 처리한 전체 코드를 봅시다. 특히 반복적으로 나오던 정규식의 값부분 처리는 함수로 정리했습니다.

object JSON{

  private val sstr = """(\s|:|,|^)(\"((?:\\"|[^"])*(?:[\{\}\[\]])(?:\\"|[^"]|[\{\}\[\]])*)\")""".toRegex()
  private val atom = """\s*(\{[^\{\}\[\]]*\}|\[[^\{\}\[\]]*\])\s*""".toRegex(RegexOption.MULTILINE)
  private val sKey = """<!#([0-9]+)#\$>""".toRegex()
  private val atomKey = """<@#([0-9]+)!\$>""".toRegex(RegexOption.MULTILINE)
  private val jsonKey = """^\s*(?:([^:,\s"]+)|"([^"]*)")\s*:""".toRegex(RegexOption.MULTILINE)
  private val regString = """"((?:\\"|[^"])*)""""
  private val regDouble = """(-?(?:0|[1-9]\d*)(?:\.\d+)(?:[eE][-+]?\d+)?)"""
  private val regLong = """(-?(?:0|[1-9]\d*))"""
  private val regBool = "(true|false)"
  private val regNull = "(null)"
  private val regValue = """^\s*(?:$regString|$regDouble|$regLong|$regBool|$regNull)\s*""".toRegex()

  private fun getValue(v:String) = regValue.find(body)?.groups?.let{v->
    v[1]?.run{VString(value.replace("\\\"", "\""))} ?:
    v[2]?.run{VDouble(value.toDouble())} ?:
    v[3]?.run{VLong(value.toLong())} ?:
    v[4]?.run{VBoolean(value.toBoolean())} ?:
    v[5]?.run{VNull()}
  }
  fun parse(txt:String):Value{
    if(txt.isBlank()) return VNull()

    val v = regValue.find(txt)?.groups
    if(v != null) return getValue(v) ?: VNull()

    val ss = mutableMapOf<String, String>()
    var idx = 0
    var v = sstr.replace(txt){
      "<!#${idx++}#$>".run{
        ss[this] = it.groupValues[2]
        it.groupValues[1] + this
      }
    }

    val kv = mutableMapOf<String, String>()
    idx = 0
    while(atom.find(v) != null) v = atom.replace(v){
      "<@#${idx++}!$>".apply{kv[this] = it.groupValues[1]}
    }

    val map = mutableMapOf<String, Value>()
    var last:Value = Vnull()
    kv.forEach {(k, v)->
      var body = sKey.replace(v.substring(1, v.length - 1)){
        ss[it.groupValues[0]] ?: ""
      }
      last = if(v[0] == '{') VObject().apply{
        do{
          val objK = jsonKey.find(body)?.groupValues?.get(1) ?: break
          body = jsonKey.replaceFirst(body, "")
  
          this[objK] = getValue(body)?.also{
            body = regValue.replaceFirst(body, "")
          } ?: map[atomKey.find(body)?.groupValues?.get(0)]?.also{
            body = atomKey.replaceFirst(body, "")
          } ?: break
          if(body.isNotBlank() && body[0] == ',') body = body.substring(1) else break
        }while(true)
      }else VArray().apply{
        do{
          this += getValue(body)?.also{
            body = regValue.replaceFirst(body, "")
          } ?: map[atomKey.find(body)?.groupValues?.get(0)]?.also{
            body = atomKey.replaceFirst(body, "")
          } ?: break
          if(body.isNotBlank() && body[0] == ',') body = body.substring(1) else break
        }while(true)
      }
      map[k] = last
    }
    return last
  }
}

최종 코드에서는 반복되던 값처리부분이 getValue로 빠진 것 외에도 run, also, apply등을 이용해서 훨씬 간략히 표현하고 있습니다.
대수타입에 대한 최종적인 코드는 다음과 같습니다.

interface Value{
  val v:Any
  fun stringify() = "$v"
}

inline class VString(override val v:String):Value{
  override fun stringify() = "\"${v.replace("\"", "\\\"")}\""
}
inline class VLong(override val v:Long):Value
inline class VDouble(override val v:Double):Value
inline class VBoolean(override val v:Boolean):Value
inline class VNull(override val v:Boolean = false):Value

class VObject:Value, MutableMap<String, Value> by mutableMapOf(){
  override val v:MutableMap<String, Value> = this
  
  override fun stringify():String{
    var r = ""
    forEach{(k, v)->r += ""","$k":${v.stringify()}"""}
    return "{${if(r.isNotBlank()) r.substring(1) else ""}}"
  }
}

class VArray:Value, MutableList<eValue> by mutableListOf(){
  override val v:MutableList<eValue> = this
  override fun stringify():String{
    val v= fold(""){r, v->r + ",${v.stringify()}"}
    return "[${if(v.isNotBlank()) v.substring(1) else ""}]"
 }
}

7. 실제 사용하기top

실제 사용 시는 다음과 같이 됩니다.

val json = JSON.parse("""{
 "a":"{...[..].}",
 "b":"abc",
 "c":"[..]",
 "d":{"c":1, "d":[1,2,3]},
 "e":[1,2,[3,4]]
}""") as VObject

우선 안타깝게도 다운캐스팅을 피할 순 없습니다. json파싱의 결과는 VLong, VDouble, VBoolean, VNull, VArray, VObject 중에 하나일 수 있기 때문이죠. 물론 캐스팅안하면 Value지만 그래서야 상세 형의 처리를 할 수 없으니까요. 만약 캐스팅을 강제하고 싶다면 parse함수에 제네릭을 넣고 형이 안맞으면 throw를 날리는 형식으로 개조를 할 수 있을 것입니다. 그러면 다음과 같이 되겠죠.

try{
  val json = JSON.parse<VObject>("""{...}""")
}catch(e:Throwable){}

여튼 거기서 거기니까요 ^^
이제 이렇게 파싱이 끝난 json은 곧장 stringify가 가능할 것입니다.

val json = JSON.parse("""{...}""") as VObject
println(json.stringify()) //json문자열 출력

또한 속성별로 참조도 가능할텐데 유의할 점이 그 안에 직접 값이 들어있는게 아니라 래핑한 대수타입 Value가 들어있으므로 v로 값을 얻어야한다는 것입니다.

val json = JSON.parse("""{...}""") as VObject
println(json["a"].v) //"{...[..].}"

하지만 인라인 클래스이므로 실제 컴파일될때 저 코드는 직접 문자열이 들어있는 것으로 변환되므로 VString에 의한 추가적인 부하는 없습니다.
반대로 json은 map일 뿐이므로 값을 추가하는 것도 가능합니다

json["new"] = VString("newValue")

이 때도 주의할게 그냥 값을 넣는건 불가하고 대수타입으로 감싸서 넣어야한다는 점입니다. 이를 이용하면 오히려 VObject를 구축하여 json문자열을 얻는 방식으로 쓸 수 있습니다.

println( VObject().also{
  it["a"] = VLong(3L)
  it["b"] = VDouble(3.3)
}.stringify()) //{"a":3, "b":3.3} 출력

나아가 훨씬 복잡한 구조의 json을 만들어 출력할 수 있는 dsl이 될 수도 있죠. 예를들어 다음과 같은 중첩된 json구조를 생각해보죠.

{
 "items":[
   {"id":1, "title":"title1"},
   {"id":2, "title":"title2"},
   {"id":3, "title":"title3"}
 ]
}

굉장히 흔하게 볼 수 있는 REST API의 간단한 json출력물입니다. 이를 구현해보면

VObject().also{
  it["items"] = VArray().also{
    it += VObject.also{
      it["id"] = VLong(1L)
      it["title"] = VString("title1")
    }
    it += VObject.also{
      it["id"] = VLong(2L)
      it["title"] = VString("title2")
    }
    it += VObject.also{
      it["id"] = VLong(3L)
      it["title"] = VString("title3")
    }
  }
}.stringify()

이렇게 개념 그대로 손쉽게 얻을 수 있습니다. 값을 넣을 때 매번 대수타입으로 감싸는게 귀찮다면 이를 자동으로 처리해주는 함수를 미리 만들어둘 수 있습니다.

interface Value{
  val v:Any
  fun stringify() = "$v"
  companion object{
    operation fun invoke(v:String) = VString(v)
    operation fun invoke(v:Long) = VLong(v)
    operation fun invoke(v:Double) = VDouble(v)
    operation fun invoke(v:Boolean) = VBoolean(v)
  }
}

이제 위의 생성코드는 아래와 같이 좀더 쉽게 만들어집니다.

VObject().also{
  it["items"] = VArray().also{
    it += VObject.also{
      it["id"] = Value(1L)
      it["title"] = Value("title1")
    }
    it += VObject.also{
      it["id"] = Value(2L)
      it["title"] = Value("title2")
    }
    it += VObject.also{
      it["id"] = Value(3L)
      it["title"] = Value("title3")
    }
  }
}.stringify()

결론top

간단한 JSON 파서 및 stringify 구축을 통해 코틀린 언어의 다양한 면을 살펴봤습니다.
아무래도 언어의 다양한 기능이 제공되다보니 간단한 코드만으로도 강력한 라이브러리를 손쉽게 짤 수 있죠.
(코틀린에 대한 재미가 생기셨을지 반감되셨을지 모르겠습니다만 ^^)