[kotlin] 풀링객체 만들기

객체 풀링과 동기화문제

풀링은 간단한 스택으로도 구현할 수 있지만 문제는 다중 쓰레드하에서 동기화 문제로 성능이 잘 나오지 않는다는 점입니다. JVM의 경우 1ms사이에도 수백개의 객체를 생성하는 일이 다반사이며 그렇게 느리지도 않습니다. 이것보다 더 빠른 객체 풀링을 쓰려면 많은 고민이 필요합니다.

특히 코틀린 멀티플랫폼 입장에서는 사용할 수 있는 상호배제가 뮤텍스 뿐이라 성능이 더 안나옵니다. 차라리 적당한 각 플랫폼별 구현체를 포함한 라이브러리를 쓰는게 낫습니다. 이에 적합한 라이브러리는 찾아봤는데 코틀린 공식 홈페이지에서도 소개하는 터치랩의 Stately를 사용하게 되었습니다.

이 가벼운 라이브러리는 멀티플랫폼용 인터페이스를 제공하면서 주요 플랫폼에서 싱글쓰레드 접근을 강제하는 간단한 구문을 사용합니다. JVM의 실제 구현을 링크에서 살펴보면 간단하게 싱글쓰레드만 블록을 실행하게 강제하는 코드입니다.

풀링 인터페이스 고민

사실 풀에서 꺼내는 것은 고민할 게 없습니다. 스택에 있으면 꺼내고 아니면 새로 만들면 되니까요. 이를 아래와 같이 표현할 수 있습니다.

abstract class Pool<T>(private val factory:()->T){

  private val pool = mutableListOf<T>()

  fun take():T{
    return pool.removeLastOrNull() ?: factory()
  }
}

하지만 다시 풀에 돌아오는 건 언제일까요? 시간으로 설정할 수도 있고 적절한 카운트로 설정할 수도 있습니다만 어쨌든 강제로 회수하면 실제 사용 중인 객체가 회수될 위험성이 있습니다. 이를 막아줄 방법과 회수 시에 객체를 초기화할 수단도 제공해야 합니다.

우선 직접 개발자가 회수시킬 수 있게 drain메소드를 추가해보죠.

abstract class Pool<T>(private val factory:()->T){

  private val pool = mutableListOf<T>()

  fun take():T{
    return pool.removeLastOrNull() ?: factory()
  }

  fun drain(item:T){
    pool.add(item)
  }
}

위 구현을 바탕으로 일정 카운트만큼 take가 일어나면 자동으로 drain이 일어나는 형태로 개조해 봅니다.

auto drain

먼저 특정 카운트에 도달하면 drain을 일으킬 것이므로 카운트를 기억해야 합니다. 더 나아가 take할 때 어떤 객체를 take해줬는지 기억하고 있어야 다시 drain할 수 있습니다. 이 두 가지를 추가하죠.

abstract class Pool<T>(
  private val autoDrain:Int = -1, //자동 드래인이 일어날 갯수
  private val factory:()->T
){

  private val pool = mutableListOf<T>()
  private val drainPool = mutableListOf<T>() //take시 기억해 둠

  fun take():T{

    //drainPool에 일정 갯수가 차면 자동으로 drain실행
    if(autoDrain > 0 && drainPool.size == autoDrain) drain() 

    val item = pool.removeLastOrNull() ?: factory()

    //외부에 take하기 전에 drainPool에 잡아줌
    drainPool.add(item)

    return item
  }

  fun drain(){
    drainPool.forEach{ //drainPool의 모든 요소를 pool로 옮김
      pool.add(it)
    }
    drainPool.clear() //초기화
  }
}

take할 때 drainPool에 미리 잡아두는 것으로 자동 드래인을 구현할 수 있게 되었습니다. 위 구현에서 만약 autoDrain을 설정하지 않는다면 수동으로 drain을 호출할 때만 객체가 풀에 회수될 것 입니다.

동시성의 문제

헌데 위 구현은 멀티쓰레드에서는 사용할 수 없습니다. 동시성 문제가 해결되지 않았기 때문이죠. 동시성에서 안전한 객체 풀링을 만드는 법은 여러가지가 알려져 있습니다. 핵심은 상호배제구간을 최소화 해야 한다는 것이죠. 저는 이 동시성 전략을 쓰레드별 풀링을 만드는 쪽으로 구현했습니다. 아예 쓰레드별로 풀링을 따로 구성하여 그 쓰레드는 자기 전용 풀링을 사용하는 것으로 상호배제를 안해도 되는 원리입니다.

이 방법의 단점은 그 쓰레드가 더 이상 사용되지 않을 때 객체 풀링도 직접 해제해야 한다는 점입니다. 근데 제 경우 코루틴을 사용하기 때문에 새 쓰레드가 계속 태어나는 식이 아니다보니 큰 문제는 없었습니다.

동시성 격리를 위한 stately-isolate 라이브러리

터치랩에서 개발한 코틀린 멀티플랫폼용 동시성 라이브러리인 stately-isolate를 이용해 간단한 객체 격리구역을 만듭니다. 이 라이브러리의 주소는 아래와 같습니다.

https://github.com/touchlab/Stately

우선 의존성에 다음과 같이 추가합니다.

sourceSets {
  val commonMain by getting{
    dependencies {
      ....
      implementation("co.touchlab:stately-isolate:1.2.1")
    }
 }

그 다음 부터는 사용하려는 객체를 IsolateState{ 대상 }.access{ 대상을 사용할 코드 } 형태로 작성하면 동시성에 안전한 락이 생성됩니다. 이를 이용해 쓰레드별 pooldrainPool을 갖게 Store객체를 내부에 생성하고 이를 쓰레드별로 생성해주는 맵을 갖게 풀링을 개선합니다.

abstract class Pool<T>(
  private val autoDrain:Int = -1, //자동 드래인이 일어날 갯수
  private val factory:()->T
){
  //기존의 모든 내용은 Store로 이동
  class Store<T>(
    private val autoDrain:Int,
    private val factory:()->T
  ){
    private val pool = mutableListOf<T>()
    private val drainPool = mutableListOf<T>()

    fun take():T{
      if(autoDrain > 0 && drainPool.size == autoDrain) drain() 
      val item = pool.removeLastOrNull() ?: factory()
      drainPool.add(item)
      return item
    }
    fun drain(){
      drainPool.forEach{pool.add(it)}
      drainPool.clear()
    }
  }

  private stores = hashMapOf<Any, Store<T>>()

  private fun store(id:Any):Store<T>{

    //쓰레드 아이디에 해당되는 Store가 없을 때만 생성해 줌
    if(id !in stores) IsolateState{stores}.access{ it[id] = Store(autoDrain, factory) }

    return stores[id]!!
  }

  fun take(id:Any):T = store(id).take()
  fun drain(id:Any) = store(id).drain()
}

간단하게 리팩토링된 코드를 보면 기존 풀링에 있던 모든 코드는 Store로 이사했습니다. 풀링은 stores에서 쓰레드 아이디에 맞는 Store를 꺼내거나 없으면 생성해주는 역할만 하는 것이죠.

이 구현에서는 상호배제 구간이 딱 stores에 그 쓰레드용 Store가 없을 때만 발생합니다.

물론 위에서 언급했던 단점처럼 그 쓰레드가 더 이상 사용되지 않는게 확정이라면 직접 drain을 호출해야하는 불편함이 존재합니다만 ^^

재활용하기 전에 객체의 상태를 확인하기

위 구현에서는 임의의 T객체를 생성하고 풀링에 넣고 다시 꺼내는 작업을 반복하는데 현실에서는 이렇지 않습니다.

왜냐면 한 번 생성하여 사용하던 객체를 다시 재사용할 때는 객체의 상태를 초기화하는 작업이 필요합니다.

또한 현재 사용 중인 객체는 자동 드래인을 당하면 안됩니다.

이런 객체의 사정을 반영하기 위해 간단한 인터페이스를 정의합니다.

interface PoolItem{
  fun beforeDrain():Boolean
}

이 인터페이스는 말그대로 드래인 당하기 전에 객체에게 드래인 당할 준비 기간을 갖게 합니다. 특히 불린을 반환하므로 드래인 당하면 안되는 객체는 이때 false를 반환하여 드래인에 저항할 수 있게 합니다.

이를 반영하여 코드를 전반적으로 수정합니다.

abstract class Pool<T:PoolItem>( //PoolItem만 받아들임
  private val autoDrain:Int = -1,
  private val factory:()->T
){
  class Store<T:PoolItem>( //상동
    private val autoDrain:Int,
    private val factory:()->T
  ){
    private val pool = mutableListOf<T>()
    private val drainPool = mutableListOf<T>()

    fun take():T{
      if(autoDrain > 0 && drainPool.size == autoDrain) drain() 
      val item = pool.removeLastOrNull() ?: factory()
      drainPool.add(item)
      return item
    }
    fun drain(){
      var i = drainPool.size
      while(i-- > 0){
        val item = drainPool[i]

        //대상객체의 beforeDrain이 true인 경우에만 pool에 편입함
        if(item.beforeDrain()){
          pool.add(it)
          drainPool.remove(item)
        }
      }
    }
  }
  private stores = hashMapOf<Any, Store<T>>()
  private fun store(id:Any):Store<T>{
    if(id !in stores) IsolateState{stores}.access{ it[id] = Store(autoDrain, factory) }
    return stores[id]!!
  }
  fun take(id:Any):T = store(id).take()
  fun drain(id:Any) = store(id).drain()
}

드래인시 beforeDrain의 반응에 따라 정교하게 제거하거나 유지해야 하므로 역순 인덱스의 while문으로 고친 뒤 참 거짓에 따라 pool에 편입여부를 판정합니다.

자동으로 autoDrain카운트를 확장하기

헌데 좀 빡빡한 상상을 해보죠.

autoDrain을 50으로 잡은 풀링은 50개가 take될 때 자동으로 드래인을 일으킬 것입니다.

이 때 만약 50개가 전부 beforeDrain에서 false를 반환한다면 실제 드래인된 객체는 0개입니다. 즉 서버의 동접자가 많아서 take되어 사용되는 객체가 50개 이상으로 훨씬 많다면 이들이 사용완료되기 전에 계속 take가 일어나기 때문에 자동 드래인이 쓸데 없이 자주 호출되는 셈입니다.

헌데 이 수치를 어떻게 처음 부터 알 수 있을까요?

동접자가 100일 때 적합한 autoDrain카운트가 있고 500일 때 적합한 수치가 있을 것입니다.

그래서 단순하게 이번 드래인 시 저항한 객체가 10개라면 다음에는 autoDrain을 10개 더 늘려서 잡는 방식으로 확장하는 알고리즘을 도입했습니다.

abstract class Pool<T:PoolItem>(
  private val autoDrain:Int = -1,
  private val factory:()->T
){
  class Store<T:PoolItem>(
    private var autoDrain:Int, //자동으로 확장되므로 var로 변경
    private val factory:()->T
  ){
    private val pool = mutableListOf<T>()
    private val drainPool = mutableListOf<T>()

    fun take():T{
      if(autoDrain > 0 && drainPool.size == autoDrain) drain() 
      val item = pool.removeLastOrNull() ?: factory()
      drainPool.add(item)
      return item
    }
    fun drain(){
      var resist = 0 //저항한 갯수
      var i = drainPool.size
      while(i-- > 0){
        val item = drainPool[i]
        if(item.beforeDrain()){
          pool.add(it)
          drainPool.remove(item)
        }else resist++ //저항갯수를 올림
      }
      if(autoDrain > 0 && resist > 0) autoDrain += resist //저항갯수 만큼 올림
    }
  }
  private stores = hashMapOf<Any, Store<T>>()
  private fun store(id:Any):Store<T>{
    if(id !in stores) IsolateState{stores}.access{ it[id] = Store(autoDrain, factory) }
    return stores[id]!!
  }
  fun take(id:Any):T = store(id).take()
  fun drain(id:Any) = store(id).drain()
}

이렇게 resist를 도입함으로서 autoDrain의 크기를 자동으로 늘리게 변경합니다.

결론

객체 풀링의 성능 측정은 객체의 성격에 따라 굉장히 다르게 나타납니다만 6~8배 정도의 속도 향상을 기대할 수 있습니다. 특히 객체 안에 많은 객체를 품고 있을 수록 그 효과는 더욱 강력해집니다. 클라이언트 개발에서는 사실 큰 차이가 없을 수도 있지만 지속적으로 작동되고 있는 서버의 경우는 메모리와 cpu연산을 교환하여 속도 향상을 선택하는 의사결정이라 할 수 있습니다.