[kotlin] JSONObject로 익혀보는 멤버 확장 함수

개요top

코틀린에는 멤버 확장 함수라는 기능이 있습니다. c#, 스위프트 등의 언어에서 지원되는 기능으로 코틀린에서도 편리하게 사용할 수 있습니다.
멤버 확장 함수는 간단히 말해 원래 정의된 클래스를 확장하거나 변경하지 않고 메소드를 추가하는 기능입니다.
실제로는 컴파일러가 코드를 변형해서 적용해주기 때문에 inline까지 적용한다면 런타임에는 아예 추가 클래스 정의가 없이 코드 상으로만 편리하게 사용할 수 있는 기능이죠.
하지만 코틀린이 제공하는 기본 멤버 확장 함수(let, also, apply, with 등)를 마치 기본 문법처럼 익숙하게 사용할지라도 본인 만의 쓸모 있는 멤버 확장 함수 군을 만드는 감각은 제법 훈련이 필요했습니다.
이번 포스팅에서는 다루기 까다로운 JSONObject를 편리하게 사용할 수 있도록 해주는 멤버 확장 함수를 만들어보며 감각을 익혀보죠.

JSONObjecttop

이 클래스는 안드로이드에서 제공하는 JSON처리용 클래스입니다.

https://developer.android.com/reference/org/json/JSONObject

아마도 구글의 구현체는 아래 구현체를 참고하고 있을 거라 예상합니다만 메소드의 형태는 제법 차이가 있습니다.

https://github.com/stleary/JSON-java

이 JSONObject는 워낙 민감하게 뭐만 하면 throw를 하는 녀석입니다. 아예 이 넘이 등장하면 그 동네를 try catch로 감싸야 하는 게 다반사로 optXXX계열로 바꿔도 상황은 크게 나아지지 않습니다. 그 외에도 사용할 때 불편한 점을 나열하면 다음과 같습니다.

  1. 위에 말한 대로 거의 모든 메소드가 throw한다.
  2. JSONObject나 JSONArray의 루프가 굉장히 불편한다.
  3. 2번에 의해 다른 컬렉션(List, Map)으로 변환하기 어렵다.

이러한 불편함을 개선하고 편리하게 사용할 수 있도록 점진적으로 멤버 확장 함수를 정의해 가겠습니다.

tryNulltop

첫 번째로 만들어볼 함수는 멤버 확장 함수가 아니라 그냥 함수입니다. 코틀린은 ?.let{}이 워낙 편리한 지라 throw를 예외 객체별로 나눠서 처리할 생각이 아니라면 일괄로 null로 만드는 편이 다루기 쉬울 수 있습니다. 그래서 정말 간단한 함수를 하나 정의해보죠.

inline fun <T> tryNull(block:()->T) = try{block()}catch(e:Throwable){null}

이 아무것도 아닌 함수는 block이 예외를 발생시키는 경우 null로 바꿔주는 역할만 수행합니다. 이제 뭔가를 하기 전에 tryNull로 감싼다면 block내부에서 일어나는 일은 정상 처리되거나 예외가 아닌 null이 될 것입니다.
여기서 사용된 inline키워드는 block함수의 몸체를 그대로 코드화하게 되므로 함수 호출에 의한 부하를 제거하고 컴파일 시에는 그냥 코드로 환원시키는 기능을 합니다.

기본 타입값 얻기top

이제 가장 쉬운 getString, getInt 등 기본 타입의 속성 값을 얻는 멤버 확장 함수를 작성해보죠.
기존 JSON 내장 메소드의 문제는 이들이 예외를 던진다는 점인데 이제는 그저 tryNull을 감싸주는 것으로 가볍게 진짜 값 또는 null로 환원시킬 수 있게 됩니다.

fun JSONObject._string(key:String):String? = _try{this.getString(it)}

겸손하게 앞에 _를 붙여 제 멤버 확장 함수임을 표시했습니다 ^^;

위 메소드를 정의했으니 이제 실제로 사용해보죠. JSONObject는 생성자에서 json문자열을 받을 수 있는데 파싱하다 잘못되면 throw합니다. 이 생성 자체가 위험하니 생성 과정도 tryNull로 감싸주겠습니다.

val json = """{"a":"abcd"}"""
tryNull{ JSONObject(json) }?._string("a")?.let{ log(it) }

이제 널 접근 연산자(?.)로 간단히 속성을 얻을 수 있게 되었습니다. 나머지 멤버 확장 함수도 전부 정의해 줍니다.

fun JSONObject._string(key:String):String? = _try{this.getString(it)}
fun JSONObject._int(key:String):Int? = _try{this.getInt(it)}
fun JSONObject._long(key:String):Long? = _try{this.getLong(it)}
fun JSONObject._float(key:String):Float? = _try{this.getDouble(it).toFloat()}
fun JSONObject._double(key:String):Double? = _try{this.getDouble(it)}
fun JSONObject._boolean(key:String):Boolean? = _try{this.getBoolean(it)}
fun JSONObject._object(key:String):JSONObject? = _try{this.getJSONObject(it)}
fun JSONObject._array(key:String):JSONArray? = _try{this.getJSONArray(it)}

재밌는 건 이러한 중간 단계를 이용하여 float도 처리할 수 있다는 것입니다. 보다 확장된 예제로 다음과 같이 사용할 수 있습니다.

val json = """{
  "a":"abc",
  "b":123,
  "c":true,
  "d":{"e":123}
}"""
tryNull{ JSONObject(json) }?.let{
  it._string("a")?.let{ log(it) }
  it._int("b")?.let{ log(it) }
  it._boolean("c")?.let{ log(it) }
  it._object("d")?._int("e")?.let{ log(it) }
}

예제의 마지막 d부분에서 얻어진 값도 JSONObject이므로 마찬가지로 _int함수를 통해 e속성을 읽어낼 수 있게 됩니다.

JSONObject의 forEach 구현하기top

JSONObject의 for문은 날로 쓰려면 다음과 같은 코드가 됩니다.

val json = JSONObject(jsonStr)
json.keys().forEach{k->
  val v = t.get(k)
  ...
}

즉 key리스트를 얻어서 루프 돌면서 다시 값을 얻는 방식이죠. 보통의 Map이라면 forEach{(k, v)->…} 형태로 엔티티 루프가 될 것입니다.
사용하기 편하도록 엔티티 (키, 값) 형태로 콜백을 호출하는 루프로 변경해보죠.

inline fun JSONObject._forEach(block:(key:String, v:Any)->Unit) = tryNull{
    this.keys().forEach{block(it, this.get(it))}
}

마찬가지 요령으로 tryNull을 감싸면 예외는 자동으로 빠져나가게 됩니다. 이 기본이 되는 forEach는 block이 Any타입의 값을 받게 되어 블록 내부에서 형변환을 해줘야 합니다. 보다 제약을 걸어서 루프를 돌리면 어떨까요?

inline fun JSONObject._forString(block:(key:String, v:String)->Unit) = tryNull{
    this.keys().forEach{key->this._string(key)?.let{block(key, it)}
}      

이 구성에서는 처음부터 _string으로 key를 조회했기 때문에 해당 속성이 string인 속성만 골라내어 block을 호출하게 됩니다.
이걸 사용해보죠.

val json = """{
  "a":"abc",
  "b":123,
  "c":true,
  "d":{"e":123}
}"""
tryNull{ JSONObject(json) }?._forString{k, v-> log(k, "=", v) }

이 결과는 자연스럽게 “a = abc” 만 나오게 됩니다. 나머지 키는 string타입이 아니기 때문입니다. 이 개념을 확장하여 기본형에 대한 루프를 만들 수 있습니다.

inline fun JSONObject._forString(block:(key:String, v:String)->Unit) = tryNull{
    this.keys().forEach{key->this._string(key)?.let{block(key, it)}
}
inline fun JSONObject._forInt(block:(key:String, v:Int)->Unit) = tryNull{
    this.keys().forEach{key->this._int(key)?.let{block(key, it)}
}
...

이왕 이럴거면 제네릭으로 뺄 수 있다는 생각도 듭니다만 제네릭으로 형을 빼기에는 공변 경계를 걸기가 애매합니다. 대신 형변환이 null을 반환하게 하여 무시하게 할 수는 있겠죠. 이럴 려면 Any타입을 반환하는 get메소드를 감싼 기본 함수도 필요합니다.

fun JSONObject._get(key:String):Any? = _try{this.get(it)}

이제 제네릭으로 for문을 일반화 시켜보죠.

inline fun<T> JSONObject._for(block:(key:String, v:T)->Unit) = _try{
    this.keys().forEach{key->
        @Suppress("UNCHECKED_CAST")
        (this._get(key) as? T)?.let{block(key, it)}
    }
}

이 구현체는 위험한 형 변환을 하고는 있지만 짜피 as?라 널로 환원됩니다. get으로 얻어낸 값이 원하는 형으로 형변환 가능한 경우만 block이 호출될 것입니다. 이것도 사용해보죠.

val json = """{
  "a":"abc",
  "b":123,
  "c":true,
  "d":{"e":123}
}"""
tryNull{ JSONObject(json) }?._for<Int>{k, v-> log(k, "=", "$v") }

이 경우 결과는 “b=123″이 될 것입니다.

JSONArray의 forEach 구현하기top

실은 JSONArray도 루프를 돌기란 쉽지 않습니다. 날로 루프를 돌려면 다음과 같은 코드가 필요합니다.

val array = JSONArray("[1,2,3,4]")
(0 until array.length()).forEach{
  try{
    val v = this[it]
    ...
  }catch(e:Throwable){}
}

너무 귀찮고 그지 같습니다. JSONObject의 _get처럼 보다 범용화된 JSONArray의 _get을 정의할 필요가 있습니다.

fun JSONArray._get(idx:Int) = tryNull{this[idx]}

이제 안전하게 인덱스의 값을 _get으로 얻을 수 있게 되었으므로 특정 타입으로 반환할 수 있도록 제네릭으로 선언해 루프함수를 만들어주면 됩니다.

inline fun <T> JSONArray._forEach(block:(i:Int, v:T)->Unit) = 
  (0 until this.length()).forEach{idx->
    try{
      @Suppress("UNCHECKED_CAST")
      (this._get(idx) as? T)?.let{block(idx, it)}
    }catch(e:Throwable){}
  }

이걸 이용하면 다음과 같이 사용할 수 있게 개선됩니다.

tryNull{JSONArray("[1,2,true,4]")}?._forEach<Int>{idx, v->
  log("$idx = $v")  
}

// 0-1
// 1-2
// 3-4

위 결과에서 2번 인덱스의 true는 자동으로 걸러지게 됩니다. T타입으로 형 변환하면 null이 되어버려 block을 호출 안 하기 때문입니다.

컬렉션형 변환

이제 마지막으로 연습해볼 주제는 List나 Map으로 변환하는 것입니다. 이미 위에서 for를 정복했으므로 이를 바탕으로 List와 Map을 정복하는 것은 어렵지 않습니다. 직관적이면서 쉬운 JSONArray를 List로 바꿔보죠.

fun<T> JSONArray._toList():List<T>? = tryNull{(0 until this.length()).map{
    @Suppress("UNCHECKED_CAST")
    this[it] as T
}}

이 코드는 의외로 쉽습니다. 짜피 모든 원소가 완전히 T형에 부합해야만 List를 반환하는 구조이므로 중간원소에 대한 평가를 생략하고 하나라도 예외를 발생하면 null을 반환하는 구조로 작성하면 됩니다. 이제 다음과 같이 List로 변환할 수 있게 되었습니다.

val list = tryNull{JSONArray("[1,2,3,4]")}?._toList<Int>()

손쉽게 List로 변환할 수 있게 되었습니다. JSONObject를 Map으로 바꾸는 것은 어떨까요?

우선 JSONObject의 특성 상 키는 반드시 String입니다. 따라서 제네릭으로 받아야 하는 형은 값 부분의 형이겠죠. 이상의 내용을 바탕으로 간단히 구현해보죠.

fun<T> JSONObject._toMap():Map<String, T>? = tryNull{
    val map = mutableMapOf<String, T>()
    this._forEach{key, v->
        @Suppress("UNCHECKED_CAST")
        map[key] = v as T
    }
    map
}

루프를 돌며 빙글빙글 map을 채워서 반환해주면 됩니다. 이 구조는 위의 JSONArray때와 마찬가지로 하나의 원소라도 T형이 아니면 null을 반환하게 됩니다.

val json = """{
  "a":"abc",
  "b":123,
  "c":true,
  "d":{"e":123}
}"""
tryNull{ JSONObject(json) }?._toMap<String>() ?: log("is Null!")

그렇습니다. null입니다. 원소를 전부 String으로 바꿀 수 없기 때문이죠. 반대로 아래에서는 모든 원소가 String이라 성공합니다.

val json = """{
  "a":"a",
  "b":"b",
  "c":"c",
  "d":"d"
}"""
val map = tryNull{ JSONObject(json) }?._toMap<String>()

하지만 이러한 작동을 기본으로 하기에는 JSONObject는 배열과 달리 입체적인 구성을 갖는 경우가 대부분입니다. 따라서 _toMap은 활용 가치가 낮을 것입니다. 차라리 원하는 형만 추출하는 형태의 알고리즘이 바람직할 수 있습니다. 아래와 같이 말이죠.

fun<T> JSONObject._toMap():Map<String, T>? = tryNull{
  val map = mutableMapOf<String, T>()
  this._forEach{key, v->
    @Suppress("UNCHECKED_CAST")
    (v as? T)?.let{map[key] = it}
    map
}

이 코드에서는 원하는 형이 아닌 원소를 부드럽게 지나치게 되므로 원하는 T형의 키, 값만 추출할 수 있게 됩니다.

val json = JSONObject("""{
  "a":"a",
  "b":"b",
  "c":1,
  "d":2
}""")
val strMap = json._toMap<String>()
val intMap = json._toMap<Int>()

위 코드에서 strMap안에는 a:”a”, b:”b”가 들어가고 intMap에는 c:1, d:2가 들어가게 됩니다.

결론top

코틀린의 인라인 및 멤버 확장 함수를 연습해봤습니다. 개인적으로 코틀린에서 매우 좋아하는 피쳐이기도 하고 인라인 활용에 따라 런타임 부하가 거의 생기지 않는 것도 안심되네요.
다양한 케이스에서 멤버 함수 확장을 구성하는 것은 코틀린에서 본격적으로 많이 하게 된 건데 덕분에 스위프트의 익스텐션을 활용하는 제 스타일이나 c#의 확장 메소드를 구성하는 스타일에도 많은 깨달음과 코드의 변화가 생겼습니다.