"Kotlin의 장점을 내가 활용하는 걸까?"
스스로에게 질문은 던졌고 내 스스로 내린 대답은 No 였습니다.
항상 변해가는 안드로이드 개발은 언제나 공부 투성이이고 예전 시나리오들을 모르는 개발자들은 어떻게 발전해왔는지도 함께 공부를 해야합니다. 같은 발자취를 걷다가 어느새 Android 의존적인 개발 보다 Kotlin 이나 Java에 충실하고 변하는 안드로이드 플랫폼에 너무 의존적이지 않고 본질을 파고 들고 싶다는 생각이 듭니다.
여러 생각이 들어서 공부하면서 Kotlin 글을 작성해야겠다는 생각이 들었습니다.
이 게시글은 막막한 생각 앞에 특별한 주제 없이 즉흥적으로,, Kotlin을 사용하는 production 개발 업무를 하다가 다시 기본적인 것을 뒤돌아보고 정리한 내용 입니다.
1. 변경 가능한 객체는 최대한 불변으로 노출 시키자.
예시) 이미지 갤러리를 직접 구현하는 화면을 제작한다는 가정하에 코드 입니다.
- RecyclerView.Adapter Class 내의 MutableList 를 사용해서 selectImages.add 혹은 remove를 통해 selectImages에 데이터를 담고 완료 버튼 UI를 누를 시 서버에 선택한 이미지 전송
class Adapter(private val dataList:List<T>):RecyclerView.Adapter ....{
val selectImages = mutableList<String>()
....
}
class MainActivity:AppCompatActivity...{
private val adapter = Adapter()
override fun onCreate(..){
super.onCreate(..)
...setting UI...
doneButton.setOnClickListener{
imageUpload(adapter.selectImages)
}
}
fun imageUpload(images:List<String>){
//서버에 업로드
}
}
좋은 코드는 아니지만, 아무튼.. 위 코드는 변경 가능한 mutableList를 외부에 바로 노출해버리는 경우가 있던거 같습니다. Kotlin은 기본적으로 불변 상태를 권장하고 상태를 변경 가능한 객체는 가능하면 immutable 로 노출하는 것이 중요합니다. 그래서 위와 같은 코드는 외부에서 언제든지 해당 객체를 건드려버릴 수 있는 여지가 있습니다.
class Adapter(private val dataList:List<T>):RecyclerView.Adapter ....{
private val selectImages = mutableList<String>()
....
fun getSelectImageList():List<String>{
return selectImages
}
}
//액티비티에서..
doneButton.setOnClickListener{
imageUpload(adapter.getSelectImageList())
}
Adatepr 내부 selectImages 의 접근제한자를 설정하고 getSelectImagesList() 라는 함수를 만들었습니다. mutable collection 의 Return Type을 해당 collection Type 의 read only Type 으로 설정하면 자동적으로 Immutable Collection으로 업캐스팅 되어 반환이 가능합니다.
이렇게 외부에서 쉽게 변경이 불가능하고 Adapter 내부에서만 변경이 가능한 구조가 됩니다. 위 같이 mutable Colletion은 외부에서 함부로 변경이 불가능하게끔 작성하는 것이 좋습니다.
그리고 일반적인 상황에서 mutable List<T> 보단 List<T>를 더 선호하는 것이 좋습니다. plus() 나 minus로 객체를 복제하는 해서 사용하는 것이 기본 적인 불변을 보장하게 만듭니다. 하지만 상황에 따라서 mutableList<T>을 사용해야하는 경우도 있으니 항상 판단은 개발자의 몫 입니다.
2. 플랫폼 타입은 조심 해야한다.
Kotlin이 아닌 외부 플랫폼에서 오는 Data의 경우 Kotlin 과 같이 null-safety 하지 못하는 언어에서 오는 데이터는 @Nullable 혹은 @NotNull anotation 을 사용해서 null 허용 여부를 결정해야 합니다. 대표적인 예시로 Java 코드가 있을 겁니다.
public class User {
public @NotNull User getUser() {
}
}
Kotlin은 null safety 기능으로 미리 컴파일 단계에서 널 허용 여부를 확인을 통해 해당 NPE 에 대한 방지를 미리 확인 할 수 있지만 어노테이션이 작성이 되지 않은 플랫폼 타입은 기본적으로 null 체크 여부를 묻지 않고 non null로 컴파일 단계에서 읽힙니다.
//플랫폼타입
public class JavaType {
public integer getData() {
return null;
}
}
//자바
fun javaTypeTest(){
val value = JavaType().data
//아래에서 런타임 에러
println(value)
}
그래서 not null로 믿었던 println(value) 함수에서 에러가 발생합니다. 그외에도 플랫폼 타입은 널리 전파가 가능하기도 합니다. 코틀린 interface 에서 아래와 같이 사용할 경우
interface JavaTypeInterface{
fun getType() = JavaType().data
}
여러 문제를 발생시키는 타입을 널리 퍼트릴 수 있게 됩니다. 항상 외부에서 들어오는 플랫폼 타입을 잘 체크하고 미리 제거하거나 어노테이션 처리를 해줘야합니다.
3. 타입 추론
Kotlin은 보통 Java와 많이 비교를 하게 됩니다. Kotlin 중에서 아주 강력한 것은 바로 타입 추론이 가능해서 빠르게 간결한 코드를 작성하게 됩니다. 하지만 이 타입추론에 너무 의지하다 보면 협업할 때 소소한 혼란을 가져오거나 타입을 잘못 유추해서 실수하는 경우가 생깁니다
보통 String,Integer 와 같이 기본 데이터는 금방 체크가 가능하지만
val score = 1
참조 타입의 객체의 경우 코드 시나리오가 잘보여지지 않는 상황일 때 타입 추론하기 애매한 경우가 있기도 합니다.
//Url 이야..? String이야..? Image 이라는 참조 타입이 있는거야..?
val getImage = repository.getImage()
interface Image {
val image:String
}
// 아하
val getImage:Image = repository.getImage()
유추가 안되는 상황에 있는 변수명이나 코드라인이면 임의적으로 반드시 타입명을 다는 것이 빠른 생산속도나 오해의 여지를 많이 줄여줍니다. 하지만 너무 많이 명시를 한다면 타입추론이라는 강력한 기능을 상실할 수 있어서, 코드 상황상 명시를 해야하는 부분에 판단해서 명시를 하는 것이 좋습니다.
4. 표준 에러를 사용하자.
특별한 상황을 제외하고 보편적으로 많이쓰는 Stendard Error 를 사용하는 것이 좋습니다. 이러한 표준 예외들은 다른 개발자도 쉽게 이해 할수록 돕고 언제든지 재사용할 수 있기 때문입니다. 아래는 많이 쓰이는 표준 에러들 입니다.
- IlligalArgumentException
- IllegalStateException
- IndexOutObBoundsException
- ConcurrentModificationException
- UnsupportedOperaionException
- NoSuchElementException
5. 함수가 원하는 결과가 아닌,, 다른 결과를 가져올 수 있는 경우 ->
Null 혹은 실패를 의미하는 객체를 따로 반환하자-
가장 대표적인 예시로 서버에서 데이터를 가져오는 경우가 있습니다.
언제든지 Network 혹은 Server 상황에 따라 클라이언트에서 원하는 값을 반환 받지 못하는 경우가 있습니다.
그럴 때를 대비해서 null 반환 혹은 Failure 로 다른 객체를 반환하는 것이 좋습니다.
왜냐 하면 두개의 경우 코틀린 문법상 예외처리가 효율적으로 가능합니다.
(1) null 반환
null 반환에 대비해서 엘비스 연산자 "?:" 혹은 null safe call 처리가 너무 수월합니다. 또한 대부분 null 이 값이 없다라는 뜻으로 통용되기도 합니다.
val getResultServer = networkApi.getData()?:"서버에 문제가 생겼습니다."
(2) Failure
LiveData를 사용했을 때의 간단한 예제입니다. ( 아니라 블로그 포스팅중에 즉흥으로 작성한거라 오류가 많은 코드입니다.)
sealed class Result<out T>
class Success<out T>(val result: T) : Result<T>()
class Failure(val throwable: Throwable) : Result<Nothing>()
class ViewModel:ViewModel(){
private val _liveData:LiveData<Result<Data>>
val liveData:LiveData<Result<Data>> = _liveData
init{
runcatching{
getServerData()
}.onSuccess{
_liveData.value = Result.Success(it)
}.onFailure{
_liveData.value = Result.Failure(it)
}
}
..fun getServerData()..
}
//액티비티
onCreate(){
...
viewModel.liveData.observe(this){ result ->
val age = when(result){
is Success -> {...}
is Failure -> {-1}
}
}
}
Failure 경우 실패와 함께 다른 값도 넘겨 줄 수 있고 when으로 패턴 매칭을 사용하기 때문에 더 상태에 대한 처리가 명확해지는 코드 작성이 됩니다.
위 두개의 경우를 제외하고 아예 예외를 할 수 있는 방법도 있지만 비추천 하는 방식입니다.
이유는 아래와 같습니다.
- 예외가 전달되는 방식이 가독성이 좋지는 않다.
- 코틀린에서 모든 예외가 고려되지는 않는다. 유저는 이를 핸들링조차 하지 않아도 될 수 있다.
- 예외가 예외적인 상황을 위해 디자인되었기 때문에, JVM내에서 명시적인 검사만큼 빠르게 작동하지 않을 수 있다.
- try-catch블록 안에 코드를 구현하는 것은 컴파일러가 최적화를 하는 것을 방해할 수 있다.
6. !!을 잘 처리해야 한다.
Kotlin은 null 을 지배하는 것이 중요한 포인트 입니다. 컴파일에서 부터 널 허용 여부를 묻는데, 해당 객체가 null 이 아닐 것이라 생각하고 쉽게 !! 를 표시하는 경우가 있습니다. 그러다가 나중에 코딩을 하다보면 안전할것이라 생각한 해당 부분에서 NPE이 걸리는 경우가 있습니다.
//아래 인자중 하나라도 null 일시 NPE 발생
fun largestOf(a: Int, b: Int, c: Int, d: Int): Int = listOf(a, b, c, d).max()!!
이외에도 !!는 경고 같은 표시라서 코드 문법 흐름상보기에도 좋지 않습니다.
이 문제에 대한 대안은 lateinit을 사용하는 경우가 많습니다.
!!의 사용을 피하도록 하는 것이 좋습니다. 그래서 많은 안드로이드 팀들은 !!를 사용하지 않는 정책을 가지고 있습니다.
7. 널러블 허용 컬렉션 타입은 널 대신 빈 콜렉션을 반환하는 것이 좋다.
ImageList(
val list:List<String> = emptyList()
)
예를 들어 List<Int>? 라는 데이터를 다룰 때 해당 자료형이 실질 적으로 null 상태인지? 아이템이 빈 상태인지? 구별하기 어렵습니다. List는 값이 없다는 것을 표현하기 위해 emptyList() 나 listOf()를 기본 할당하는 것이 좋습니다.
8. Kotlin 문법을 너무 남용하지 말자
Kotlin 을 하다보면 scope function 을 많이 사용하게 되고, kotlin 다운 문법을 과도하게 활용하려는 경우가 있습니다. 항상 케바케라는 말이 있듯이 kotlin 특유의 문법을 필요이상으로 사용하면 기본적인 문법만 못하는 경우가 많습니다.
대표적인 예시로 take if 와 let으로 null 체크를 과도하게 쓰고 엘비스 연산자로 지저분하게 처리하는 경우가 있습니다.
아래 예제는 teahwan 님 블로그에서 잘못된 예시로 작성된 예제 코드 일부 가져왔습니다.
https://thdev.tech/kotlin/2020/09/08/kotlin_effective_01/
imageResponse.imageList?.let {
it.forEach { image ->
image.imageUrl?.let {
image.imageType?.let {
image.imageWidth?.let {
image.imageHeight?.let {
}
}
}
}
}
}
과연 좋은 코드라 할 수 있을진 모르겠습니다. 이렇게 scope 가 길어지는 모양새는 오히려 가독성을 하락 시킴으로 이런 경우 if문으로 처리하는게 더 나을거라 생각이 듭니다. 아래처럼 심플한 경우라면 올바른 scope 함수 사용이라 생각이 듭니다.
user?.let{
println(user.name)
}
기본적인 코딩 문법과 kotlin scope function 활용을 아름답게 균형을 잡아야 더 아름다운 코딩이 되는거 같습니다.
'코틀린 Kotlin' 카테고리의 다른 글
Kotlin (7) getter 와 Assignment (=)의 차이 (1) | 2021.08.03 |
---|---|
kotlin (6) - lateinit 과 by lazy 용도 간단 정리 (0) | 2021.06.05 |
kotlin (5) Object 와 Companion Object (0) | 2021.06.04 |
Kotlin (4) - 안드로이드의 코틀린 (Google IO) (0) | 2021.05.29 |
Kotlin (3) - 코틀린의 repeat와 Pair를 알아보자 (백준 1003) (0) | 2021.05.23 |