쾌락코딩

How to Use Context Correctly in Android(번역)

|

원본 - How to Use Context Correctly in Android

Android Context는 Android에서 가장 중요한 객체 중 하나입니다. Context객체는 application의 현재 상태에 대한 맥락이자 아래와 같은 중요한 책임을 가지고 있습니다.

  • Activity와 Application에 대한 정보 제공
  • database, application 관련 리소스(string, drawable, …), 클래스들, 파일 시스템, shared preference 등등에 대한 접근 권한
  • activity 런칭, 브로드캐스팅, intent 수신 등과 같은 application 레벨 기능 호출

보시다시피 안드로이드의 많은 부분이 Context를 필요로 합니다. 따라서 Context를 잘 못 사용한다면, 메모리 릭으로 이어질 수 도 있습니다. Context에는 크게 두 가지 타입으로 볼 수 있습니다.

Application Context

이 Context는 application의 생명주기에 종속되어있습니다. 즉 application이 죽지 않고 살아있는 한, 사용 가능하다는 뜻이죠. 이 컨텍스트는 application 클래스의 getApplicationContext()함수를 통해 접근할 수 있는 싱글톤 객체입니다. 중요한 점은 UI와 관련된 컨텍스트가 아니라는 점입니다. 따라서 intent를 사용하여 activity를 시작하거나, toast를 보여준다거나, 기타 UI와 관련된 작업을 하신다면 application Context를 사용하시면 안됩니다. 한편 수명이 긴 객체나 쓰레드에서 activity 참조를 가지고 있으면 메모리 누수가 발생할 수 있으니 조심하세요. 이럴 때야 말로 application Context를 사용할 때입니다. 아래는 application context의 기능들입니다.

  • resource value들을 Load
  • service 시작
  • service 바인딩
  • braodcast 전송
  • broadcastReceiver 등록

Activity Context

Activity Context는 명확히 Activity의 생명 주기에 바인딩 되어 있기 때문에 액티비티가 살아있는 한 getContext() 메서드를 통해 접근할 수 있습니다. Activity Context는 오직 UI와 관련된 작업을 할 때 현재 살아있는 Activity에서 접근해야만 합니다. 그 예는 아래와 같습니다.

  • resource value들을 Load
  • 레이아웃 인플레이션(Layout Inflation)
  • 액티비티 시작
  • dialog 또는 toast 보여주기
  • service 시작
  • service 바인딩
  • broadcast 전송
  • braodcastReceiver 등록

위에서 언급한 두 가지 컨텍스트 외에서 getBaseContext()this 를 사용하여 Context에 접근하는 것을 보셨을겁니다. getBaseContext() 는 ContextWrapper의 메서드인데요, ContextWrapper는 단순히 모든 context 호출을 다른 context로 위임하는 프록시 구현체입니다. original Context를 변경하지 않고 동작을 수정하기 위해서 subclassed 될 수 있습니다.

getBaseContext() 를 사용하면 ContextWrapper 클래스에 존재하는 Context를 가져올 수 있습니다.

또한 this는 아시다시피 객체를 참조하는 것인데요, Activity 내의 context가 필요할 때면 언제든지 사용할 수 있습니다. 아래는 Context를 요청하는 자바와 코틀린 코드입니다.

// 액티비티에서 다른 액티비티를 시작하려 한다면, `this`를 넘기세요.
val intent = Intent(this, <YourClassname>::class.java)
startActivity(intent)

//view.getContext() 는 현재 activity view를 참조합니다.
listView.setOnItemClickListener { parent, view, position, id ->
    val myElement = adapter.getItemAtPosition(position)
    val intent = Intent(view.getContext(), <YourClassname>::class.java)
    view.getContext().startActivity(intent)
}
public class ApplicationListAdapter extends RecyclerView.Adapter<
    ApplicationListAdapter.ApplicationListViewHolder> {
    ...

    @NonNull
    @Override
    public ApplicationListAdapter.ApplicationListViewHolder onCreateViewHolder(
        @NonNull ViewGroup parent, int viewType) {
        //the correct context can be inferred from the parent view as 'parent.getContext()' for inflation.
        View view =
            LayoutInflater.from(parent.getContext())
            .inflate(R.layout.list_row_application, parent, false);
        return new ApplicationListViewHolder(view);
    }
    ...

    public class ApplicationListViewHolder extends RecyclerView.ViewHolder {
        private final ImageView mApplication_icon;

        public ApplicationListViewHolder(@NonNull View itemView) {
            super(itemView);

            mApplication_icon = itemView.findViewById(R.id.application_image);
        }

        private void bind(Application application) {
            //Context is needed outside of the onCreateViewHolder() method which is retrieved via 'itemView.getContext()'.
            Glide.with(itemView.getContext())
                    .load(application.getIcon())
                    .placeholder(R.mipmap.ic_launcher)
                    .into(mApplication_icon);
        }
    }
    ...
}

Effective Class Delegation(번역)

|

원본 - Effective Class Delegation

Effective Java book 의 ‘Item 18: 상속보다는 합성을 사용하라‘는 가장 인상 깊은 내용 중 하나입니다. 간단히 말하자면 아래와 같습니다.

상속은 재사용 하고 싶은 코드를 이미 가지고 있는 클래스를 상속함으로써 코드를 재사용하는 인기있는 방법이다. 그러나 이 방법은 에러를 유발하기 쉽다. 하위 클래스가 상위 클래스의 자세한 구현들에 의존하게 되어 캡슐화를 위반하기 때문이다.

Problem statement

아래에 책에 나오는 예제를 그대로 가져왔습니다.

// 잘못된 예 - 상속을 잘못 사용했다!
public class InstrumentedHashSet<E> extends HashSet<E> {
    // 추가된 원소의 수
    int addCount = 0;

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}

HashSet의 하위 클래스는 더해진 원소의 갯수를 추적하는 클래스입니다만, 제대로 동작하지 않습니다. 그 이유는 상위 클래스의 addAll 함수 내부에서 add 함수를 사용하기 때문입니다. addAll에서 count를 증가시키고, add에서 또 다시 count를 증가시키게되어 중복으로 counting하게 됩니다.

val set = InstrumentedHashSet<Int>()
set.addAll(listOf(1, 2, 3, 4, 5))
println(set.addCount) // 10

이를 해결하기 위해 단순히 override한 addAll 함수를 지우면 되지 않을까라고 생각할 수 있습니다. 그렇게 하면 동작은 하겠지만, 상위 클래스의 addAll 함수의 구현이 언제 또 바뀔지 모른다는 점을 고려하면 좋지 않은 방식이겠죠. 또는 count를 했는지를 체크하는 flag를 두는 방식을 고려할 수도 있지만, 생각만 해도 복잡하네요.

Tha Java solution

그럼 어떻게 해야할까요? 책에서 추천하는 방법은 상속이 아니라 합성을 사용하는 것입니다. 클래스를 새로 하나 만들고, HashSet을 상속하는게 아니라 HashSet객체를 가지고 있는 방법이죠.

public class InstrumentedSet<E> implements Set<E> {
    int addCount = 0;

    private final Set<E> set;

    public InstrumentedSet(Set<E> set) { this.set = set; }

    public boolean add(E e) {
        addCount++;
        return set.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c);
    }

    // ...
}

이렇게 바꾸면 사용하는 측에서 Set()을 넘겨주어야 합니다.

val set = InstrumentedSet<Int>(HashSet())
set.addAll(listOf(1, 2, 3, 4, 5))

좋습니다. 하지만 이제는 새로운 문제가 있어요. 우리가 Set 의 모든 인터페이스들을 다 구현해야한다는 것이죠. addaddAll을 구현했지만, Set인터페이스는 12개가 더 넘는 메서드들을 요구합니다🤣. 우리는 이 현상을, 껍데기 메서드가 호출되면 가지고 있는 set 객체의 메서드를 그대로 다시 호출하는 biolerplate라고 부르죠.

Effective Java에서는 이를 해결하기 위해서 중간에 ForwardingSet클래스를 추가하는 방법을 소개합니다. 이 클래스는 InstrumentedSet가 상속받을 수 있구요, 우리가 필요한 딱 두 개의 메서드만 override하면 되도록 나머지 함수들을 구현해 놓았습니다.

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s) { this.s = s; }
    public void clear() { s.clear(); }
    public boolean contains(Object o) { return s.contains(o); }
    public boolean isEmpty() { return s.isEmpty(); }
    /* Lots of more methods... */
}

public class InstrumentedSet<E> extends ForwardingSet<E> {
    int addCount = 0;

    public InstrumentedSet(Set<E> s) { super(s); }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}

아마 이 방법이 Java에서는 최선이 아닐까 생각합니다.

Going Kotlin

그럼 이제는 Kotlin으로 InstrumentedSet 를 구현해볼까요? 클래스 위임을 통해 구현해봅시다. 클래스 위임이란 다른 특정 객체에게 interface를 구현하도록 위임하는 것입니다. 네 맞습니다. 바로 위에서 Java코드로 직접 구현한 것과 같은 일입니다.

class InstrumentedSet<E>(
	private val set: MutableSet<E>
) : MutableSet<E> by set

(java.util.Set 인터페이스와 동일한 코틀린의 MutableSet을 사용합니다.)

이렇게 하여 InstrumentedSetset 프로퍼티를 통해 MutableSet 인터페이스를 구현했습니다. InstrumentedSet의 메서드가 호출될 때면 간단하게도 set 객체의 동일한 메서드가 그대로 호출됩니다. 자바로 만든 FowardingSet를 단 한 줄로 구현한셈이죠. 이 코드를 자바로 디컴파일해서 살펴볼까요.

public final class InstrumentedSet implements Set {
   private final Set set;

   public InstrumentedSet(@NotNull Set set) {
      this.set = set;
   }

   public int getSize() {
      return this.set.size();
   }

   public boolean add(Object element) {
      return this.set.add(element);
   }

   public void clear() {
      this.set.clear();
   }

   // ...
}

이제 우리가 할 일은 addaddAll메서드를 추가된 요소의 갯수를 카운팅 하도록 수정하는 일입니다.

class InstrumentedSet<E>(
        private val set: MutableSet<E> = HashSet()
) : MutableSet<E> by set {
    var addCount = 0

    override fun add(element: E): Boolean {
        addCount++
        return set.add(element)
    }

    override fun addAll(elements: Collection<E>): Boolean {
        addCount += elements.size
        return set.addAll(elements)
    }
}

set 파라미터의 기본값을 HashSet으로 만들었습니다. Client는 필요하다면 MutableSet 구현체를 넘길 수도 있겠지만 굳이 그럴 필요가 없기 때문에 편의상 default value를 설정했습니다.

이게 끝입니다. 우리가 직접 구현하지 않은 MutableSet 메서드들은 모두 set객체로 위임되었습니다. Java코드와 마찬가지로 코드는 예상처럼 잘 동작합니다.

val set = InstrumentedSet<Int>()
set.addAll(listOf(1, 2, 3, 4, 5))
println(set.addCount) // 5

마무리

흥미로운 점이 하나 있는데요, 위임에 의한 구현이라 칭해지는 이 feature가 여러 행사에서 Kotlin lead designer인 Andrey Breslav로 하여금 “worst” feature라고 불렸다고하네요(KotlinConf 2018 closing panel discussion). 몇몇 상황에서는 이런 위임이 상황을 더 복잡하게 만들기도 한다네요. 하지만 간단한 상황에서는 많은 boilerplate코드를 제거할 수 있습니다.

Coroutines Job Structures(번역)

|

원본 - Coroutines Job Structures

코루틴은 다양한 상황에서 사용되기 때문에, 우리가 만든 Job들 사이의 관계를 이해하고 파악하는 것은 중요한 일입니다. 우리가 만든 coroutine Job들을 코루틴 라이브러리가 어떻게 취소시키는지는 이런 관계로 인해 결정됩니다. 이 글에서는 Job 계층을 만드는 예시를 자세히 들여다 보고, 그들이 cancellation에 미치는 영향을 알아보고, 더불어 Supervisor Job을 알아보겠습니다.

Problem

Android에서 coroutine을 사용한다고 가정해봅시다. 아마 우리는 coroutine을 launch하는 view model을 하나 준비하겠죠. 그 코루틴 안에는 또 다른 일을 하는 코루틴들을 launch할 수도 있습니다. 코루틴을 실행하는 방법에는 여러가지가 있습니다.

class MyViewModel(
    repo1: MyRepository1,
    repo2: MyRepository2
): ViewModel {
      fun getData() {
        viewModelScope.launch {
            launch { ... }
            launch { ... }
            repo1.getData()
            repo2.getData()
        }
      }
}

class MyRepository1 {
    val coroutineScope = CoroutineScope(Dispatchers.IO)

    fun getData() {
        coroutineScope.launch {  ...  }
    }
}

class MyRepository2(
    val lifecycleScope: LifecycleCoroutineScope
) {
    fun getData() {
        lifecycleScope.launch(Dispatcers.IO) {  ...  }
    }
}

이 예제에서 view model은 view models scope 에서 코루틴을 launch 합니다. launch 블록을 사용하죠. 또한 두 개의 repository에 의존하고 있는걸 볼 수 있습니다. 첫 번째 repository는 자기 자신 내부에서 scope를 만들며 그 scope에서 coroutine을 launch하고 있네요. 두 번째 repository는 생성자로 주입받은 lifecycle scope에서 coroutine을 launch하고 있습니다.

우리는 서로 서로 관계가 있는 coroutine들을 만들고 있습니다. 그들 중 일부는 독립되어 있기도 하구요. 아무튼 이런 관계는 코루틴 라이브러리가 clean-up을 할 때 혹은 coroutine 내부에서 exception이 발생했을 때 어떻게 worker를 취소 하는지에 영향을 미칩니다.

Coroutine Hierarchy

우리가 IO dispatcher를 포함하는 scope를 가지고 있고, 그 scope에서 3개의 코루틴을 launch한다고 해봅시다.

val scope = CoroutineScope(Dispatchers.IO)

// scope.coroutineContext[Job]

val job1 = scope.launch { ... }

val job2 = scope.launch { ... }

val job3 = scope.launch { ... }

이 예제에는 4개의 서로 다른 job들이 생성되어있습니다. launch의 리턴으로 생성되는 잡 3개와, scope자체의 job 1개죠. scope의 Job에 접근하는 방법은 아래와 같습니다.

scope.coroutineContext[Job]

이 Job은 다른 Job들과 관계를 맺고 있는데요, 이 scope의 Job은 나머지 3개 job의 부모가 됩니다.

image

Job Cancellation

위 코루틴간의 관계는 코루틴 cancellation에 영향을 미칩니다.

val scope = CoroutineScope(Dispatchers.IO)

val job1 = scope.launch {
    while(isActive) {  delay(2000)  }
}

val job2 = scope.launch {
    while(isActive) {  delay(3000)  }
 }

val job3 = scope.launch {
    while(isActive) {  delay(3000)  }
}

delay(1000)

scope.cancel()

각각의 자식 코루틴들은 isActive를 체크하며 각자의 일을 합니다. scope를 취소하는 것은 모든 자식들을 순회하며 그것들을 취소합니다.

Nested Coroutines

코루틴을 중첩하여 사용함으로써 조금 복잡한 구조로 사용하게 될 경우도 있습니다.

val scope = CoroutineScope(Dispatchers.IO)

val job = scope.launch {
    val job1 = launch {
        delay(2000)
    }
    val job2 = launch {
        delay(3000)
    }
    val job3 = launch {
        delay(4000)
    }
}

예제를 보시면, scope에서 하나의 코루틴을 launch했고, 그 코루틴 안에서 세개의 다른 코루틴들을 launch했습니다. 각 코루틴은 몇 초씩 delay합니다. 이 세 코루틴의 Job은 scope의 손자 Job이 됩니다.

image2 한편 Job의 자식이 몇 개인지 print해 볼 수도 있습니다.

scope.coroutineContext[Job]?.children?.count() // 1

위의 코드를 보면 scope의 Job은 단 1개 입니다. scope에서 직접 launch한 코루틴인 것이죠. 그 job은 세개의 Job을 가지고 있습니다.

job.children.count() // 3

Nested Coroutines Cancellation

만약 scope job을 취소하면 그 취소는 아래로 전파되어 모든 자식, 손자 Job들을 취소합니다.

val scope = CoroutineScope(Dispatchers.IO)

val job = scope.launch {
    val job1 = launch {
        delay(2000)
    }
    val job2 = launch {
        delay(3000)
    }
    val job3 = launch {
        delay(4000)
    }
}

scope.cancel()

코루틴을 launch할 때 다른 scope에서 코루틴을 만들 수 있습니다. 이 때 이것의 의미를 제대로 이해하는 것이 중요합니다.

val scope1 = CoroutineScope(Dispatchers.IO)

val job = scope.launch {

    val scope2 = CoroutineScope(Dispatchers.IO)
    scope2.launch {  ... }

    val job1 = launch {  ...  }
    val job2 = launch {  ...  }
    val job3 = launch { ... }
}

scope1.cancel()

scope2에서 launch한 코루틴은 독립적입니다. 이 코루틴은 위 코드의 어떤 코루틴과의 위계 관계가 없습니다. 따라서 scope1의 job을 취소하여도 scope2는 취소되지 않습니다. 독립적인 코루틴이기 때문이죠. scope2에서 생성된 코루틴은 계속 유지되며 상황에 따라서는 memory leak으로 이어지기 때문에 조심해야 합니다.

Coroutine Exceptions

scope 위에서 자식 coroutine들을 만드는 것은 다 이유가 있고 이점이 있습니다. 그 코루틴들 중 하나가 exception을 내뿜으면 scope 안에 정의된 error handler로 에러가 전달됩니다.

val scope = CoroutineScope(
    Dispatchers.IO +
    CoroutineExceptionHandler { _, _, _
        // exception will be given here
    }
)

scope.launch { ... }

scope.launch {
    throw Exception()
}

만약 스코프 위에서 여러개의 코루틴을 생성한다면, 그들 중 어떤 하나의 코루틴이exception을 내뿜으면 다른 모든 코루틴을 취소합니다. 아래의 코드를 보시면 세개의 코루틴이 취소되는 것을 볼 수 있습니다.

val scope = CoroutineScope(
    Dispatchers.IO +
    CoroutineExceptionHandler { _, _ ->
        // exception will be given here
    }
)

scope.launch { ... }   <--- Cancel upon exception

scope.launch {  <--- Cancel upon exception
    throw Exception()
}

scope.launch { ... }  <--- Cancel upon exception

반면 독립적인 코루틴을 생성하여 사용할 경우, 독립적인 코루틴에서 발생한 exception은 자신을 둘러싼 scope가 있다 하더라도 그 scope에 예외를 알리지는 않습니다.

val scope1 = CoroutineScope(
    Dispatchers.IO +
    CoroutineExceptionHandler { _, _, _
        // exception will NOT be given here
    }
)

scope1.launch { ... }

scope1.launch {
    val scope2 = CoroutineScope(Dispatchers.IO)
    scope2.launch {
        throw Exception()
    }
}

위 코드를 보시면 scope1 안에서 scope2라는 스코프를 새로 만들었고, scope2에서 새로운 코루틴을 생성하였습니다. 따라서 이 코루틴은 standalone, 즉 독립적인 코루틴입니다. 이 독립적인 코루틴 내부에서 exception이 발생하여도 socpe1까지 올라가진 않습니다. 이렇게 될 경우 예상치 못한 버그가 발생할 수 있겠지요. 코루틴 취소와 exception 전파를 위해서라도 위계 관계를 가지도록 코루틴을 생성하는 것이 좋습니다.

Supervisor Job

우리는 위에서 하나의 코루틴 내부에서 일어난 에러가 형제 코루틴들을 취소시키는 예제를 보았습니다. 물론 Supervisor Job을 사용하여 이를 제어할 수도 있습니다. Supervisor Job은 자식 코루틴 중 하나가 exception을 던져도 부모 코루틴은 계속해서 동작할 수 있게끔 할 수 있습니다.

val supervisorJob = SupervisorJob()
val scope = CoroutineScope(Dispatchers.IO + supervisorJob)

val job1 = scope.launch {
    while(isActive) {
        delay(2000)
    }
}

val job2 = scope.launch {
    throw Exception()
}

val job3 = scope.launch {
    while(isActive) {
        delay(2000)
    }
}

위 코드에서는 scopeSupervisorJob을 사용하고 있습니다. 그리고 그 scope에서 3개의 코루틴을 실행시켰고 두 번째 코루틴에서는 Exception을 던지고 있습니다. 이 경우에 이전 예제들과는 달리 다른 코루틴들은 영향을 받지 않고 계속 자신의 일을 유지합니다. 또한 코루틴 라이브러리는 supervisorScope도 지원합니다.

Lifecycle Scope

코루틴간의 관계를 더 잘 이해하기 위해 이때까지 배운 지식을 가지고 처음 보았던 예제로 돌아가봅시다.

class MyViewModel(
    repo1: MyRepository1,
    repo2: MyRepository2
): ViewModel {
    fun getData() {
        viewModelScope.launch {
            launch {  ... }
            launch {  ...  }
            repo1.getData()
            repo2.getData()
        }
    }
}

class MyRepository1 {
    val coroutineScope = CoroutineScope(Dispatchers.IO)

    fun getData() {
        coroutineScope.launch {  ...  }
    }
}

class MyRepository2(
    val lifecycleScope: LifecycleCoroutineScope
) {
    fun getData() {
        lifecycleScope.launch(Dispatcers.IO) {  ...  }
    }
}

viewModelScope 는 ViewModel의 extension입니다.

val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(
            JOB_KEY,
            CloseableCoroutineScope(
                SupervisorJob() + Dispatchers.Main
            ))
    }

viewModelScope 는 SupervisorJob을 사용하여 scope를 만듭니다. 이것은 이전에 살펴본 것과 같이 자식 코루틴 중 하나가 error를 던진다 하여도 scope의 Job이 취소되지 않음을 의미합니다.

이 예제에서는 viewmodelScope의 Supervisor Job은 3개의 자식 Job을 가지고 있습니다.

fun getData() {
    viewModelScope.launch {
        launch {  ... } <---- Job 1
        launch {  ...  }  <---- Job 2
        repo1.getData()  <--- Standalone Job
        repo2.getData() <---- Job 3
    }
}

repo1 에 의해 생성된 Job은 viewModelScope와 위계 관계가 없습니다. 따라서 안드로이드 프레임워크에 의해 viewModel의 onDestroy 가 호출될 때, 특별히 repo1 의 코루틴은 직접 취소시켜주어야 합니다(onDetroy되면 viewModelScope만 취소되기 때문).

또한 Job1, Job2, Job3 중 하나가 exception을 내뿜는다해도 부모인 supervisor job을 취소시키진 않습니다.

class MyRepository1 {
    val coroutineScope = CoroutineScope(Dispatchers.IO)

    fun getData() {
        coroutineScope.launch {  ...  }
    }
}

class MyRepository2(val lifecycleScope: LifeCycleScope) {
    fun getData() {
        lifecycleScope.launch(Dispatcers.IO) {  ...  }
    }
}

MyRepository2는 생성자로 lifecycle scope를 주입받았고, 그 scope에서 코루틴을 생성했습니다. 반면 MyRepository1는 클래스 내부에서 스스로 Scope를 새로 생성했습니다. MyRepository2과 같이 코루틴을 생성하는 방식은 코루틴 생명 주기를 자신을 생성한 view model과 동일하게 가져가기 때문에 이점을 가집니다. 조금 응용하자면 lifecycle socpe에서 새로운 scope를 만들어 이를 repo에 주입시키는 방법도 있겠네요.

결론

이 글이 코루틴을 생성하는 방법이 가지는 의미를 이해하는데 도움이 되었으면 좋겠습니다. 코루틴을 생성할 때면 보통 위계 관계가 생성이 되는데요, 부모, 자식, 손자 관계가 생깁니다. 이는 코루틴의 Job이 어떻게 취소되는지를 결정하기 때문에 중요한 부분입니다.

Data Driven Testing with Kotest(번역)

|

원본 - Data Driven Testing with Kotest

미리 정의된 입력&출력 값 Set을 사용하여 동일한 테스트를 반복해서 빠르게 실행하는 것을 data driven testing, table driven testing 또는 table driven property checks라고 부릅니다. 뭐라고 부르던지 Kotest는 이 테스트 패러다임을 훌륭하게 지원하며 이 글에서 그런 테스트를 어떻게 작성하는지를 다루려고 합니다.

두 integer 값 중 큰 값을 반환하는 max 함수 테스트를 생각해보죠. 일반적인 테스트 방식으로는 함수를 여러번 호출할 것입니다.

"maximum of two numbers" {
  Math.max(2, 3) shouldBe 3
  Math.max(0, 0) shouldBe 0
  Math.max(4, -1) shouldBe 4
  Math.max(-2, -1) shouldBe -1
}

잘 동작합니다. 하지만 data inputs 또는 argument가 아주 많을 경우 코드가 매우 길고 장황해질 것입니다. 또한 test logic이 지금 처럼 한 줄이 아니라 더 복잡해진다면 더욱더 보기 싫어지겠죠. Data-driven-testing를 통해 데이터를 테스트 로직에서 꺼낼 수 있습니다. 가독성이 좋아지겠죠.

먼저 꺼낼것은 inputs과 output을 포한한 데이터 set입니다. 이 set은 종종 table이라고 칭하는데요, 이 패러다임이 table driven testing이라고도 불리는 이유입니다. 각각의 data set은 row라고 불립니다.

본격적으로 시작하기에 앞서, kotest-assertions 라이브러리를 추가해야 합니다. maven central에서 최신 버전을 확인할 수 있습니다.

Kotest에는 Data-drvien-testing을 구성하기 위해 두 가지 방법이 존재합니다.

첫 번째 방법

첫 번째로 table 함수를 사용하여 미리 tables을 정의하는 것입니다. header 함수를 사용하여 각 parameter에 이름을 지정할 수 있고, 각 row 함수의 인자로 row data를 넣으면 됩니다.

예를 들자면, 아래는 위에서 작성한 함수를 아래와 같이 작성할 수 있겠네요.

"maximum of two numbers" {
  table(
      headers("a", "b", "max"),
      row(2, 3, 3),
      row(0, 0, 0),
      row(4, -1, 4),
      row(-2, -1, -1)
  ).forAll { a, b, max ->
    Math.max(a, b) shouldBe max
  }
}

우리가 지정한 table은 3가지 arg로 정의되어있음을 확인할 수 있습니다(a, b, max). 그리고 4가지 data row를 정의했죠. 첫 번째, 두 번째 인자는 input(a, b)구요, 마지막 세 번째 인자는 기대하는 결과값(max)입니다.

table을 정의하고 나면 각 row별로 테스트를 진행할 lambda test function을 작성해야합니다. 여기에는 두 가지 옵션이 있는데, forAll 을 사용하여 모든 row들의 input이 통과될 것이라고 명시할 수 있고, forNone 을 사용하여 모든 row들의 input이 실패한다고 명시할 수도 있습니다. 즉 위의 테스트에 forAll를 사용한 경우 shouldBe를 사용하고, forNone을 사용한 경우 shouldNotBe를 사용해야 테스트가 통과됩니다.

이게 전부입니다. 이렇게 하여 4개의 data set을 테스트하는 코드가 완성되었습니다. 지금까지 봐서는, 맨 처음 보았던 예제보다 코드가 더 길어졌을 뿐이라고 생각할 수 있을겁니다. 그렇다면 이제 logic이 좀 더 복잡해서 한 줄이 훌 쩍 넘는 코드를 테스트해봅시다.

"pixel extraction example" {
  table(
      headers("path", "x", "y", "r", "g", "b"),
      row("space.jpg", 1, 2, 255, 0, 0),
      row("space.jpg", 0, 0, 255, 255, 0),
      row("worldcup.jpg", 23, 2, 17, 84, 221),
      row("mountain.jpg", 67, 825, 0, 0, 0),
      row("piano.jpg", 845, 53, 255, 0, 46),
      row("sunshine.jpg", 14, 423, 155, 65, 37)
  ).forAll { path, x, y, r, g, b ->

    // load image from resources
    val image = ImageIO.read(javaClass.getResourceAsStream(path))

    val rgb = image.getRGB(x, y)

    // shift 16 to get red
    rgb shr 16 and 0x000000FF shouldBe r

    // shift 8 to get green
    rgb shr 8 and 0x000000FF shouldBe g

    // just mask to get blue
    rgb and 0x000000FF shouldBe b
  }
}

image에서 pixel 값을 가져오고, 그 pixel의 RGB 값을이 주어진 input과 같은지를 테스트 합니다. 이런 테스트는 입력 값이 중요하죠.

보시는것 처럼 테스트 로직이 길어졌습니다. 물론 이를 다른 함수로 빼내어 간단히 한 줄로 호출할 수도 있겠죠. 하지만 각 row를 반복하는 loop를 작성해야하고, 함수를 호출해야하고, 에러 reporting 코드를 작성해야만합니다. 그렇게 깔끔하게 테스트 코드를 리팩토링 하다보면 결과적으로 위의 코드처럼 데이터가 이끄는 테스팅 형태가 되어버리겠죠. 깔끔하게 하면 할수록 위의 코드처럼 될 것입니다.

error handling

당연히 처음 부터 모든 data set 테스트가 통과하진 않겠죠. 때문에 error handling은 매우 중요합니다. 테스트가 실패할 때 우리는 어떤 row가 테스트에 실패했는지, 그 row들의 매개변수들을 알고 싶어합니다. max 예제로 다시 돌아가서 마지막 row가 실패하는 케이스를 추가해봅시다.

"maximum of two numbers" {
  table(
      headers("a", "b", "max"),
      row(2, 3, 3),
      row(0, 0, 0),
      row(4, -1, 4),
      row(-2, -1, -1),
      row(4, 3, 2)
  ).forAll { a, b, max ->
    Math.max(a, b) shouldBe max
  }
}

max(4,3)은 명백히 2가 아닙니다. 따라서 결과는 fail이겠죠. 이를 실행하면 Kotest의 ouptut은 아래와 같습니다.

java.lang.AssertionError:
Test failed for (a, 4), (b, 3), (max, 2) with error expected: 2 but was: 4

보시다시피 실패한 input들에 대한 정보가 각자의 매칭되는 파라미터 이름과 함께 노출됩니다.

Kotest의 Data-Driven-Testing의 error handling 장점은 AJ Alt의 노고 덕분에 여기서 멈추지 않습니다. kotest는 하나의 row가 실패했다고해서 테스트를 멈추지 않습니다. 하나의 row가 실패하더라도 나머지 row들을 테스트 하죠. 따라서 모든 row에 대해서 테스트를 돌며 실패한 모든 row의 정보를 알 수 있습니다. 아래의 코드를 봅시다. 실패하는 row 두 개가 더 추가되었습니다.

"maximum of two numbers" {
  table(
      headers("a", "b", "max"),
      row(2, 3, 3),
      row(0, 0, 0),
      row(4, -1, 4),
      row(-2, -1, -1),
      row(4, 3, 2),
      row(0, 0, 1),
      row(1, 2, 3)
  ).forAll { a, b, max ->
    Math.max(a, b) shouldBe max
  }
}
The following 3 assertions failed:
1) Test failed for (a, 4), (b, 3), (max, 2) with error expected: 2 but was: 4
 at com.sksamuel.kotest.data.DataDrivenTestingTest$2.invoke(DataDrivenTestingTest.kt:37)
2) Test failed for (a, 0), (b, 0), (max, 1) with error expected: 1 but was: 0
 at com.sksamuel.kotest.data.DataDrivenTestingTest$2.invoke(DataDrivenTestingTest.kt:37)
3) Test failed for (a, 1), (b, 2), (max, 3) with error expected: 3 but was: 2
 at com.sksamuel.kotest.data.DataDrivenTestingTest$2.invoke(DataDrivenTestingTest.kt:37)

모든 실패 케이스들이 다 노출되는 것을 볼 수 있습니다.

두 번째 방법

Kotest의 또 다른 특징 중 하나는 lambda 정의에 사용되는 변수의 이름을 추론할 수 있다는 점입니다. data-driven-testing을 하기 위한 두 번째 방법이죠.

max 테스트를 다시 봅시다. 아래와 같이 다시 작성할 수 있수 있을 것 같네요.

"maximum of two numbers" {
  forall(
      row(2, 3, 3),
      row(0, 0, 0),
      row(4, -1, 4),
      row(-2, -1, -1)
  ) { a, b, max ->
    Math.max(a, b) shouldBe max
  }
}

table 함수 대신 forall 함수로 시작했으며 인자로는 곧바로 row를 넘겼습니다(header 정의는 없습니다). Kotest는 람다에 부여된 파라미터 이름을 이용하여 각 row의 인자 값들의 이름을 추론할 수 있습니다. 따라서 람다 파라미터에 a, b, max 라는 이름을 부여해줍니다.

두 번째 방식이 첫 번째 방식보다 덜 장황하고 중복이 없기 때문에 보통 더 선호되는 방식입니다. 개인적으로도 더 우아한 방법이라 생각됩니다.

에러를 다루는 방법은 이전과 동일합니다. 극단적인 예시를 보자면 아래와 같습니다.

"contrived example of parameter name inference" {
  forall(
      row(1, 2, 3, 4, 5, 6, 7, 8, 9)
  ) { foo1, foo2, foo3, foo4, foo5, foo6, foo7, foo8, foo9 ->
    foo1 shouldBe 0
  }
}

위 코드가 실패하면 에로 로그는 아래와 같이 나타납니다.

java.lang.AssertionError:
Test failed for (foo1, 1), (foo2, 2), (foo3, 3), (foo4, 4), (foo5, 5), (foo6, 6), (foo7, 7), (foo8, 8), (foo9, 9) with error expected: 0 but was: 1

마무리

개인적으로 data-driven-test를 참 좋아합니다. 한번 test logic을 작성해 놓으면 다른 코드는 필요 없이 아주 쉽고 빠르게 coverage를 추가할 수 있기 때문입니다.

여기 GitHub에서 Kotest 프레임워크를 사용하기에 충분한 이유가 되는 다른 기능들을 살펴볼 수 있습니다.

Kotlin Inline class

|

비즈니스 로직을 작성하다 보면 어떤 타입의 Wrapper를 작성할 때가 있다. 예를 들어 userName을 표현할 때 단순히 String으로 나타낼 수 있겠지만, 좀 더 도메인적인 의미를 나타내 주고 싶을때는 UserName 타입으로 표현할 수 있다.

// primitive type만 사용할 경우
data class person(
	val userName: String,
	val password: String,
)

// 도메인에 특화된 타입을 만들어서 사용하는 경우
data class person(
	val userName: UserName,
	val password: Password,
)
data class UserName(val value: String)
data class Password(val value: String)

그러나 이 Wrapper는 추가적인 Heap영역에 할당되므로 런타임 오버헤드가 발생한다. 특히 Wrapping의 대상이 되는 타입이 primitive라면 런타임 성능에 더 악영향을 미치는데, primitive 타입은 보통 런타임에 굉장이 최적화되어 있는 반면 primitive를 wrapping하는 순간 더이상 그 최적화가 의미 없어지기 때문이다.

이 문제를 해결하기 위해 코틀린에서는 inline class 를 제공한다. inline class는 생성자로 단 하나의 값만 받을 수 있다. 물론 클래스 내에 프로퍼티와 함수를 정의할 수도 있다.

inline class UserName(val value: String)
inline class Password(val value: String) {
	fun isValid() = value.isNotEmpty()
}

inline class 객체는 런타임에 wrapper로써 표현될 수도 있고 wrapping되기 전의 타입으로 표현될 수도 있다. 마치 Int클래스가 primitive type인 int 로 표현되기도 하고 Wrapper인 Integer 클래스로 표현되기도 하는 것과 같다.

코틀린 컴파일러는 성능상의 이유로 wrapper보단 wrapping되기 전의 타입을 더 선호한다. 그러나 때로는 wrapper를 유지해야 할 때가 있기 마련이다. 보통 인라인 클래스를 선언해놓되 다른 타입으로 사용될 때 Wrapper로 유지되곤 한다. 공식문서의 코드를 보면 이해가 수월하다.

interface I

inline class Foo(val i: Int) : I

fun asInline(f: Foo) {}
fun <T> asGeneric(x: T) {}
fun asInterface(i: I) {}
fun asNullable(i: Foo?) {}

fun <T> id(x: T): T = x

fun main() {
    val f = Foo(42)

    asInline(f)    // unboxed: used as Foo itself
    asGeneric(f)   // boxed: used as generic type T
    asInterface(f) // boxed: used as type I
    asNullable(f)  // boxed: used as Foo?, which is different from Foo

    // below, 'f' first is boxed (while being passed to 'id') and then unboxed (when returned from 'id')
    // In the end, 'c' contains unboxed representation (just '42'), as 'f'
    val c = id(f)
}

Inline classes vs type aliases

Inline class와 Type aliase는 타입에 대해 새로운 이름을 부여하고, 런타임에 원래 타입으로 사용된다는 공통점이 있다.

그러나 차이는 존재한다. type aliase는 기존 타입과 Aliase 타입의 호환성이 보장되는 반면 Inline class는 기존 타입과 Inline class를 명확히 구분짓기 때문에 호환되지 않는다. 즉 inline class는 완전히 새로운 type을 만드는 거라고 볼 수 있고, type aliase는 그저 원래 있던 타입에 별칭을 붙여주는 것 뿐이다.

공식문서에 나와있는 예제를 보면 이해하기 수월하다.

typealias NameTypeAlias = String
inline class NameInlineClass(val s: String)

fun acceptString(s: String) {}
fun acceptNameTypeAlias(n: NameTypeAlias) {}
fun acceptNameInlineClass(p: NameInlineClass) {}

fun main() {
    val nameAlias: NameTypeAlias = ""
    val nameInlineClass: NameInlineClass = NameInlineClass("")
    val string: String = ""

    acceptString(nameAlias) // OK: alias 타입을 기존 타입에 넘겨주어도 된다.
    acceptString(nameInlineClass) // Not OK: Inline class를 String타입으로 넣을 수 없다. NameInlineClass 타입은 String타입이 아니기 때문.

    // And vice versa:
    acceptNameTypeAlias(string) // OK: String타입을 Alias타입으로 넣어도 된다. NameTypeAlias는 단지 String의 다른 이름일 뿐이기 때문이다.
    acceptNameInlineClass(string) // Not OK: String과 NameInlineClass는 다른 타입이기 때문에 불가능하다.
}

참고자료