[kotlin] json파서 만들어보기

개요

새로 배우게 된 kotlin에 익숙해질 좋은 방법이 없을까 고민하던 중 제가 늘 하는 방식을 선택하기로 했습니다. 보통

  1. 파서
  2. IO처리기
  3. 동시성 처리기
  4. 그래픽스 처리기

정도 만들어보면서 언어 윤곽을 익히는 편인데 그 1단계인 파서로 적당하면서도 실용적인 JSON파서를 구현해보죠(그렇다고 이 포스팅이 시리즈물은 아닙니다 ^^)

json파서의 원리

json은 중첩된 객체와 배열 구조를 제공하기 때문에 정통파(?)방식에서는 구문분석기가 먼저 돌고 구문트리가 만들어지만 파서가 작동해서 객체로 취합하는 과정으로 만들어집니다.
하지만 그리 거창한 방법을 쓰지 않아도 json문법 자체가 간단하고 변화가 크게 없는 바, 정규식 기반의 스택머신으로 충분히 해결할 수 있습니다. 따라서 정규식으로 토크나이저를 진행하면서 중간중간 {, [를 만날 때 중첩스택을 오가면 충분할 것입니다.
그럼 여기서 핵심이 되는 구성요소를 토크나이저 입장(한 글자씩 전진하는)에서 정리해보죠.

  • , – 컴마는 배열의 원소나 객체의 키를 구분짓는 분리자입니다.
  • { – 중괄호 시작은 새로운 자식 객체 컨텍스트를 형성하는 시점을 제공합니다.
  • [ – 대괄호 시작은 새 배열의 시작이죠.
  • }, ] – 기존 컨텍스트를 종료하고 다시 부모 컨텍스트로 복귀하는 시점입니다.

그 외의 경우는 정규식으로 대응하면 됩니다.

  • “xxxx” : – 객체의 키 부분을 나타냅니다. {“key”:3} 에서 바로 key부분을 인식하기 위한 패턴이죠.
  • “xxxx” – 문자열 값을 나타냅니다. 이 안에는 \n,\t,\r,\b,\f,\” 등의 이스케이프와 \ua3401 같은 유니코드 이스케이프를 포함합니다.
  • 10, 10.5, -5, -2.6, 1.4e+10, -5.3E-5 – 숫자 값을 나타냅니다.
  • true, false – 불린 값을 나타냅니다.

이 외에도 ISO타입의 날짜 지정 문자열을 포함해 몇 가지 확장 규약이 있지만 사뿐히 무시하겠습니다 ^^;

정규식 매핑

앞에서 살펴본 단일 문자 토큰은 직접 글자를 when으로 분리하면 되고 정규식 대응하는 패턴은 정규식을 만들어 else에서 대응하면 될 것입니다. 필요한 정규식은 객체키 처리, 문자열값, 숫자값, 불린값 입니다.
값은 묶어서 한 방에 값으로 퉁치고, 객체키 처리만 분리하면 되겠죠. 이를 위해 정규식 객체를 만들고 간단히 래핑 메소드를 제공하는 용도로 enum class를 사용해죠. enum class는 제한된 인스턴스를 플랫폼 차원에서 만들어주므로 안정적이면서 객체 생성 관리 포인트를 줄일 수 있습니다.

private enum class R(r:String){
  Value("^\s*(?:" +
    """"((?:[^\\"]+|\\["\\bfnrt]|\\u[0-9a-fA-f]{4})*)"|""" + //1-string
    "(-?(?:0|[1-9]\d*)(?:\.\d+)(?:[eE][-+]?\d+)?)|" + //2-double
    "(-?(?:0|[1-9]\d*))|" + //3-long
    "(true|false)|" + //4-boolean
    "null" + //5-null
    ")\s*"),
  Key("""^\s*(?:"([^":]*)")\s*:\s*""");

  private val re: Regex = r.toRegex() //내부정규식객체
  fun match(it: String):MatchResult? = re.find(it) //찾기
  fun cut(it:String):String = re.replaceFirst(it, "") //찾은 만큼 제거
}

이제 R.Value와 R.key를 이용하면 객체의 키를 분리하거나 json의 값을 처리할 수 있을 것입니다.

기본적인 스택머신

토크나이저와 트리형 깊이탐색을 같이 하려면 스택이 계속 문자열을 소비하면서 전진하는 형태로 작성해야만 토크나이저로서 전진하면서도 대상 컨텍스트를 교체할 수 있습니다. 이 스택에는 어떤 정보가 담겨야 할까요.

  • 부모 컨텍스트 – }, ] 등으로 원래 스택으로 복귀하기 위해서는 반드시 부모 컨텍스트를 알아야 합니다.
  • 현재 컨텍스트의 타입 – 현재 진행 중인 컨텍스트의 종류는 오브젝트이거나 배열입니다. 이걸 기억하고 있어야죠.
  • 오브젝트의 키 – 값을 담을 오브젝트의 키를 알아야 합니다
  • 인덱스 – 마찬가지로 배열인 경우는 인덱스를 알고 있어야 하죠.
  • 토크나이징할 문자열 – 계속 문자열을 소비하면서 진행하기 때문에 스택에 문자열을 저장해야 합니다.
    이상의 요구사항을 충족하는 간단한 스택클래스를 작성합니다.
private class Stack(
  val parent:St?, 
  val type:String, 
  str:String, 
  val key:String = "", 
  val idx:Int=0){

  val str:String = str.trim()
  fun getKey(){ //부모 스택을 종합하여 최종적으로 중첩된 키를 생성 ex) a.1.c.0.d 
    var k = if(type == "object") key else idx.toString()
    parent?.let{
      var target = it
      while(target != null){
        k = "${if(target.type == "object") target.key else target.idx.toString()}.$key"
        target = target.parent
      }
      k = key.substring(1)
    }
    return k
  }  
}

매번 받아들이는 문자열을 trim하기 귀찮으므로 Stack이 받아들일 때 trim하도록 장치했습니다. 현재 스택에서 부모 스택을 쭉 순회하며 하나의 key문자열을 만들어내는 getKey라는 메소드를 하나 정의합니다.

파서와 생성공급자

모든 준비가 끝났으니 이제 파서를 만들 차례인데 파서는 순수한 파싱 로직만 갖고 있을 예정이므로 각 키에 대한 처리는 생성공급자가 처리할 것입니다. 생성공급자는 파서로 부터 루프도는 각 객체의 키와 값을 인자로 받아 거기에 맞춰 새로운 객체를 생성할 책임을 지게 됩니다.
이러한 생성 공급자를 간단히 정의하죠.

class Maker{
  val root = mutableMap<String, Any>()
  set(k:String, v:Any){
    val keys = k.split('.')
    var target = root
    for(i in 0..keys.size - 1){
      if(target is Map) target = target[keys[i]] ?: throw Exception("no key:" + keys[i])
      else if(target is List) target = target[keys[i].toInt()] ?: throw Exception("no index:" + keys[i])
      else throw Exception("invalid container")
    }
    target[keys[keys.size -1]] = v
  }
}

약간 어렵게 보이는 이 생성자는 set으로 들어온 계층형 키에 대응하여 값을 넣을 수 있게 되어있습니다.
즉 k = “a.b.0.c”, v = 3 같은 상황을 생각해보죠. 이는 우선

  1. root[“a”] == Map 인 상황이고
  2. root[“a”][“b”] == List인 상황이며
  3. root[“a”][“b”][0] == Map인 상황 하에서
  4. root[“a”][“b”][0][“c”] = 3 을 할당한 것입니다.

즉 1 ~ 3번까지(전체 키 길이에 하나 적게) 대상 객체를 root로부터 찾아 들어가 최종 키에 값을 넣어주는 코드입니다.

이제 스택 루프를 돌면서 생성자에게 파서의 결과를 보고해줄 수 있게 되었습니다.

fun jsonParser(json:String): Map<String, Any>{
  val maker = Maker() //새로운 객체를 만들어내는 생성자
  val stack = mutableListOf(Stack(null, "object", json)) //기초스택
  while(stack.size > 0){ //스택이 없어질 때까지!
    val curr = stack.remove(stack.size - 1) //마지막 스택을 pop!

    when(curr.str[0]){ //첫번째 문자로 토큰을 찾는다.
    //컴마는 현재 컨텍스트를 유지한 채로 인덱스만 증가시킴
    ',' -> stack += Stack(curr.parent, c.type, c.str.substring(1), "", c.idx + 1)

    //새로운 오브젝트의 시작
    '{' ->{
       stack += Stack(curr, "object", curr.str.substring(1)) //현재스택을 부모로 하여 새로운 스택생성
       maker.set(curr.getKey(), mutableMap<String, Any>()) //새로운 오브젝트를 삽입하도록 생성자에게 보고
    }

    //새로운 배열의 시작
    '[' ->{
       stack += Stack(curr, "array", curr.str.substring(1))
       maker.set(curr.getKey(), mutableList<Any>()) //새로운 배열을 삽입하도록 생성자에게 보고
     }

     //닫기일 때는 부모스택으로 돌아감
     '}', ']' -> curr.parent?.let {parent ->  
       val v = curr.str.substring(1) //문자열 하나를 치운 뒤
       if(v.isNotEmpty()){ 여전히 진행할 문자열이 있다면 스택에 추가
         stack += Stack(parent.parent, parent.type, v, parent.key, parent.idx)
       } 
     }

     else ->
       //키: 형태를 만족하는 경우 키를 추가한다.
       R.Key.match(curr.str)?.let { 
         stack += Stack(curr.parent, curr.type, R.Key.cut(c.v), it.groupValues[1]) 
       } ?:
       //값의 각 타입에 맞춰 생성자에게 보고한다.
       R.Value.match(curr.str)?.let {
         val v:Any = 0
         it.groups[1]?.let{v = it.value} ?:
         it.groups[2]?.let{v = it.value.toDouble()} ?:
         it.groups[3]?.let{v = it.value.toLong()} ?:
         it.groups[4]?.let{v = it.value.toBoolean()}
         maker.set(curr.getKey(), v)
         val v = R.Value.cut(c.v)
         if(v.isNotEmpty()) stack += Stack(curr.parent, curr.type, v, curr.key, curr.idx)
       }
       return this
    }
  }
  return maker.root
}

좀 길어 보이지만 간단한 스택루프이므로 큰 어려운 점은 없을 것입니다 ^^; 실제 사용은 아래와 같이 이뤄집니다.

val json = jsonParser("""
{
  "a":3, "b":"abc", "c":[1, true]
}
""")

json["a"]?.let{(it as Long) == 3}
json["b"]?.let{(it as String) == "abc"}
json["c"]?.let{
  if(it as List){
    it[0] == 1
    it[1] == true
  }
}

maker응용

만약 Any타입이 반복되는 결과물이 싫다면 maker만 바꿔주면 보다 정적으로 작동할 것입니다.
실제 정적인 데이터 구조에서는 모든 키를 그대로 복사하는 것이 아니라 특수한 키만 json으로 받는 경우가 대부분입니다. 이런 경우는 다음과 같이 maker를 변경하면 됩니다.

//정적 엔티티객체
class Member{
  var userid = ""
  var nick = ""
  var thumbnail = ""
}

class Maker{
  val member = Member()
  set(k:String, v:Any){
    when(k){
    "data.info.userid"-> member.userid = v as String
    "data.profile.thumb"-> member.thumb = v as String
    "data.profile.nickname"-> member.nick = v as String
    }
  }
}

이 코드에서 maker는 전체적인 키를 다 복사하는 게 아니라 정확히 원하는 키만 찾아 정적 엔티티 구조에 반영한 뒤 최종 member를 결과물로 내놓는 방식을 취하고 있습니다.
물론 이 경우 위에서 작성한 jsonParser가 json문자열 뿐만 아니라 그때 그때 별도의 maker를 받을 수 있도록 개선하는 편이 좋을 것입니다.

결론

kotlin과 친해지기 첫 번째 단계로 가벼운 json파서를 작성해봤습니다.
약간은 베이직처럼 한 가지로만 해석되는 방향의 명쾌한 구문이 맘에 듭니다. 그러면서 반복적으로 코드에 등장하는 구문은 단축표현을 많이 갖고 있어 노가다도 덜하고 무엇보다 재밌고 즐거운 리듬감의 코딩이 가능하네요.

전체 코드는 다음과 같습니다.

private enum class R(r:String){
  Value("^\s*(?:" +
    """"((?:[^\\"]+|\\["\\bfnrt]|\\u[0-9a-fA-f]{4})*)"|""" + //1-string
    "(-?(?:0|[1-9]\d*)(?:\.\d+)(?:[eE][-+]?\d+)?)|" + //2-double
    "(-?(?:0|[1-9]\d*))|" + //3-long
    "(true|false)|" + //4-boolean
    "null" + //5-null
    ")\s*"),
  Key("""^\s*(?:"([^":]*)")\s*:\s*""");
  private val re: Regex = r.toRegex()
  fun match(it: String):MatchResult? = re.find(it)
  fun cut(it:String):String = re.replaceFirst(it, "")
}
private class Stack(val parent:St?, val type:String, str:String, val key:String = "", val idx:Int=0){
  val str:String = str.trim()
  fun getKey(){
    var k = if(type == "object") key else idx.toString()
    parent?.let{
      var target = it
      while(target != null){
        k = "${if(target.type == "object") target.key else target.idx.toString()}.$key"
        target = target.parent
      }
      k = key.substring(1)
    }
    return k
  }  
}
private class Maker{
  val root = mutableMap<String, Any>()
  set(k:String, v:Any){
    val keys = k.split('.')
    var target = root
    for(i in 0..keys.size - 1){
      if(target is Map) target = target[keys[i]] ?: throw Exception("no key:" + keys[i])
      else if(target is List) target = target[keys[i].toInt()] ?: throw Exception("no index:" + keys[i])
      else throw Exception("invalid container")
    }
    target[keys[keys.size -1]] = v
  }
}
fun jsonParser(json:String): Map<String, Any>{
  val maker = Maker()
  val stack = mutableListOf(Stack(null, "object", json))
  while(stack.size > 0){
    val curr = stack.remove(stack.size - 1)
    when(curr.str[0]){
    ',' -> stack += Stack(curr.parent, c.type, c.str.substring(1), "", c.idx + 1)
    '{' ->{
       stack += Stack(curr, "object", curr.str.substring(1))
       maker.set(curr.getKey(), mutableMap<String, Any>())
    }
    '[' ->{
       stack += Stack(curr, "array", curr.str.substring(1))
       maker.set(curr.getKey(), mutableList<Any>())
     }
     '}', ']' -> curr.parent?.let {parent ->  
       val v = curr.str.substring(1)
       if(v.isNotEmpty()){
          stack += Stack(parent.parent, parent.type, v, parent.key, parent.idx)
       } 
     }
     else ->
       R.Key.match(curr.str)?.let { 
         stack += Stack(curr.parent, curr.type, R.Key.cut(c.v), it.groupValues[1]) 
       } ?:
       R.Value.match(curr.str)?.let {
         val v:Any = 0
         it.groups[1]?.let{v = it.value} ?:
         it.groups[2]?.let{v = it.value.toDouble()} ?:
         it.groups[3]?.let{v = it.value.toLong()} ?:
         it.groups[4]?.let{v = it.value.toBoolean()}
         maker.set(curr.getKey(), v)
         val v = R.Value.cut(c.v)
         if(v.isNotEmpty()) stack += Stack(curr.parent, curr.type, v, curr.key, curr.idx)
       }
       return this
    }
  }
  return maker.root
}