[android] okhttp3의 간단한 래퍼구현

개요

거의 업계 표준이다시피 한 okhttp3는 그냥 쓰기엔 번거로운 절차가 많고 레트로핏을 쓰자니 너무 제약사항이 많아서 약간은 귀찮은 존재입니다. 사용편의성을 높이자라는 목표 하에 간편하게 사용할 수 있는 정도만 래핑해 보았습니다.

인터페이스의 정의

우선 간편하게 사용할 인터페이스를 정의해보죠.

interface Http{
    fun url(method:String, url:String):Http
    fun header(key:String, value:String):Http
    fun form(key:String, value:String):Http
    fun json(json:String):Http
    fun file(key: String, filename: String, mine: String, file: ByteArray):Http
    fun send(callback:(ResponseBody?, String?)->Unit)
}

각 용도에 맞게 최소한의 시그니쳐로 정의했습니다. 이 인터페이스에서는 메서드체이닝을 통해 원하는 요청을 만들어낸 후 최종적으로 send를 하게 되면 비동기 callback에 결과가 문자열로 수신되는 형태로 사용됩니다.
callback의 인자가 두 개인 것은
첫 번째 인자에 결과 ResponseBody가 들어오거나, 통신에러가 발생한 경우 null이 들어오고
두 번째 인자에 정상인 경우는 null이 들어오고 통신에러상황에서는 에러메세지를 보내주기 위함입니다.

기초 객체 생성

이제 기본적인 요소를 생성해보죠.

private val okHttpClient = OkHttpClient.Builder()
    .connectTimeout(3, TimeUnit.SECONDS)
    .writeTimeout(10, TimeUnit.SECONDS)
    .readTimeout(5, TimeUnit.SECONDS)
    .build()

private val JSON = MediaType.parse("application/json; charset=utf-8")

okHttpClient를 생성하고 요청이 풀바디 json인 경우에 사용할 미디어타입을 미리 잡아둡니다.
이 둘은 직접 사용되지 않으므로 private로 잡고 기본적으로 싱글톤이면 충분하기 때문에 변수로 선언했습니다.

실 객체의 구현

위에서 정의한 인터페이스와 기초 객체에 맞춰 유틸리티 객체를 생성해줍니다. 헌데 요청 빌드 과정에 동시성 문제가 생길 수 있기 때문에 반드시 요청 시작 시에는 개별 데이터 객체를 만들어줘야 합니다.
외부에서의 직접 생성을 금지하고 함수만 노출할 예정이므로 생성자는 internal로 해서 간단히 구상해보죠.

class HttpData internal constructor(private val method:String, private var request:Request.Builder):Http{
    private var form: FormBody.Builder? = null
    private var json:String? = null
    private var multi: MultipartBody.Builder? = null

우선 여기까지 보면 method와 request.builder를 외부에서 공급 받는다는 것을 알 수 있습니다. 앞서 말씀드린 함수에서 처리할 예정입니다.
그 외에 속성으로는 form형식의 데이터를 추가할 때 사용하는 formBody, 풀바디 json인 경우 json문자열, 파일을 업로드하는 경우의 멀티파트폼으로 구성되어있습니다.
우선 순위는 멀티파트폼 계열이 오면 먼저 반영되며 json이 오면 form은 무시되는 식입니다.

이를 바탕으로 간단히 인터페이스를 구현해보죠.

override fun header(key:String, value:String):Http{
    request = request.addHeader(key, value)
    return this
}
override fun form(key:String, value:String):Http{
    if(form == null) form = FormBody.Builder()
    form?.add(key, value)
    return this
}
override fun json(json:String):Http{
    this.json = json
    return this
}

간단한 것들부터 살펴보면 헤더를 넣어주는 것, form데이터를 넣어주는 것, json데이터를 넣어주는 것입니다.
약간 복잡한 멀티파트를 살펴보면

override fun file(key:String, filename:String, mine:String, file:ByteArray):ChHttp{
    if(multi == null) multi = MultipartBody.Builder().setType(MultipartBody.FORM)
    multi?.addFormDataPart(key, filename, RequestBody.create(MediaType.parse(mine), file))
    return this
}

바이너리 업로드를 위해 필요한 인자를 받아 넣어주고 있습니다.

send처리

이제 최종적으로 send처리만 남았습니다. 비동기와 메인UI쓰레드가 얽혀서 좀 귀찮긴 합니다만 간략히 정리해보죠.
여태 수집한 데이터를 정리하여 최종적으로 request를 확정짓고 비동기 방식으로 호출합니다.

override fun send(callback:(ResponseBody?, String?)->Unit){
    if(method == "POST") multi?.let {multi->
            json?.let {multi.addPart(RequestBody.create(JSON, it))} ?:
                form?.let {multi.addPart(it.build())}
            request = request.post(multi.build())
        } ?:
        json?.let{request = request.post(RequestBody.create(JSON, it))} ?:
        form?.let{request = request.post(it.build())}
    okHttpClient.newCall(request.build()).enqueue(object: Callback{
        override fun onFailure(call: Call, e: IOException){
            callback(null, e.toString())
        }
        override fun onResponse(call: Call, response: Response){
            response.body()?.let{callback(it, null)} ?: callback(null, "body error")
        }
    })
}

post인 경우 멀티파트를 최우선으로 고려하되 json이나 form이 있는 경우라면 파트에 붙여줍니다.
멀티파트가 아닌 경우에는 json을 우선 적용하고 json이 없으면 form을 적용합니다.
모든 준비가 끝나면 드디어 newCall을 통해 요청을 시작하고 callback처리를 해줍니다.

사용하기 위한 함수작성

위에 작성한 클래스는 직접 인스턴스를 만들 수 없게 되어있습니다. 이를 생성하는 간단한 함수를 정의해보죠.

fun http(method:String, url:String):Http = HttpData(method, Request.Builder().url(url))

간단히 HttpData를 생성하고 새로운 Request를 시작해줍니다.

결론

실제 사용은 다음과 같이 하게 될 것입니다.

http("POST", "https://...test.json")
  .form("userid", "hika")
  .form("nick", "hika")
  .send{ body, err ->
    body?.let{
      Log.i("bs", body.string())
    } ?: Log.i("bs", "error:${err!!}")
  }

최종 병합 코드는 다음과 같습니다.

fun httpUtil(method:String, url:String):Http = HttpData(method, Request.Builder().url(url))

interface Http{
  fun url(method:String, url:String):Http
  fun header(key:String, value:String):Http
  fun form(key:String, value:String):Http
  fun json(json:String):Http
  fun file(key: String, filename: String, mine: String, file: ByteArray):Http
  fun send(callback:(ResponseBody?, String?)->Unit)
}

private val okHttpClient = OkHttpClient.Builder()
  .connectTimeout(3, TimeUnit.SECONDS)
  .writeTimeout(10, TimeUnit.SECONDS)
  .readTimeout(5, TimeUnit.SECONDS)
  .build()

private val JSON = MediaType.parse("application/json; charset=utf-8")

class HttpData internal constructor(
  private val method:String, 
  private var request:Request.Builder
):Http{
  private var form:FormBody.Builder? = null
  private var json:String? = null
  private var multi:MultipartBody.Builder? = null
  override fun header(key:String, value:String):Http{
    request = request.addHeader(key, value)
    return this
  }
  override fun form(key:String, value:String):ChHttp{
    if(form == null) form = FormBody.Builder()
    form?.add(key, value)
    return this
  }
  override fun json(json:String):ChHttp{
    this.json = json
    return this
  }
  override fun send(callback:(ResponseBody?, String?)->Unit){
    if(method == "POST") multi?.let {multi->
        json?.let {multi.addPart(RequestBody.create(JSON, it))} ?:
          form?.let {multi.addPart(it.build())}
        request = request.post(multi.build())
      } ?:
      json?.let{request = request.post(RequestBody.create(JSON, it))} ?:
      form?.let{request = request.post(it.build())}
    okHttpClient.newCall(request.build()).enqueue(object:Callback{
      override fun onFailure(call: Call, e: IOException){
        callback(null, e.toString())
      }
      override fun onResponse(call: Call, response: Response){
        response.body()?.let{callback(it, null)} ?: callback(null, "body error")
      }
    })
  }
}