읽기 좋은 Kotlin 코드! Property와 Scope Functions!
개인 광고 영역
효과적으로 Kotlin을 사용하고, 적용하려면 어떻게 하는 게 좋을까?
- Kotlin Reference 문서 확인
- Effective 관련 서적을 함께 본다(Effective Java, Effective Kotlin - 원서 링크)
- 더 많은 샘플 코드와 사례들을 살펴본다.
많은 사례들이 모든 좋은 결과물을 만들진 않지만, 다양하게 보다 보면 좀 더 좋은 코드를 만드는 데 도움을 줄 수 있다.
결국 많은 코드를 작성하고, 자신이 작성한 코드의 리팩토링이 필요함을 함께 깨우칠 수 있기도 하다. 처음부터 너무 욕심부려도 만족스러운 코드가 나오지는 않는다.
그래서 필요한 게 Effective 관련 서적을 함께 보는 것을 추천한다. 이 책에서 좋은 사례들을 많이 접할 수 있으며, 왜 이렇게 설계했고 이렇게 썼을 때 좀 더 좋은 방법 등을 함께 살펴볼 수 있기 때문이다.
필자 생각으론 아름다운 코드란 존재하지 않는다고 생각하나, 동작하는 코드를 만들어내기도 사실 바쁘다. 동작하는 코드를 조금씩 조금씩 수정하다 보면 자기도 모르게 발전되어잇는 모습을 확인할 수 있다.
이런 부분을 위한 최소 조건으로 코드 컨벤션 지키는 것부터 시작해도 좋다고 생각한다.
이 글에서는 읽기 좋은 코드를 만들기 위한 2가지 방법을 살펴본다.
이 글에서 알아볼 내용
- 읽기 좋은 코드를 위한 코드 컨벤션과 Android Studio의 코드 정리 설정을 살펴본다.
- 코틀린의 Property 사용 시 주의할 점을 알아본다.
- Scope functions의 활용도를 어디까지 허용하는 게 좋을지 알아본다.
드로이드 나이츠 2020 발표에 포함되어 있는 내용
발표 영상
발표 자료
코드 컨벤션부터 시작해보자.
읽기 좋은 코드를 만들 수 있는 가장 기본인 코드 컨벤션(Code conventions)부터 시작해보자. 코드 정렬을 Android Studio에서 제공하는 방법 만 잘 활용해도 어딘가 모르게 깔끔한 느낌이 든다.
가장 흔하게 볼 수 있는 전역변수 선언의 규칙인데 Member의 약자로 m을 사용한 전역 변수 컨벤션, 그리고 Member 임을 별도로 명시하지 않은 2번째 변수를 정의했다.
class Test {
private val mName: String = "name"
// 또는
private val name: String = "name"
}
m을 붙여 사용하는 케이스는 IDE가 좋지 않은 시절부터 쓰이던 코드 컨벤션이라한다. 회사마다, 사람마다 이런 가이드를 따르는 경우가 있어 아마 어느 회사든 2개의 컨벤션이 혼용되어 있을 가능성이 있다.
이런 코드 컨벤션 만 보아도 하나로 통일하는 게 필요함을 알 수 있다.
필자의 개인적인 생각으론 IDE가 매우 좋아진 현재로썬 굳이 Member의 m을 표현할 필요는 없다고 생각한다.
이제 갓 시작한 개발자나, 개인 개발을 할 생각이라면 이러한 규칙을 미리 정의하고 쓰는 게 좋은데 문서가 필요하다면 아래 주요 문서를 확인해 볼 수 있다.
주요 Code Conventions 문서들
오래전부터 구글은 여기저기 코드 컨벤션 문서들을 나열해 두고 있는데, 이 글에서는 Java와 kotlin 컨벤션 관련 문서를 나열해보았다.
변수 이름 정의는 camel case를 기본으로 활용하도록 안내하고 있고, 그 외 내용은 문서를 참고하길
필자가 다녔던 이전 회사들은 대부분 Google Java Conventions 기본으로 사용했었다.
camel case(카멜 케이스)는 낙타(Camel)의 등처럼 툭 튀어나온 케이스를 말한다. 첫 글자는 소문자로 시작하고, 두 번째 글자의 시작은 대문자로 시작한다. userNameXxxXxx의 형태다
AOSP 변수 이름 정의는 아래와 같이 명시하고 있다. 그 외 내용은 문서를 참고하시길
- Non-public, non-static field names start with m.
- Static field names start with s.
- Other fields start with a lower case letter.
- Public static final fields (constants) are ALL_CAPS_WITH_UNDERSCORES.
Java Conventions과 다르게 Non public, Non static인 경우 m(Member)으로 시작하도록 명시한다.
이것만 보아도, Java Conventions과 다르게 명시하고 있음을 알 수 있다.
Kotlin에 대한 Kotlin style guide 문서이다. 자세한 내용은 문서를 참고하고, 이중 {}(중괄호)
사용법에 대한 가이드를 아래와 같이 나열해보았다.
if (string.isEmpty())
return // WRONG!
if (string.isEmpty()) {
return // Okay
}
if 문 사용 시 엔터를 친다면 {}(중괄호)
를 사용하도록 명시하는 게 좋다는 가이드이다. {}(중괄호)
를 사용치 않은 경우에는 다음과 같이 한 줄로 작성하는 게 가능하다.
if (string.isEmpty()) return
코틀린 공식 문서를 통해서도 Coding conventions을 제공하는데 이름에 대한 규칙과 Property names 규칙 등을 제공한다.
다양한 Coding conventions을 제공하고 있으니, 이른 문서를 참고하여 개인 또는 회사 컨벤션을 만들어 두는 게 좋다.
Android Studio 코드 정렬 방법을 잘 지정해두자.
Android Studio에서 코드 정렬 기준을 잡을 수 있다. java/kotlin/xml 세 개 언어를 주로 사용하니 이를 잘 설정해두는 게 좋다.
각각의 메뉴는 아래와 같고, 혹시라도 정렬이 이상하다고 생각된다면 한 번씩 눌러주면 좋다.
Code style - Java
Code style - Kotlin
Code style - XML
요렇게 적용하면 가장 기본적인 코드 정렬을 해준다.
추가로 import는 *
로 묶어두는 것보단 풀어쓰는 게 좋다고 한다. 이 경우 import 실수도 줄일 수 있으나, 생각보다 많은 import가 나열될 수 있으니 잘 선택하여 사용하길
이와 관련한 정리 문서가 있어 링크를 추가한다.
Why you should disable wildcard imports in IntelliJ IDEA - 링크
- 버전 컨트롤 사용 시 import 충돌이 발생할 수 있다. * import 사용 시 이때 추적이 어려울 수 있다.
- 다양한 import의 패키지 정보를 잘 못 import 하는 실수를 방지할 수 있다. 예를 들면 동일한 패키지 안에 파일명이 동일하고, 행동이 다른 케이스가 있다면 실수할 수 있는 여지가 생길 수 있다.
Property 사용 시 조건을 지정하자.
Kotlin 하면서 변수 사용법이 모호해지는 경향이 생길 수 있는데, 변수는 Property(프로퍼티) 역할도 함께 하기 때문이다.
이런 Property는 getter/setter을 자동으로 생성해 주고, 직접 getter/setter를 정의할 수도 있다.
var <propertyName>[: <PropertyType>] [= <property_initializer>]
[<getter>]
[<setter>]
직접 정의할 수 있는 getter/setter가 어떤 부분에서 문제가 될 수 있는지 살펴보자.
명확한 경우를 먼저 보자
변수에 getter을 직접 정의하기 위해 다음과 같이 선언해보았다.
class UserInfo {
private var _name: String? = null // 1번
val name: String // 2번
get() = _name.takeIf { it != null } ?: ""
}
1번 라인에서 _name
을 private 접근자로 정의하고, Nullable 할 수 있도록 변수를 구현했다.
2번 라인에서 name
의 public 접근할 수 있도록 정의하고, Property getter에서 _name
의 상태를 체크하고, 이를 return 하도록 만들었다.
참고로 함수로 정의한다면 다음과 같다.
fun getName(): String =
_name.takeIf { it != null } ?: ""
이 Property는 getter에서 Nullable이던 _name
의 값을 NonNull의 값으로 리턴 시켜준다.
이 경우 매우 명확하다. name을 호출하면 내부에서는 Nullable인 값을 NonNull로 변환시켜 리턴해줄 뿐 Name의 값에 다른 행동이 개입되어 있지 않다. UserInfo의 name을 호출하더라도 name의 값을 보장받게 된다.
문제를 만들어 볼까?
name을 호출할 때 새로운 행동을 추가해보았다. 이 행동이 어떤 영향을 미칠지는 코드를 보면 알 수 있을 것이다.
class UserInfo {
private var _name: String? = null
private var nameLength: Int = 0 // 1번
private var isUsed: Boolean = false // 2번
val name: String
get() {
return if (isUsed.not()) { // 3번
val ret = _name.takeIf { it != null } ?: ""
nameLength = ret.length
isUsed = true
ret
} else {
"Used"
}
}
}
1번과 2번의 새로운 변수를 2개 추가했다. 이들이 어떤 영향을 미칠지는 3번의 getter 정의를 보면 알 수 있다.
3번의 코드가 기존엔 _name
의 Nullable 값을 체크하고, NotNull 상태로 변환해 돌려주는 정도였다면, 값의 사용 여부를 체크하는 값이 하나 들어가 있고, name의 길이를 설정하는 값도 들어가 있다.
이건 문제없다고 본다면 if 조건을 살펴보자.
- isUsed의 Boolean 값이 false 이면 _name의 값을 리턴한다.
- isUsed의 Boolean 값이 true 이면 “Used”라는 새로운 값을 리턴한다.
여기서 문제가 생겼다. UserInfo에 접근하여 name을 리턴하는데 isUsed이라는 생각지도 못한 값에 따라 name을 한 번만 호출할 수 있도록 변경되었다.
만약 이를 알지 못하고 누군가 쓴다면 name의 값을 보장받지 못하게 된다. 심지어 변수의 이름은 name인데 말이다.
상태와 행동은 명확하게 구분해라.
이렇게 작성해본 이유는 하나다. Property는 상태의 값을 표현하거나, 상태에 대한 설명을 가지는 게 좋다.를 말하고 싶었다.
- Property : 상태의 값을 표현, 상태를 설명하는 형태
- 함수 : 행동을 처리한다
변수의 이름을 onlyOneName으로 지을 수 있을지 모르지만, 값의 상태를 설명하고, 표현하는 범위를 벗어난다면 Property 정의가 아니라 함수로 표현해야 한다.
좀 더 좋은 가이드로 Effective Kotlin 책을 통해 확인할 수 있으니 참고하시길.
위에 작성한 Property 정의한 행동 코드는 Name과 무관한 행동도 포함되어 있어 아래와 같이 함수로 정의해야 한다.
fun getOnlyOneName(): String =
if (isUsed.not()) {
val ret = _name.takeIf { it != null } ?: ""
nameLength = ret.length
isUsed = true
ret
} else {
"Used"
}
변수는 name의 상태를 설명할 수 있는 코드 수준으로 변경하여 아래와 같이 작성해야 한다.
val name: String
get() = _name.takeIf { it != null } ?: ""
결과적으로 명확한 코드 작성이 어려울 것 같다면 Property를 사용할 게 아닌 함수를 잘 나눠 사용해야 한다.
또 다른 Property 예는?
첫 번째 케이스
명확한 Property는 어떤 게 있을까? 필자가 생각하는 몇 가지를 나열했다.
fun getImageUrl() =
"https://farm$farm.staticflickr.com/$server/${id}_$secret.jpg"
이 코드는 함수로 작성해도 무리가 없으나, 굳이 함수일 필요도 없다.
imageUrl이라는 명확한 행동을 가지기 때문에 아래와 같이 수정하는 게 가능하다.
val imageUrl: String
get() = "https://farm$farm.staticflickr.com/$server/${id}_$secret.jpg"
두 번째 케이스
아래와 같이 status를 가지는 변수와 enum으로 정의한 값이 있다.
enum class Status {
SUCCESS,
FAIL
}
private var status: Status = Status.SUCCESS
val isSuccess: Boolean
get() = status == Status.SUCCESS
여기에 status를 등록하고, 예외를 하는 Property를 하나 정의해보았다.
이 경우라면 명확하게 isSuccess의 상태를 설명할 수 있기 때문에 괜찮은 표현에 해당한다.
현재 생각나는 케이스를 나열해보았기에 참고하길 바란다.
항상 Scope Functions이 좋을까?
결론부터 말하면 항상 써야 할 이유는 존재하지 않는다. 그냥 적절하게, 적당히 사용하는 게 가장 좋은 건 당연하다. 이왕 쓰는 Scope Functions을 잘 알고 써야 한다.
Scope Function은 모두 lambda expression으로 되어있다. 실제 내부 코드는 Kotlin extensions과 Higher-Order Functions을 활용하고 있다.
참고 : Kotlin Higher-Order Functions 사용방법
기본으로 제공하는 Scope Function은 아래와 같은데, 크게 2가지 형태이다.
- Receiver로 접근하는 extensions
- Parameter 정의를 통해 사용하는 경우
우리는 보통 Receiver를 통해 접근한다. 이유는? Nullable를 예외 처리하기 쉬워 별도의 null 체크해야 하는 with (parameter) {}
의 활용도는 상대적으로 낮다.
아래와 같은 형태를 제공하고 있다.(Kotlin 1.3 기준)
- run : receiver를 넘겨주는 경우
- with : parameter로 넘겨주는 경우
- apply : receiver를 넘겨주는 경우
- also : receiver를 넘겨주는 경우
- let : receiver를 넘겨주는 경우
- takeIf : receiver를 넘겨주는 경우
- takeUnless : receiver를 넘겨주는 경우
정말 간결해질까?
ImageResponse Data를 하나 생성했다.
data class ImageResponse 정의는 서버로부터 내려오는 데이터를 가정하였고, 서버로 받아온 데이터는 모두 Nullable 임을 가정한다.
data class ImageResponse(
val imageList: List<ImageItemResponse>?,
val next: Boolean
)
data class ImageItemResponse(
val imageUrl: String?,
val imageWidth: Int?,
val imageHeight: Int?,
val imageType: String?
)
ImageResponse 변수를 하나 생성해보자. 실제 서버로 내려왔다면 gson과 같은 컨버터를 활용하겠으나 여기선 직접 작성한다.
private val imageResponse: ImageResponse by lazy {
ImageResponse(
imageList = mutableListOf(
ImageItemResponse(
imageUrl = "https://url",
imageWidth = 1920,
imageHeight = 1080,
imageType = "png"
)
),
next = true
)
}
위와 같은 구조로 내려왔다고 가정하고, 화면에 이미지를 노출해야 한다.
- 사전 조건은 ImageItemResponse의 값이 모두 NotNull인 경우에 이미지를 노출할 수 있다는 조건을 만들어보자.
- if 문 대신 Scope function을 활용해 작성한다.
필자는 Response로 전달받은 데이터 접근 시 let을 활용하고 있다. let을 아래와 같이 활용해보았는데 어떤가?
fun loadImage() {
imageResponse.imageList?.let {
it.forEach {
it.imageUrl?.let {
// 여기서 쓰자
}
it.imageType?.let {
// 여기서 쓰자
}
it.imageWidth?.let {
// 여기서 쓰자
}
it.imageHeight?.let {
// 여기서 쓰자
}
}
}
}
그런데 이렇게 각각을 let으로 사용하니 각각을 따로 쓰기엔 편하나 새로운 Data 객체를 만들기 어렵다.
그래서 어떻게 할까 고민하다가 그냥 let에 let을 중복으로 사용해보았다.
fun loadImage() {
imageResponse.imageList?.let { // 1번
it.forEach { // 2번
it.imageUrl?.let { // 3번
it.imageType?.let { // 4번
it.imageWidth?.let { // 5번
it.imageHeight?.let { // 6번
// 여기서 쓰자
}
}
}
}
}
}
}
이렇게 쓰는 케이스가 많지는 않겠으나, 그냥 if 문 대신 Scope function을 활용한다고 가정해보고 사용했다.
하지만 여기엔 문제가 있는데, it의 범위가 {}
중괄호를 벗어나기 때문에 실제로 동작에 문제가 있다. {} 안의 it을 제외한 상위의 it은 접근할 수 없어 아래와 같은 오류가 발생한다.
Unresolved reference: imageUrl
당연하게도 it을 중첩해서 써버려 Scope이 달라졌다.
- 1번의 let의 it은 imageList의 NonNull로 변했다.
- 2번의 let은 it.forEach의 NonNull로 변했다.
- 3번의 let는 it.forEach의 NonNull의 값 중 imageUrl의 NonNull로 변했다.
- 4/5/6번은 자신의 it을 알지만 상위의 it은 알 수 없다. scope을 벗어났기 때문에 동작하지 않는 게 당연하다.
forEach의 it을 이름을 정의하는 표현인 image로 변경했다.
fun loadImage() {
imageResponse.imageList?.let {
it.forEach { image -> // 여기만 변경되었다.
// 생략
}
}
}
또 다른 문제를 찾아보자. 4개의 값 모두가 NonNull 임을 알 수 있는데, 문제는 마지막 Scope인 image.imageHeight?.let에서 상위 값인 imageUrl, imageType, imageWidth에 접근할 방법이 없어졌다.
이 경우도 각각 모두 it 대신 이름을 정확하게 지정해 주거나, 아래처럼 그냥 체크만 하고, forEach에서 정의한 image를 사용할 수 있는데 아래와 같다.
data class ImageState(
val imageUrl: String,
val imageType: String,
val imageWidth: Int,
val imageHeight: Int
)
fun loadImage() {
imageResponse.imageList?.let { // 1번
it.forEach { image ->
image.imageUrl?.let {
image.imageType?.let {
image.imageWidth?.let {
image.imageHeight?.let {
ImageState(
imageUrl = image.imageUrl,
imageType = image.imageType,
imageWidth = image.imageWidth,
imageHeight = image.imageHeight
)
}
}
}
}
}
}
}
모두 NotNull 임을 확인할 수 있는 마지막 let에서 image.imageUrl, image.imageType을 통해 접근할 수 있다.
정말 Scope Function이 답일까?
이미 가독성이 떨어진 코드가 만들어졌다. 일명 장풍 날려 만든 코드가 만들어졌다.
필자가 생각하기엔 아래와 같다.
- 가독성이 올라갔는가? : 아니오. 전혀 올라가지 않았다.
- 지금은 18줄 정도라서 문제가 없어 보이지만, Scope으로 30줄 50줄이 넘어가면? 과연 it이 어디서부터 온 것인지 알 수 있을까?(this 역시 동일하다)
- run을 활용했다면 더 심각해질 수 있다. run을 중첩한다고 상상해보면 답은 나온다
let을 써보고 싶다면 stackOverflow의 safeLet이라고 있다. 2016년에 stackOverflow에 작성되어 있는 글을 참고해보길 바란다.
여기서는 if 문을 활용하는 게 좋다. 굳이 safeLet을 활용할 필요는 없다고 생각한다. 있다고 해도 그리 편한 방법도 아니다.
fun loadImage() {
imageResponse.imageList?.let {
it.forEach { image ->
if (image.imageUrl != null && image.imageType != null && image.imageWidth != null && image.imageHeight != null) {
ImageState(
imageUrl = image.imageUrl,
imageType = image.imageType,
imageWidth = image.imageWidth,
imageHeight = image.imageHeight
)
}
}
}
}
보통 let은 null 체크하고 사용하는데 너무 복잡하게 할 필요 없다. 위에서 작성한 let 중복을 제거하고 if 문을 활용하는 게 읽기도 편하고 사용도 편할 수 있다.
결국 Scope function은 적당히
let만 가지고 간단하게 살펴봤는데, 너무 Scope function을 꼭 활용해야겠다는 생각을 줄이는 게 가장 중요하다.
Scope function은 하나의 값을 확장하여 사용하는 케이스에 적절하다.
위에 필자가 작성했던 코드에서 좋은 부분과 나쁜 접근 방법을 살펴보면 아래와 같다.
fun loadImage() {
imageResponse.imageList?.let { // 1. 축약인 it을 활용할 수 있으나 좋은 접근 방법이 아닐 수 있다.
it.forEach { image -> // 2. receiver로 받은 값을 it이 아닌 image -> 취한
image.imageUrl?.let { // 3. 아래 let들은 모두 좋은 접근법은 아니다.
image.imageType?.let {
image.imageWidth?.let {
image.imageHeight?.let {
}
}
}
}
}
}
}
1번 케이스는 아래와 같이 변경하는 게 더 좋을 수 있다.
imageResponse.imageList?.forEach { image ->
// 생략
}
2번 케이스는 forEach로 전달받은 image를 it 대신 명시했다. 명시함으로써 얻는 장점은 다음 라인을 보면 이유를 알 수 있다. 중첩으로 let을 활용한 부분이 문제가 될 수 있기 때문에 여기선 잘 작성한 부분이다.
3번의 let 체크는 if 문으로 처리하는 편이 가독성이 더 좋다. 그리고 모두 NotNull 체크 후 it을 사용하고 있어 이 it이 어디까지 영향을 미치는지 판단하기 어려워진다.
저 let 대신 run으로 교체했다고 생각하면 더욱 가독성을 해칠 수 있다.
- let은 Receiver를 parameter로 전달하기 때문에 명시가 가능하다.
- run은 this로 사용해야 하는데, 스쿱 지정을 잘해야 한다.
- 결국 스쿱 지정이 엉망이라면 코드 가독성도 떨어지고, 명확하지 않을 수 있다.
결론은 Scope function을 잘못 활용한다면 가독성을 오히려 떨어트린다. 그 말은 추후 수정할 때도 어렵다는 이야기다.
receiver, parameter 필요조건에 따라서 명확하게 정의해야 한다.
추가로
Scope function의 이해도는 사람마다 다를 수 있다. 특히 협업에서 더 그럴 수 있는데, 필자가 생각하는 예는 아래와 같다.
apply : apply는 Receiver로 전달받은 값을 그대로 리턴해준다.
활용법 : 값을 초기화할 때 주로 사용한다. 필자는 값 초기화에서 주로 활용한다.
run : run은 Receiver로 전달받은 값을 가지고 사용하고, block을 리턴한다.
활용법 : this가 필요하고 block을 리턴해야 하는 경우 사용할 수 있다. 필자는 UI에서 주로 활용한다.
let : let은 Parameter로 전달받은 값을 가지고 사용하며, Kotlin 람다 표현식에 따라 it을 사용할 수 있다.
활용법 : parameter 명확화가 필요하고, block을 리턴해야 하는 경우에 사용할 수 있다. 필자는 데이터 객체 접근 시 ?.
체크 후 사용한다.
필자가 생각하고 사용하는 부분은 이와 같은 형태이다. 여기서 활용법을 적은 적은 부분은 일부 동의하거나, 동의하지 않을 수 있다.
결국 이 말은 같이 일하는 분들조차 누구는 동의하고, 누구는 동의하지 않을 수 있다는 결론이 생길 수 있다.
이렇게 이해도가 다름이 생길 수 있으니, 협업 간에 동일한 코드에서 누구는 let을 누구는 run을 활용하는 케이스가 생길 수 있다.
이 부분 역시 코드 컨벤션에 포함하여 문서로 정의해두는 게 추후 코드 리뷰나 상호 간의 대화에 도움이 될 수 있으니 잘 정리해두고 쓰시는 걸 추천한다.
마무리
코틀린을 좀 더 잘 다루기 위한 코드 컨벤션, 사용법을 정리해보았다.
Kotlin Property
Kotlin Property가 가능함에 따라 함수 역할을 대신할 수 있다. 그러다 보니 좀 더 명확한 Property 정의가 필요하여 설명하게 되었다.
이 글에서 적은 설명이 모든 케이스를 커버하지는 못하겠으나, 어느 정도 Effective Kotlin의 내용에 따라 정리해보았으니, 개발에 도움 되었으면 한다.
Scope function
Scope Function은 사용할 수 있는 게 많고, 잘 활용하면 매우 유용하다.
그 잘이라는 부분이 애매할 수 있으나, 어느 정도 정리해보려고 노력했다. Scope Function을 잘 활용하기 위해서 일단 안 좋은 케이스를 정리해야겠다 싶어, 단적인 예를 정리해보았다.
이 부분은 개인적인 생각일 수 있으니 참고만 하고, 좀 더 좋은 코드를 활용할 수 있도록 잘 이해하고 사용하는 것이 좋다.
예에서도 적었지만 적당히 쓰지 않으면 오히려 가독성을 해치게 될 수 있어 그냥 if로 체크하는 게 좋을지도 모르겠다는 생각이 든다.
이 글은 여기에서 마치도록 한다.
이 글만 보기 아쉽죠. Effective Kotlin 더 보기
지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.
Comments