쾌락코딩

RxBinding을 사용하여 안드로이드 버튼 중복 클릭 방지하기

|

버튼을 터치시 발생하는 이벤트를 처리할 때, 대부분의 경우 중복 터치 이벤트를 막아야 한다. 예를 들어 송금 버튼이 있을 경우 사용자가 아주 빠른시간에 연속해서 두번 터치한다고 해서 돈이 두 번이나 빠져나가서는 곤란하다. RxBinding을 사용하면 쉽게 중복터치를 차단할 수 있다(꼭 중복 터치 뿐만 아니라 RxJava의 훌륭한 메서드들을 안드로이드 UI에 적용할 수 있으니 꽤나 유용하다).

debounce() VS throttleFirst()

중복 터치 이벤트를 막기 위해서는 debounce()throttleFirst메서드를 사용할 수 있다. 둘다 중복 터치를 차단 할 수는 있지만 미세한 차이가 있기 때문에 자세히 알아보자.

debounce()

debounce

debounce()는 특정 시간(예를들어 1초)을 정해주면, 특정 이벤트가 발생한 직후부터 1초 동안은 어떤 이벤트도 발생하지 않아야만 1초 전에 발생한 이벤트가 실행된다. 만약 사용자가 처음 버튼을 클릭한 이후 0.5초 이후에 또 버튼을 클릭했다면, 처음 버튼을 눌렀던 이벤트는 없었던 일이 되어버리고 0.5초 뒤에 누른 두 번째 버튼 이벤트를 다시 1초동안 감시한다. 두 번째 누른 버튼 이벤트 이후 1초 동안 아무 이벤트도 없었다면 그때서야 두 번째 버튼 이벤트만 발생한다.

throttleFirst()

throttleFirst

throttleFirst() 역시 특정 시간(예를들어 1초)을 정해주어야 한다. debounce()와는 다르게 특정 이벤트가 발생한 직후 곧바로 이벤트가 발생하고 이후 1초 동안은 어떤 이벤트가 들어와도 무시된다. 즉 사용자가 “송금” 버튼을 한 번 클릭한 이후 0.5초 뒤에 한 번 더 클릭했다면 두 번째 클릭은 무시된다. 주의해야할 점은 사용자가 처음 “송금” 버튼을 누르고 나서 1.1초뒤에 한 번더 클릭 할 경우에는 이벤트가 두 번 작동한다는 것이다. 이 부분은 바로 아래에서 조금더 다뤄보자.

무엇이 더 적합할까?

위에서 debounce()throttleFirst()를 알아 보았다. 둘다 간단하게 중복 터치를 막을 수 있는데, 뭐가더 좋을까?

사실 정답은 없다. 정해진 답은 없지만 처해진 상황에 따라 더 적합한 메서드를 선택해야 한다.

“송금” 버튼을 클릭한 이후 서버로 부터 응답을 받아오는 시간이 너무 오래걸린다(3초)고 가정해보자. “송금”버튼을 누르고 서버로부터 응답을 받고 나면 화면이 전환되어야 하는데 사용자에게 3초라는 시간은 너무 긴 시간이다. “버튼눌렀는데 왜 안넘어가지? 버튼이 안눌렸나? 다시눌러야지” 라며 2초 후에 또 버튼을 누를 것이다.

위의 상황에서 debounce()에 시간을 3초로 걸어놓았을 경우를 생각해 보자. 사용자가 성격이 너무 급해서 2초 마다 버튼을 클릭하며 “아 왜이렇게 느린거야!?”라고 소리칠 수도 있다. 이 사용자가 2초 마다 버튼을 계속 클릭한다면 절대 다음 화면으로 넘어갈 수 없다. 언젠가는 이 사용자가 버튼 클릭 이후 3초를 기다리길 바랄 수 밖에 없다.

이번엔 throttle()에 시간을 3초로 걸어놓았을 경우를 생각해보자. 성격 급한 사용자가 처음 버튼 클릭 후 2초뒤에 또 버튼을 클릭해도 그 이벤트는 무시된다. 즉 맨 처음 누른 그 시점에만 곧 바로 이벤트가 적용되어 서버로 요청이 날라갔고, 2초 뒤에 누른 이벤트는 무시된다. 대부분 경우 버튼 중복 터치 이벤트 방지는 throttle()이 좋은 선택이 될 수가 있지만, 서버로 부터의 응답이 조금 늦어져서 3.2초가 걸릴 경우 3.1초에 클릭한 중복 터치 이벤트는 무시하지 못해서 송금이 될 것이다. 따라서 버튼 클릭 이후 해당 버튼이 없어지거나 화면이 바뀐다면 throttle()의 시간을 넉넉히 잡아주는 것이 좋아보인다.

사실은…

사실 debounce()는 버튼 중복 클릭 이벤트 방지에도 쓰이긴 하지만, 더 적합한 곳은 주소 검색시 자동완성 기능 같은 곳에 적합한것 같다. 나는 최근에 안드로이드에서 카카오 API를 사용하여 주소 입력시 관련 주소들을 나열해주는 자동완성 기능을 개발했는데, 이때 debounce를 사용해서 효율성을 높혔다. 내가 찾고 싶은 것은 판교역인데, ㅍ,파,판 … 을 입력 할 때마다 카카오에 쿼리를 날려서 데이터 낭비가 심한 상황이 생겼다. 이때 적당히 debounce를 준다면 매번 쿼리를 날리지 않게 되고, 사용자가 입력을 하다가 아주 잠시 멈추는 순간에 추천 검색어가 뜨게 된다.

코틀린 프로퍼티(kotlin property)

|

코틀린은 프로퍼티를 언어 기본 기능으로 제공한다. 프로퍼티란 무엇일까? 프로퍼티란 필드와 접근자를 통칭하는 것이다. 일반적인 자바빈 클래스인 Person을 보면서 정확히 알아보자.

public class Person {
    private final String name;
    private boolean isMarried;

    public Person(String name, boolean isMarried) {
        this.name = name;
        this.isMarried = isMarried;
    }

    public String getName() {
        return this.name;
    }

    public void setIsMarried(boolean isMarried) {
        this.isMarried = isMarried;
    }

    public boolean getIsMarried() {
        return this.isMarried;
    }
}

자바에서는 데이터를 필드(field)에 저장한다. nameisMarried라는 데이터를 Person클래스의 필드에 저장한 것이다. 한편 각 데이터마다 적용되는 getter와 setter를 접근자라 부른다. 이 접근자를 통해서 가시성이 private인 데이터들에 접근할 수 있다.

위의 자바 코드에서 Person클래스 필드에 들어가는 데이터들이 점점 증가한다면 getter와 setter같은 보일러플레이트 코드가 지저분하게 많아진다. 코틀린에서는 위의 Person클래스를 간단하게 정의할 수 있다.

class Person(val name: String, var isMarried: Boolean)

이 한줄의 코틀린 코드는 21줄의 자바 코드와 완전히 동일한 코드다.

자세히보면 자바코드에서 setter를 제공하지 않는 nameval로 선언하였고, getter와 setter모두 제공하는(조회화 변경을 모두 허용하는) isMarried는 var로 선언했다. 익히 알고있듯이 val은 불변, var은 가변 데이터를 선언할 때 사용한다. 이와 같은 맥락으로 val로 선언한 name은 setter가 생성되지 않는다. 이 부분을 코드로 보자면 아래와 같다.

class Person {
    val name: Int
        get() {
            return this.age
        }

    var isMarried: Boolean
        get() {
            return this.isMarried
        }
        set(isMarried: Boolean) {
            this.isMarried = isMarried
        }
}

val로 선언한 name의 경우 setter가 없다. setter를 만들고 싶어도 val은 불변이기에 만들 수 없다(억지로 만드려고 하면 컴파일에러가 뜬다). 참고로 위에서 get()set()을 정해준 것은 커스텀 접근자이다. 기본적으로는 코틀린 클래스를 만들 때 생성자에 넣어준 데이터들에 대하여 get()과 set()이 숨겨져 있으나, 위의 코드처럼 명시적으로 적어줄 수 있다. 그말은 getter와 setter를 커스텀 할 수도 있다는 뜻이다.

생성자에 val, var의 유무 차이

class Person(val name: String) // 1

class Person(name: String) //2

이 둘의 차이는 무엇일까? 먼저 생성자에 val(또는 var)이 있는 경우 멤버변수로 변환된다. 즉 class Person(val name: String)의 경우 아래 자바 코드로 변환된다.

public final class Person {
   @NotNull
   private final String name;

   @NotNull
   public final String getName() {
      return this.name;
   }

   public Person(@NotNull String name) {
      Intrinsics.checkParameterIsNotNull(name, "name");
      super();
      this.name = name;
   }
}

반면 class Person(name: String)의 경우 아래의 자바 코드와 같다.

public final class Person {
   public Person(@NotNull String name) {
      Intrinsics.checkParameterIsNotNull(name, "name");
      super();
   }
}

코틀린 클래스 생성자에 val이나 var이 없는 경우에는 주 생성자의 파라미터들은 딱 생성자(init {...}) 또는 프로퍼티를 초기화 하는 식에서만 사용 가능하다. 따라서 클래스의 생성자 외 다른 메서드에서는 사용할 수 없다(프로퍼티가 되지 못했기 때문).

class Person(name: String) {
    init {
        println(name)
    }
}

위 코드는 아래의 자바 코드로 변환된다.

public final class Person {
   public Person(@NotNull String name) {
      Intrinsics.checkParameterIsNotNull(name, "name");
      super();
      System.out.println(name);
   }
}

주의할 점

디컴파일한 자바 코드에서 필드가 private이라고 하여 코틀린의 프로퍼티도 private은 아니다. 이게 무슨말인가 싶겠지만, 필드와 프로퍼티를 다르게 인식할 줄 알아야 한다. 자바는 기본적으로 필드로 다루고, 코틀린은 프로퍼티(필드 + 접근자)를 기본으로 다루는 언어다.

class Person(var name: String)

위의 코드는 아래의 자바코드가 된다.

public class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public Void setName(String value) {
        this.name = value;
    }

    public String getName() {
        return this.name;
    }

}

자바 필드인 name자체만 보면 private 키워드가 붙어있으므로 private이 맞지만, 프로퍼티 전체를 보면 다르다. 필드는 private이지만 getter와 setter로 접근이 모두 가능하기 때문에 프로퍼티는 private하다고 볼 수 없다. 위의 코드에서 name 프로퍼티가 private이기 위해서는 아래와 같은 코틀린 코드가 필요하다.

class Person(private var name: String)

name 앞에 private이 붙었다. private이 붙지 않은 상태였어도 디컴파일한 자바 코드의 필드에는 private이 붙지만, 코틀린은 기본적으로 필드가 아닌 프로퍼티를 다루기 때문에 프로퍼티 전체가 private이 된다. 디컴파일된 자바 코드는 아래와 같다.

public final class Property {
   private String name;

   public Property(@NotNull String name) {
      Intrinsics.checkParameterIsNotNull(name, "name");
      super();
      this.name = name;
   }
}

getter와 setter가 없어서 프로퍼티가 private이라고 볼 수 있다.

핵심

  • 자바는 필드를 기본으로, 코틀린은 프로퍼티를 기본으로 다룬다.
  • 디컴파일된 자바 코드의 필드가 private이라고해서 kotlin 프로퍼티가 private인 것은 아니다.

언제 takeIf()와 takeUnless()를 쓸까?

|

takeIf()takeUnless()는 자주 쓰이는 함수는 아니지만 코드를 더 읽기 쉽게 하는 좋은 함수다. 그러나 내게는 종종 이 함수들의 잘 못된 예시 코드들이 보인다. 이 글에서는 이 함수들이 무엇이고, 어떻게 제대로 사용하는지에 대해 살펴볼 것이다.

takeIf()takeUnless()의 기능

간단히 말하자면, takeIf()는 null이 아닌 객체에서 호출될 수 있고, predicate(Boolean을 리턴하는)함수를 인자로 받는다. 만약 predicate가 true를 반환하는 식이라면, takeIf()는 null아닌 그 객체를 리턴할 것이고, predicate가 만족되지 않아 false를 반환한다면 null을 리턴할 것이다.

아래의 함수를

return if(x.isValid()) x else null

아래처럼 사용할 수 있다.

return x.takeIf { it.isValid() }

takeUnless()는 반대다. predicate함수가 false를 반환하면 null이 아닌 객체를 리턴하고, true를 반환하면 null을 반환한다.

아래의 함수를

return if(!x.isError()) x else null

아래처럼 사용할 수 있다.

return x.takeUnless { it.isError }

위의 두 함수는 모두 Kotlin Standard Library 1.1 부터 존재하던 것이다. 코드 원형은 아래에 기재했지만, 실제로는 어노테이션이나 contract specification같은 것들이 더 존재한다. 이 글에서는 함수의 역할과 사용법에 집중하기 위해 간편화 했다.

// In Standard.kt

public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? {
    return if (predicate(this)) this else null
}

public inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? {
    return if (!predicate(this)) this else null
}

takeIf()takeUnless()가 독이 되는 경우

겉으로보면 if(someCondition) x else null 이라는 코드는 x.takeIf { someCondition }으로 대체되고, if(!someCondition) x else nullx.takeUnless { someCondition }으로 대체되는 것 같다. 하지만 여기에는 미세하게 차이가 있음을 알아야한다.

차이점 1 : 연산의 순서

아래의 코드를 보자

return if(x.isValid()) doWorkWith(x) else null

위의 코드를 아래와 같이 쓰고싶을 것이다.

return doWorkWith(x).takeIf { x.isValid() }

그러나 바로위의 코드를 실행 할 경우 에러가 난다. 왜일까? 그 이유는 doWorkWith(x) 함수가 predicate 평가 시점 이전에 호출되기 때문이다. x가 유효한지 유효하지 않은지를 판단하기도 전에 doWorkWith(x)가 호출되는 셈이다. 만약 doWorkWith()가 오직 유효한 입력값에서만 동작하는 함수라면 에러가 난다.

차이점 2 : 초과 연산

if/else절에서는 x가 유효하지 않을 경우 따로 연산을 하지 않는다. else절에 가지 않기 때문이다. 반면 takeIf()를 사용할 경우 코드는 항상 doWorkWith(x)를 호출하게 된다. 이것은 predicate가 false일 때 초자 불필요하게 연산을 한다. predicate가 어떤 에러없이 안전하게 호출되는 것은 좋지만, 굳이 필요하지 않을 때도 연산을 하는 것이다.

차이점 3 : 부수 효과

초과 연산의 연장선으로 볼 수 있다. 원하던 원하지 않던 predicate가 실행되기 때문에 부수효과가 일어날 수 있는 것이다. 나는 함수형 프로그래밍 애찬론자는 아니기에 부수효과에 대해 왈가왈부하진 않겠다. 그러나 predicate가 로그를 찍는다고 생각해보자. predicate 함수로 인해 데이터를 생성하고, 원하지 않는 작업들이 수행될 수도 있다.

takeIf()takeUnless()가 약이 되는 경우

바로 위에서 takeIf()takeUnless()가 독이 되는 경우를 보았다. 이제는 약이 되는 경우, 즉 사용하기에 적합한 경우를 살펴보자.

예시 1 : 객체가 식이 아닐 때

takeIf()를 호출하는 것이 그저 값(객체)일 때 위에서 말한 3가지 문제를 피할 수 있다. 식에 대해서 takeIf()를 호출한 다면 에러를 유발하기 쉽다.

예시 2 : predicate가 좀 복잡해서 읽기 힘들 때

간단한 예시를 보자. 아래의 함수를 더 좋게 바꿀 수 있다.

return if (x) y else null

이렇게 바꾸자

return y.takeIf { x }

이렇게 바꾸는 것이 더 읽기 쉽다는 것은 전적으로 내 주관이다. 나는 개인적으로 predicate가 복잡하면 복잡할수록 takeIf()takeUnless()가 더 빛을 본다고 생각한다.

아래의 코드를

return if (evensOnly && x % 2 == 0) x else null

아래처럼 바꿔보자

return x.takeIf { evensOnly && x % 2 == 0}

다시 말하지만 개인 취향이고 내 생각이다. 나는 takeIf()가 더 읽기 쉽다고 생각한다.

예시 3 : 어떤 함수가 특정 객체의 조건부로 호출될 때

if문에서 많은 일을 한다고 가정해보자.

return if (someString.isNotBlank()) {
    someMoreWork(someString)
} else {
    null
}

위의 코드는 아래처럼 바뀔 수 있다.

return someString.takeIf { it.isNotBlank() }?.let { someMoreWork(it) }

다시 말하지만, takeIf가 더 읽기 쉽다는 것은 주관적인 생각이다.

AAC(Android Architecture Component) ViewModel에 대한 짧은 고찰

|

오해의 소지가 많은 AAC ViewModel

안드로이드 아키텍쳐 컴포넌트(AAC)는 ViewModel이라는 추상클래스를 제공한다. 안드로이드 개발자나 또는 아키텍쳐에 관심이 많은 개발자라면 ViewModel이란 단어를 들었을 때 아마도 가장 먼저 MVVM패턴을 떠올릴 것이다. 마이크로소프트에서 처음 MVVM을 선보이면서 ViewModel이란 단어를 뷰와 모델 사이에서 데이터를 관리하고 바인딩 해주는 요소로 칭했기 때문이다. 실제 MVVM패턴 에서의 viewModel의 역할이 그렇다.

그럼 구글이 안드로이드 개발의 편의성을 위해 제공하는 ViewModel은 어떨까? 앱 개발의 대세가 MVVM이다 보니 많은 개발자가 이름만 보고서는 MVVM의 ViewModel 일거라고 생각한다. 그러나 일반적인 MVVM의 viewModel과 전혀 상관이없다. 1도 상관이 없다. 심지어 안드로이드 공식 문서에도 viewModel을 설명하면서 mvvm 패턴을 이야기 하지 않는다. AAC의 뷰모델이 MVVM패턴의 뷰모델이라면 당연히 언급이 있어야 하는데, 없다.

AAC ViewModel이란?

The ViewModel class is designed to store and manage UI-related data in a lifecycle conscious way. The ViewModel class allows data to survive configuration changes such as screen rotations.

안드로이드 공식 문서에서 가장 첫 문장에 나오는 부분이다. 화면 회전같은 환경 변화에서 뷰에 사용되는 데이터를 유지시키기 위한, 라이프사이클을 알고있는 클래스라고 한다. 우리가 알고있는 mvvm에서의 viewModel(뷰와 모델 사이에서 데이터를 관리하고 바인딩해주는)역할과는 설명이 다르다. 즉, 이름만 viewModel이지 MVVM의 viewModel과 전혀 관계가 없는 클래스라는 것이다.(개발 입문자 입장에서 자바와 자바스크립트 관계보다 더 혼란..)

구글이 viewModel을 잘 못 알고 만든 것일까? 절대 그렇지 않다. 한 가지 잘 못한게 있다면 이름을 헷갈리게도 viewModel로 지었다는점…? 사용자가 화면을 회전 시킬 경우를 생각해보자. 기존 view에는 로그인 된 사용자의 정보를 가지고 있다. 사용자가 화면을 회전 시키면 액티비티는 종료되었다가 다시 생성된다. 그렇기 때문에 사용자의 데이터를 또 다시 받아와야 한다. 그저 화면 회전이 되었을 뿐인데 말이다. 그러나 AAC 뷰모델은 화면을 회전 시켜도 로그인 정보를 그대로 보존해놓기 때문에 다시 유저 로그인 정보를 불러오지 않아도 된다. 이건 정말 편한 기능이다(직접 구현 해보면 얼마나 귀찮고 어려운 작업인지 알게 될것이다). AAC의 ViewModel은 딱 이 역할만 하는 클래스일 뿐이다. ViewModel을 생성하고 거기에 유저 로그인 정보를 넣었을 뿐, 그렇다고 이게 MVVM 패턴이 되지는 않는다.

그런데 생각해보니 조금 이상한 점이 있다. 분명 앱 개발의 대세는 mvvm 패턴이 되어가고 있는데 “mvvm 패턴 샘플 예제”를 검색해서 안드로이드 앱 예제를 보면 대부분 AAC ViewModel을 사용하고 있다. AAC ViewModel은 MVVM의 ViewModel과 전혀 상관이 없다고 했는데…. 인터넷에 올라온 예제들이 모두다 잘못된 예제일까? 그렇게 star를 많이 받은 안드로이드 MVVM 예제 샘플 코드들이 모두 AAC ViewModel을 MVVM의 ViewModel로 착각하고 만든 것일까? 그렇지 않다. AAC의 ViewModel을 MVVM의 ViewModel로써 사용할 수 있다. MVVM의 뷰모델의 역할은 위에서 보았듯이 뷰와 모델 사이에서 데이터를 관리하고 바인딩해주는 것이다. 뷰모델이 가지고 있는 데이터를 옵저버블하게 해주고, 뷰에서는 데이터 바인딩으로 그것을 구독하고 있으면 되는 것이다. AAC ViewModel이라고 그게 안될리가 없다. 오히려 화면 회전시 데이터를 유지시켜주는 기능까지 있으므로 더 좋다. AAC ViewModel에 LiveData를 사용하여 바인딩 시킬 수 있다.

안드로이드 개발을 하면서 MVVM패턴을 사용하려고 한다면, 꼭 AAC의 ViewModel을 사용하지 않아도 MVVM구현은 가능하다. 선택은 개발자의 몫이다. 그러나 놓치지 말아야 할 것은, AAC의 뷰모델은 MVVM에서 말하는 ViewModel과 다르다는 것을 아는 것이다. 이걸 모르고 AAC ViewModel을 mvvm ViewModel처럼 사용한다면 개발 도중 어려움이 생길 가능성이 크다. 한 가지 예를 들자면, AAC ViewModel은 ViewModelProviders를 사용해서 ViewModel을 만드는데, 이렇게 만들어진 뷰모델은 그 액티비티에서 딱 하나만 존재하게 된다. 액티비티 한 개 내에서만 유효한 싱글톤인 셈이다. 이런 특성은 일반적인 MVVM에서는 강제되는 것이 아니기 때문에 혼란이 올 수 있다.

바로 위에서 “뷰모델은 그 액티비티에서 딱 하나만 존재하게 된다”라고 설명했는데 오해의 소지가 있을수 있을 것 같아서 추가적으로 언급하자면, 이 말이 뷰 한개에 뷰모델 유형이 딱 한개 존재해야 한다는 것은 아니다. 예를 들어 SignUpActivity 뷰가 있다고 했을 때, 그에 대응하는 SignUpViewModel 딱 하나만이 존재해야 한다는 것은 아니다. MVVM패턴에서는 뷰와 뷰모델은 1:n 관계이기 때문이다. 개발자는 필요에 따라서 얼마든지 UserPersonalDataViewModel, UserAccountViewModel 등등 여러가지 뷰모델로 나눠서 사용이 가능하다. 다만 UserPersonalDataViewModel을 한번 생성하면, 그 액티비티에서 UserPersonalDataViewModel을 여러번 생성해도 그것은 싱글톤이기 때문에 하나의 객체만 계속 사용된다는 것이다. 그리고 한 가지더, 구글은 하나의 뷰에 하나의 뷰모델만 두고 사용하는 것을 권장한다. SignUpActivity가 있다면, SignUpViewModel 하나만 놔두고, 그 안에 여러 Model과 LiveData를 사용하는 것을 권장하고 있다. 이것은 원래의 MVVM 원칙과 맞지 않는 내용이다. 더 좋고 더 나쁜 방식은 없다. 구글이 추천하는 방식과 원래 MVVM의 원칙 중 어떤것이 더 자신의 프로젝트에 맞는지는 개발자가 판단할 몫이다.

결론

처음 mvvm을 공부해 나갈 때, 내게 더욱 더 혼란을 주는 몇 가지 강연들이 있었다.

MVVM 패턴 이야기 하면서 구글 이야기를 한다면 자리를 박차고 나가라. - 강사룡님

또는

MVVM은 AAC의 ViewModel과 연관성이 없다. AAC ViewModel을 전제로 MVVM을 설명하려고 한다면 단언컨데 우리 회사 1차 면접도 통과하지 못할 것이다.- 정승욱님

AAC ViewModel에서 제대로 알아야겠다고 마음먹기 전에 이런 이야기를 들었다. 그래서 그 당시 나는 “구글이 MVVM을 잘 이해하지도 못하면서 전세계 개발자를 대상으로 ViewModel 컴포넌트를 제공하고 있구나~ “라는 오해를 했다. 그러는 한편, 저 사람들은 자기가 얼마나 잘났길래 감히 구글이 틀렸다고 말하지? 라는 생각을 했다. 그러나 구글도, 강사룡님도, 정승욱님도 틀리지 않았다. 틀린건 오직 나 뿐이었다. 구글이 ViewModel을 내놓았으니 당연히 mvvm의 viewModel이겠거니~ 라며 얕게 생각했던 탓에 구글이 맞는지, 저 강사분들이 맞는지 헷갈렸던 것이다. 정말이지 강사룡님 말씀처럼 MVVM 패턴 이야기 하면서 그것과 전혀 아무런 관계도 없는 구글의 ViewModel를 설명하는 사람이 있다면 그자리는 피하는게 좋을 수도 있다. 또한 당연히 면접자리에서 MVVM을 설명해보라는 질문에 쌩뚱맞게 AAC ViewModel을 말한다면 어느 회사에서든 떨어지지 않을까.

구글은 MVVM과 전혀 상관없는, 그저 이름만 ViewModel인 클래스를 제공했을 뿐 “MVVM의 ViewModel이니까 가져다 쓰세요~”라고 말한 적이 없다. 그냥 나처럼 멍청한 개발자들이 이름만 보고 헷갈려할 뿐….(솔직히 구글도 너무하긴 했다 이름이 viewModel이라니)

참고자료

map과 flatMap에 대하여

|

FlatMap을 이해하기 위해 Map과 FlatMap의 차이점을 알아보자.

Map

map은 이해하기 쉽다. 반복 가능한 배열을 대상으로, 배열의 각 요소를 하나하나 순회하며 그 요소를 조작하고, 조작한 요소들이 모여있는 배열을 리턴해 주는 것이다.

즉 [“A”, “B”, “C”]라는 배열이 있을 때, 각 요소마다 느낌표(!)를 붙인 배열을 만들고 싶을 경우 아래와 같이 코드를 작성하면 된다.

map1

map이라는 함수이름은 직관적이다. 흔히 1대1 매핑시킨다 라는 말을 할때 적용되는 그 mapping이다.

FlatMap

이제 FlatMap을 알아보자.

헷갈릴 수 있기 때문에 몇 가지만 미리 인지하고 가자.

  • flatMap도 map처럼 결국은 배열(또는 iterable)을 리턴한다.
  • 대상이 되는 배열의 요소가 3개라면, flatMap 내부적으로도 3번 호출된다(그러나 결과는 하나의 배열 또는 이터러블 또는 옵저버블이다).
  • map은 무조건 1대1 매핑이지만, flatMap은 1대1 뿐만 아니라 1대다 매핑이가능하다.
  • flatMap에 넘겨주는 함수는 꼭 iterable한 값을 리턴해야한다(iterable을 평평히 펴주는 역할을 할 것임으로).

flatMap이란?

flatMap은 먼저 매핑 함수를 사용해 각 엘리먼트에대해 map을 수행 후, 결과를 새로운 배열로 평평화한다.

map vs flatMap

flatMap1 flatMap에 넘겨주는 람다는 꼭 iterable(반복가능)한 값이여야 한다. newList2를 보면 flatMap의 인자로 넘겨주는 람다의 리턴값에 toList()를 사용했다. toList()"HI"[H, I]와 같이 반복 가능한 List로 만들어 주는 역할을 한다.

flatMap을 사용한 newList2를 보자. testList의 각 요소(it)들이 "$it!".toList()로 인해 각자 배열로 mapping(문자열 A는 배열 [A, !]로 mapping됨)되고, 리턴해준 각각의 배열이 모두 펼쳐져지고 합해져 [A, !, B, !, C, !]가 된다.

결과적으로 1대1 매핑이아닌 1대2 매핑이 된것이다.

flatMap 파헤치기

코틀린은 flatMap을 어떻게 구현했는지 살펴보자.

/**
 * Returns a single list of all elements yielded from results of [transform] function being invoked on each element of original collection.
 */

public inline fun <T, R> Iterable<T>.flatMap(transform: (T) -> Iterable<R>): List<R> {
    // 첫 번째 인자로 빈 배열 하나를 넘겨준다.
    // 이 배열안에 값들을 넣어줄 것이며
    // 이는 곧 flatMap의 결과물이 될 것이다.
    return flatMapTo(ArrayList<R>(), transform)
}

/**
 * Appends all elements yielded from results of [transform] function being invoked on each element of original collection, to the given [destination].
 */
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.flatMapTo(destination: C, transform: (T) -> Iterable<R>): C {
    for (element in this) {  // iterable한 객체의 요소들을 순회
        val list = transform(element) // 요소들을 람다에 넣어 호출하고 값을 반환
        destination.addAll(list)
    }
    return destination
}

우리가 flatMap 함수를 호출함과 동시에 내부적으로 destination이라는 빈 배열이 하나 자동으로 만들어 진다. flatMapTothis는 Iterable 타입이기 때문에 순회가 가능하며, for문으로 순회를 시작한다. 순회를 돌면서 각 요소에다가 우리가 flatMap을 호출하며 함께 넘겨준 람다함수를 적용한다. 람다가 무엇이었는지 상기하기 위해 위에 올려놓은 사진을 다시 보자.

flatMap1

여기서 flatMap에 넘겨준 람다함수는 9번째 줄의 코드다. 매개변수가 생략되었으나 실은 String 타입의 it을 입력 받고 거기에 느낌표(!)를 붙여서 toList()를 적용하여 배열을 리턴해 주는 람다이다.

다시 돌아와서, iterable한 객체의 요소들을 순회하며 각각 람다를 적용해 주었기 때문에 flatMapTo 함수 정의의 list변수는 리스트 타입이다. 그 리스트의 내용물들을 처음에 자동으로 만들어진 destination 배열에다가 넣어준다.

여기서 destinationadd를 호출하는 것이 아니라 addAll을 호출하는 것에 주의해야 한다. addAll을 사용함으로써 배열 자체를 넣어주는 것이 아니라, 배열의 내용물을 넣어줄 수 있게 된다. 아래는 코틀린 공식 문서의 내용이다.

image