코틀린은 정말 안전한 언어지만, 정말로 안전하게 사용하려면 개발자가 뒷받침을 해야 합니다.
item 1. 가변성을 제한하라
코틀린 프로그램은 모듈로 이루어져 있다.
모듈의 대표적 종류는 다음과 같다.
- 클래스
- 객체
- 함수
- 타입 별칭
- top-level 프로퍼티
가변을 지양할 이유
모듈 중 var 프로퍼티나 mutable 객체는 상태를 가질 수 있는데 이는 생산성을 높여주지만 여러 문제점을 갖는다.
- 상태를 갖는 부분들의 관계를 이해해야 함. 상태를 갖는 부분이나 변경 부분이 많을수록 이해하기 어려워짐
- 시점에 따라 바뀌는 상태를 가지면 실행을 예측하기 힘듬. → 테스트 하는 것도 어려움
- 멀티스레드 프로그램일 경우 충돌이 발생할 수 있음
- 변경이 일어날 때마다 다른 부분에 알려야 할 경우가 있음
class Roulette(
var winner: String = if (LocalDateTime.now().hour < 5) "Java" else "Cafe"
)
...
@Test
fun guessWinner() {
Assertions.assertEquals(Roulette().winner, "Java")
}
추가적으로 immutable 객체는 다음 이점을 갖는다.
- 참조가 변경되지 않으므로 캐시가 쉽다.
- 방어적 복사본을 만들지 않아도 된다.
- map의 키 또는 set로 사용이 가능하다.
코틀린은 가변을 지양하는 언어라서 쉽게 제한할 수 있도록 여러 방법을 제공한다.
- 읽기 전용 프로퍼티 (val)
- 읽기 전용 컬렉션
- 데이터 클래스의 copy
읽기 전용 프로퍼티
val을 사용해 읽기 전용 프로퍼티로 생성 가능하다.
하지만 프로퍼티 레퍼런스의 변경을 제한할 뿐 읽기 전용 프로퍼티도 변할 수 있다.
var color: String = "Space gray"
val laptop: String = "$color Macbook"
fun printLaptop(selectedColor: String) {
println(laptop)
color = selectedColor
println(laptop)
}
- 읽기 전용 프로퍼티를 정의하거나 getter를 정의할 때 가변 프로퍼티를 사용하는 것을 지양해야 함
그럼에도 프로퍼티 레퍼런스도 변경이 가능한 가변 프로퍼티보다 읽기 전용 프로퍼티를 사용하는 것이 더 안전하다.
읽기 전용 컬렉션
코틀린의 Iterable, Collection, Set, List 인터페이스는 읽기 전용이라 변경을 위한 메서드가 없다.
이 인터페이스에 변경 메서드를 추가한 MutableIterable, MutableCollection, MutableSet, MutableList 같은 가변 컬렉션이 별도로 제공된다.
읽기 전용 컬렉션을 불변하게 만들지 않은 것이 중요한 부분이다.
Iterable의 map 메서드는 변경할 수 있는 리스트인 ArrayList를 반환한다.
이런 구현은 플랫폼 고유의 컬렉션 사용을 가능하게 한다.
immutable하지 않은 컬렉션을 외부적으로는 immutable하게 보이도록 하기 때문이다.
하지만 이를 이용해 읽기 전용 리스트를 쓰기 가능하게 만들면 안된다.
var list = listOf(1,2,3)
if(list is MutableList) list.add(4)
위 코드의 listOf는 JVM에서 자바의 List를 구현한 Array.ArrayList를 반환하는데 이 인스턴스는 MutableList로 변경이 가능하다. 하지만 Arrays.ArrayList는 변경 메서드를 구현하고 있지 않기 때문에 오류가 발생한다.
listOf는 읽기 전용 컬렉션을 반환 하므로 이를 가변 컬렉션으로 다운캐스팅 하면 안된다.
변경이 필요하면 컬렉션의 copy를 이용해 새로운 가변 컬렉션을 만들어야 한다.
var list = listOf(1,2)
val mutableList = list.toMutableList()
mutableList.add(4)
데이터 클래스의 copy
immutable 객체는 변경할 수 없다는 단점이 있다. 따라서 자신의 일부를 수정한 새로운 객체를 만드는 메서드를 제공한다.
- Int의 plus, minus
- Iterable의 map, filter
우리가 만드는 클래스도 이처럼 읽기 전용 프로퍼티를 변경해서 새로운 객체를 반환하는 메소드를 제공해야 한다.
이 과정이 번거롭기 때문에 data class에서는 copy 메서드를 제공해 이런 과정을 쉽게 만들어준다.
data class User(
val name: String
)
user = User("bell").copy(name="ann")
하지만 list에 대해 copy는 메모리가 많이 소모된다.
다른 종류의 변경 가능 지점
저자는 mutable 프로퍼티를 더 권장한다.
mutable 컬렉션과 mutable 프로퍼티
두 가지의 변경 가능한 리스트가 있을 때 어떤 것이 멀티스레드에 더 적합할까?
val list1: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()
멀티 스레드 안정성
첫번째 코드는 리스트 구현 내부에 변경 가능 지점이 있다. 내부적으로 적절한 동기화가 되어 있는지 확실히 알 수 없으므로 위험하다. 즉, 동기화 여부에 대한 구현이 블랙박스라서 위험하다.
반면 두번째 코드는 프로퍼티 자체가 변경 가능한 지점이다.
변경 상태 추적 가능
mutable 프로퍼티를 사용하는 경우 변경 상태 추적이 가능하다.
var names by Delegates.observable(listOf<String>()) { _, old, new ->
println("Name changed from $old to $new")}
객체 변경 제어
mutable 프로퍼티를 사용하면 객체 변경을 제어하기 더 쉽다.
여러 객체를 변경하는 여러 메서드 대신 세터 사용이 가능하고 이를 private으로 만들 수 있기 때문
var announcements = listOf<Announcement>()
private set
변경 가능 지점 노출하지 말기
Property 접근제어
- 엔티티 외부에서 Property 변경하는 것을 막는 것이 유지, 보수를 쉽게 하고 애플리케이션을 복잡하지 않게 만듬
- pk가 아닌 property들은 생명주기 동안 변경이 가능하기 때문에 mutable property로 선언하는 것이 이상적.
- 하지만 생성자 내부에서 mutable propery로 선언한 property들은 자동으로 getter 및 setter가 만들어진다. 따라서 아래와 같이 재정의해보자.
@Entity
@Table(name = "`user`")
class User(
name: String,
) : PrimaryKeyEntity() {
@Column(nullable = false, unique = true)
var name: String = name
protected set
}
엔티티 클래스를 allOpen 하면 open property의 경우 private setter는 허용되지 않아 컴파일 에러가 발생한다.
외부에 노출할 연관관계 Collection은 immutable 선언
연관관계 property를 val로 선언하더라도 외부에서 쉽게 변경할 수 있다.
@Entity
@Table(name = "`user`")
class User(
name: String,
) : PrimaryKeyEntity() {
@Column(nullable = false, unique = true)
var name: String = name
protected set
@OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "writer")
val mutableBoards: MutableList<Board> = mutableListOf()
}
@DataJpaTest(showSql = true)
class UserRepositoryTest {
@Autowired private lateinit var userRepository: UserRepository
@Autowired private lateinit var testEntityManager: TestEntityManager
@Test
fun test() {
val user = User("홍길동")
val board1 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf())
val board2 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf())
testEntityManager.persist(user)
testEntityManager.persist(board1)
testEntityManager.persist(board2)
testEntityManager.flush()
testEntityManager.clear()
val findUser = userRepository.findById(user.id).get()
val board3 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf())
findUser.mutableBoards.add(board3)
testEntityManager.merge(findUser)
testEntityManager.flush()
testEntityManager.clear()
}
}
위 코드에서 add 함수를 통해 User가 가진 Board를 외부에서 마음대로 추가할 수 있다.
그래서 MutableList는 내부에서만 사용하고 외부 노출은 ImmutableList로 한다.
@Entity
@Table(name = "`user`")
class User(
name: String,
) : PrimaryKeyEntity() {
@Column(nullable = false, unique = true)
var name: String = name
protected set
@OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "writer")
protected val mutableBoards: MutableList<Board> = mutableListOf()
val boards: List<Board> get() = mutableBoards
}
하지만 위 코드로 아래 테스트를 진행하면 이슈가 발생한다.
@Test
fun test() {
val user = User("홍길동")
val board1 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf())
val board2 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf())
testEntityManager.persist(user)
testEntityManager.persist(board1)
testEntityManager.persist(board2)
testEntityManager.flush()
testEntityManager.clear()
val findUser = userRepository.findById(user.id).get()
val boards = findUser.boards
assertEquals(2, boards.size)
val board3 = Board("게시판", "내용", BoardInformation(null, 1), user, setOf())
findUser.writeBoard(board3)
assertEquals(2, boards.size) // assert error : expected: <2> but was: <3>
}
findUser로부터 가져온 boards의 size는 2일 것으로 기대했지만 이후에 findUser에 board를 추가했을 때 과거 값인 boards.size가 변한 것을 볼 수 있다. 따라서 아래처럼 toList함수로 복제하여 반환하도록 해서 mutableBoards가 변경되어도 boards에 영향을 미치지 않게 한다.
@Entity
@Table(name = "`user`")
class User(
name: String,
) : PrimaryKeyEntity() {
@Column(nullable = false, unique = true)
var name: String = name
protected set
@OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "writer")
protected val mutableBoards: MutableList<Board> = mutableListOf()
val boards: List<Board> get() = mutableBoards.toList()
fun writeBoard(board: Board) {
mutableBoards.add(board)
}
}
위의 경우 엔티티의 상태 변경에 대한 메서드를 엔티티 내부로 모두 감추었을 경우 추천할 수 있는 방법이다. 하지만 지나치게 메서드가 많아져 엔티티 파악이 어려워지는 단점이 존재한다. 따라서 팀의 선호도에 따라 선택하는 것이 바람직하다.
item 2. 변수의 스코프를 최소화하라
상태를 정의할 때 변수와 프로퍼티의 스코프를 최소화하는 것이 좋다.
- 변경 가능한 부분을 좁혀 추적하고 관리하기 쉽기 때문
변수 스코프 최소화 예시
// 나쁜 예
var user: User
for (i in users.indices) {
user = users[i]
print("$user $i")
}
// 좋은 예
for ((i,user) in users.withIndex()) {
print("$user $i")
}
여러 프로퍼티를 한꺼번에 설정해야 하는 경우 구조분해 선언을 활용하는 것이 좋다.
fun updateWeather(degrees: Int) {
val (description, color) = when {
degrees < 5 -> "cold" to Color.BLUE
degrees < 23 -> "mild" to Color.YELLOW
else -> "hot" to Color.RED
}
}
스코프가 넓으면 위험한 이유
캡쳐링
val primes: Sequence<Int> = sequence {
var numbers = generateSequence(2) {it + 1}
var prime: Int
while (true) {
prime = numbers.first()
yeild(prime)
numbers = numbers.drop(1)
.filter {it % prime != 0}
}
}
print(primes.take(10).toList()) // [2,3,5,6,7,8,9,10,11,12]
위 코드를 실행하면 prime 변수를 캡쳐링하기 때문에 원하는 결과가 나오지 않는다.
시퀀스를 활용하기 때문에 필터링은 지연된다. 따라서 최종 prime 값으로만 필터링되어 prime이 2일 때 4만 필터링된다.
시퀀스 뿐만 아니라 람다에서도 변수를 캡쳐링 하는데 이를 이용해서 클로저를 지원하기도 한다.
위와 같이 변수 캡쳐링은 예상하지 못한 동작을 야기시키기 때문에 스코프를 줄여야 한다.
item 3. 최대한 플랫폼 타입을 사용하지 말라
- null-safety 메커니즘이 없는 자바, C 같은 언어와 코틀린을 연결해서 사용할 때 NPE가 발생할 수 있다.
- 자바 코드를 코틀린으로 변환하려 할 때
- String 타입을 반환하는 메서드: nullable에 관한 정보가 없다면 안전하게 nullable로 가정해 변환하는 것이 좋다.
- 제네릭 타입을 반환하는 메서드 (ex. List<User>): List와 내부의 User 객체 모두 null 여부를 확인해야 한다.
- List<List<User>>일 경우
val users: List<List<User>> = UserRepo().groupUsers!!.map { it!!.filterNotNull() }
위처럼 다른 언어의 객체에 대해 null 여부를 확인하는 것이 복잡해진다.
→ 코틀린은 다른 언어에서 넘어온 타입들을 특수하게 플랫폼 타입으로 다룬다.
// 자바
public class UserRepo {
public User getUser() {
// ...
}
}
// 코틀린
val repo = UserRepo()
val user1 = repo.user // 타입 : User!
val user2: User = repo.user
val user3: User? = repo.user
val users: List<List<User>> = UserRepo().groupedUsers // 플랫폼 타입으로 인해 가능
문제는 null이 아니라고 생각되는 것이 null일 때이다.
현재 not-null이더라도 미래에 nullable하게 변경될 수 있기 때문에 플랫폼 타입을 사용할때는 주의를 해야한다.
자바에서 @ParametersAreNonnullByDefault 어노테이션으로 파라미터가 널이 아니라는 것을 보장할 수 있음
// 자바
public class JavaClass {
public String getValue() return null
}
// 코틀린
val statedType: String = JavaClass().value // NPE
print(statedType.length)
val platformType = JavaClass().value
print(platformType.length) // NPE
위 두 가지는 모두 NPE를 발생시킨다.
statedType은 자바에서 값을 가져올 때 타입이 적합하지 않으므로 에러가 발생한다.
platformType은 플랫폼 타입으로 변환이 되지만, 이를 null이라 생각하지 않고 사용하는 순간 에러가 발생한다. 이는 디버깅을 힘들게 만든다.
인텔리제이는 platformType으로 프로퍼티가 선언될 경우 nullable 이슈 경고를 알려준다.
item 4. 추론(inferred) 타입으로 리턴하지 말라
코틀린의 타입 추론은 생산성을 높여주는 코틀린의 가장 큰 특징 중 하나이다.
하지만 타입 추론은 몇가지 상황에서 위험하기 때문에 아래 내용을 잘 숙지해야 한다.
- 변수 할당에 타입 추론을 사용하면 정확하게 레퍼런스 타입으로 설정된다.
- 반환 타입에 타입 추론을 사용하면 예상치 못한 동작을 할 수 있다.
문제 상황
변수 할당에 타입 추론
open class Shape
class Circle: Shape()
fun main() {
var circle = Circle() // var circle: Circle로 선언됨
circle = Shpae() // error: type mismatch
}
위와 같은 상황을 막으려면 타입을 명시적으로 선언하면 해결된다.
fun main() {
var circle: Shape = Circle() // var circle: Circle로 선언됨
circle = Shpae() // error: type mismatch
}
반환 타입에 타입 추론
라이브러리와 같이 메소드를 제공하는 코드에서 반환 타입에 타입 추론을 사용할 경우 문제가 발생할 수 있다.
interface CarFactory {
fun produce(): Car
}
open class Car
class Avante: Car()
val DEFAULT_CAR: Car = Avante()
이때 DEFAULT_CAR가 Car 타입으로 명시되어 있기 때문에 리턴 타입을 생략한다면
interface CarFactory {
fun produce() = DEFAULT_CAR
}
형태로 라이브러리가 제공될 것이라 예상할 수 있다.
그런데 사용자가 DEFAULT_CAR를 타입 추론으로 생성할 경우
val DEFAULT_CAR = Avante()
DEFAULT_CAR가 Avante 타입으로 제한되므로 CarFactory는 Avante만 생성할 수 있게 제한된다.
만약 위의 인터페이스가 직접 작성한 것이 아니라 외부에서 가져온 것일 경우 제작자에게 책임을 물을 것이다.
정리
타입을 확실하게 지정해야 하는 경우 명시적으로 타입을 지정해야 한다는 원칙을 가져야 한다.
또 외부 api를 만들때 반드시 타입을 지정하고 이 타입을 제거 해서는 안된다.
item 5. 예외를 활용해 코드에 제한을 걸어라
코틀린은 코드의 동작을 제한할 때 사용할 수 있는 몇가지 방법을 제공한다.
- require
- check
- assert
- return 또는 thorw와 함께 elvis 연산자
제한을 걸어주면 여러 장점을 가질 수 있다.
- 문서를 읽지 않은 개발자도 문제를 확인할 수 있음
- 문제가 있더라도 함수가 예상치 못한 동작을 하지 않고 예외를 던짐
- 코드가 자체적으로 검사되어 단위 테스트를 줄일 수 있음
- 스마트 캐스트 기능 활용 가능
상황에 따른 제한 방법
Argument 제한
숫자를 argument로 받을 때 양의 정수여야 하거나, 필드의 값이 입력되어 있는지, 형식이 올바른지 확인해야 할 경우 같이 제한을 걸어야 할 때가 있다.
이럴 때 일반적으로 require 함수를 사용한다.
require 함수
- 제한 확인
- 만족하지 않을 경우 IllegalArgumentException 발생
- 일반적으로 함수의 가장 앞단에 위치
- 람다를 활용해 메시지 정의 가능
fun factorial(n: Int): Long {
require(n >= 0) { "Cannot calculate factorial of $n"}
// ...
}
fun sendEmail(user: User, message: String) {
requireNotNull(user.email)
require(isValidEmail(user.emai))
// ...
}
상태 제한
어떤 조건을 만족할 때만 함수 사용하게 하려면 check 함수를 사용할 수 있다.
예시로는 아래의 경우가 있다.
- 어떤 객체가 미리 초기화되어 있어야만
- 사용자가 로그인 했을때만
- 객체를 사용할 수 있는 시점에만
check 함수
- 지정된 예측을 만족하지 못할 때 사용
- 만족하지 않을 경우IllegalStateException 발생
- require 뒤에 위치
- 람다를 활용해 메시지 정의 가능
- 사용하면 안되는 상황에서 해당 함수를 사용하는 것을 방지
fun speak(text: String) {
check(isInitialized)
// ...
}
fun getUserInfo(): UserInfo {
checkNotNull(token) {"user is not authorized to access this resource"}
// ...
}
스스로 구현한 내용 확인
함수가 올바르게 구현 됐다면 확실히 참을 반환하는 코드에 대해 단위테스트를 할 때 assert 함수를 사용한다.
class StackTest {
@Test
fun `Stack pops correct number of elements`() {
val stack = Stack(20) { it }
val ret = stack.pop(10)
assertEquals(10, ret.size)
}
@Test
fun pop(num: Int = 1): List<T> {
// ..
assert(ret.size == num)
return ret
}
}
assert 함수
- 스스로 구현한 내용을 확인할 때 사용
- 코틀린/JVM에서만 활성화
- 테스트 할 때만 활성화 되므로 프로덕션 환경에서 오류가 발생하지 않음 → check 활용
- 장점
- 코드 자체를 점검
- 모든 상황에 대해 테스트 가능
- 실행 시점에 정확히 어떻게 되는지 확인 가능
- 실제 코드가 더 빠르게 실패할 수 있도록 만듬
- 코틀린(자바)에서는 잘 사용되지 않음
nullability 확인
elvis 연산자를 활용해 쉽게 nullable을 확인할 수 있다.
fun sendEmail(person: Person, message: String) {
val email: String = person.email ?: run {
log("no email address")
return
}
}
설명한 방법들 이외에도 throw를 활용해 직접 예외를 던질 수도 있다.
스마트 캐스팅
코틀린은 require와 check 함수로 true가 반환되면 그 뒤로도 true라고 가정한다. 이를 통해 스마트 캐스트가 작동한다.
class Person(val email: String?)
fun sendEmail(person: Person, message: String) {
requireNotNull(person.email)
val email: String = person.email
}
위처럼 어떤 필드를 언팩할 때 사용할 수 있다.
item 6. 사용자 정의 오류보다는 표준 요류를 사용하라
많은 상황에서 예외 상황을 나타내는 표준 라이브러리의 오류가 없기 때문에 사용자 정의 오류를 사용했다.
하지만 가능하면 최대한 표준 라이브러리의 오류를 재사용하자!
→ 다른 사람들이 API를 더 쉽게 이해할 수 있음
많이 사용되는 예외는 아래와 같다.
- IllegalArgumentException, IllegalStatementException
- IndexOutOfBoundException
- 인덱스 파라미터의 값이 범위를 벗어날 때
- 컬렉션 또는 배열과 함께 사용
- ConcurrentModificationException
- 사용자가 사용하려 했던 메서드가 현재 객체에서 사용할 수 없다는 것을 나타냄
- 이 오류보다 클래스에 해당 메서드를 삭제하는 것이 좋음
- NoSuchElementException
- 사용하려 했던 요소가 존재하지 않음을 나타냄
item 7. 결과 부족이 발생할 경우 null과 Failure를 사용하라
조건에 맞는 요소가 없거나, 파싱한 텍스트로 객체를 만들지 못할 때와 같이 결과를 만들어내지 못하는 상황이 있다.
이랬을 때 조치 방법은 크게 두가지이다.
- null 또는 실패를 나타내는 sealed 클래스 반환
- 예외를 던짐
null 또는 sealed 클래스를 반환하자
예외는 특별히 잘못된 상황을 나타내는 수단이기 때문에 정보를 전달하는 방법으로 사용해서는 안된다.
- 코틀린의 모든 예외는 unchecked 예외
- 예외 처리를 하지 않을 수 있음
- 문서에 잘 드러나지 않음
- 사용시 파악하기 힘듬
- 명시적인 테스트만큼 빠르게 동작하지 않음
- try-catch 블록 내부에 코드를 배치하면 컴파일러의 최적화가 제한됨
반면 null 또는 Failure는 예상되는 오류를 표현하기 적절하다.
public fun <T> Array<out T>.getOrNull(index: Int): T? {
return if (index >= 0 && index <= lastIndex) get(index) else null
inline fun <reified T> String.readObject(): Result<T> {
// ...
if(incorrectSign) return Failure(JsonParsingException())
}
// ...
return Success(result)
}
Result 같은 공용체를 리턴하면 when 표현식을 사용해 처리할 수 있다.
val age = when(person) {
is Success -> person.age
is Failure -> -1
}
null값과 sealed result 클래스는 명시적으로 처리해야하며, 애플리케이션의 흐름을 중지 하지도 않는다.
추가적인 정보를 전달해야 한다면 → sealed result
그렇지 않으면 → null
Result 프로퍼티 및 확장 함수
Result - Kotlin Programming Language
kotlinlang.org
item 8. 적절하게 null을 처리하라
null이 가질 수 있는 여러가지 의미
- 값이 부족하다 (기본적 의미)
- 값이 설정되지 않거나 삭제됨
- 적절하게 변환 불가 (ex. toIntOrNull)
- 주어진 조건에 맞는 요소가 없음 (ex. firstOrNull)
→ nullable 객체는 사람이 처리하기 때문에 null의 의미가 명확해야 함
nullable 타입 처리 방법
- ?. ,스마트 캐스팅, elvis 연산자
- 오류를 던짐
- nullable 타입이 반환되지 않게 리팩토리
?., 스마트 캐스팅, elvis 연산자로 처리
가장 안전한 방법이다. 특히 elvis 연산자의 오른쪽에 return 또는 throw를 포함한 모든 표현식이 허용되어 자유도가 높다.
스마트 캐스팅은 코틀린의 규약 기능(contracts)을 지원한다. 이 기능으로 스마트 캐스팅이 가능하다.
if (!name.isNullOrBlank()) {
printf(name.toUpperCase())
}
코틀린 규약 기능
코틀린 contracts는 개발자와 컴파일러 간의 약속이다.
런타임 동안 예상할 수 있는 것에 대해 컴파일러에게 힌트를 제공해 더 스마트한 결정과 최적화를 가능하게 한다.
예를 들어 함수가 성공적으로 반환되면 주어진 인수가 null이 아니라는 것을 정의하는 계약을 추가할 수 있다.
null 체크와 함께 사용
@ExperimentalContracts
fun validate(request: Request?) {
contract {
returns() implies (request != null)
}
if (request == null) {
throw IllegalArgumentException("Undefined request")
}
// ...
}
위 함수는 request가 null이 아님을 보장한다. 즉, 이 함수가 성공적으로 반환되면 request는 null이 아니라는 것을 컴파일러에게 알려준다.
타입 체크와 함께 사용
data class MyEvent(val message: String)
@ExperimentalContracts
fun isInterested(event: Any?): Boolean {
contract {
returns(true) implies (event is MyEvent)
}
return event is MyEvent
}
위 함수는 event가 MyEvent 타입임을 보장한다. 즉, 이 함수가 true를 반환하면 event는 MyEvent 타입이라는 것을 컴파일러에게 알려준다.
람다 함수 호출 횟수를 명시하는 경우
inline fun <T> customRun(block: () -> T): T {
contract {
callsInPlace(block, AT_MOST_ONCE)
}
return block()
}
위 함수는 람다 block이 최대 한번만 호출됨을 보장한다.
예외 던지기
다른 개발자가 어떤 코드를 보고 “당연히 ~~할 것이다”라 생각할 수 있다. 그런데 이 부분에 문제가 발생할 경우에 오류를 강제로 발생시켜 주는 것이 좋다.
다음은 오류를 강제로 발생시키는 예시이다.
- throw
- !!
- requireNotNull
- checkNotNull
!! 이슈
not-null assertion(!!)을 사용하면 자바처럼 NPE가 발생할 수 있다.
현재 확실하지만 미래의 상황도 확실히 보장할 수는 없기 때문에 좋은 방법이 아니다.
의미 없는 nullability 피하기
nullability는 처리해야 하므로 추가 비용이 발생할 수 밖에 없다.
따라서 null은 실제로 어떤 메시지를 알려주는 용도로 사용해야 한다.
nullability를 피할 때 사용할 수 있는 몇 가지 방법이다.
- 클래스에서 nullability에 따라 여러 함수를 제공 (ex. get과 getOrNull)
- 클래스 생성 이후 확실히 값 설정이 된다는 보장이 있으면 lateinit, notNull 델리게이트 사용
- 빈 컬렉션 대신 null 반환 지양
- nullable enum과 None enum의 의미를 잘 구별하여 사용
lateinit 프로퍼티와 notNull 델리게이트
클래스 생성 중 초기화 할 수 없는 프로퍼티를 가지는 클래스가 있다.
이럴 경우 lateinit 한정자를 사용할 수 있다. lateinit 한정자는 프로퍼티가 이후 설정될 것임을 명시하는 한정자이다.
class UserControllerTest {
private lateinit var controller: UserController
@BeforeEach
fun init() {
controller = UserController()
}
}
lateinit이 붙은 프로퍼티가 초기화 되기 전에 사용될 경우 예외가 발생해 미리 그 사실을 알 수 있다.
lateinit은 다음 장점을 가진다.
- 언팩하지 않아도 됨
- 어떤 의미를 나타내기 위해 nullable로 만들 수 있음
- 프로퍼티가 초기화된 이후에 초기화되지 않은 상태로 돌아갈 수 없음
주의할 점은 JVM에서 Int, Double과 같은 기본 타입과 연결된 타입으로 프로퍼티를 초기화해야 하는 경우 사용하지 못한다. 이 경우에는 Delegates.notNull을 사용한다.
class Doctor() {
private var doctorId: Int by Delegates.notNull()
// ...
}
item 9. use를 사용해 리소스를 닫아라
더이상 필요하지 않을 때 close 메서드를 통해 명시적으로 닫아야 하는 리소스들이 있다.
이런 리소스들은 AutoCloseable을 상속받는 Closeable 인터페이스를 구현하고 있다. GC는 레퍼런스가 없는 것을 확인한 후 이런 리소스들을 처리하지만 비용이 많이 들어간다. 따라서 close를 명시적으로 호출하는 것이 좋다.
close 메소드로 리소스를 닫을 때 try-catch를 많이 사용하지만 복잡하고, 리소스를 닫을 때의 예외를 처리하지 않는다. 또, try 블록과 finally 블록 내부에 오류가 발생하면 둘 중 하나만 전파된다.
둘 다 전파시키는 함수로 use라는 함수를 활용할 수 있다.
fun countCharactersInFile(path: String): Int {
val reader = BufferedReader(FileReader(path))
reader.use {
return reader.lineSequence().sumBy { it.length }
}
// 람다로도 가능
// BufferedReader(FileReader(path)).use { reader -> return reader...}
}
한 줄씩 읽어 들일 때 활용하는 useLines도 제공한다.
fun countCharactersInFile(path: String): Int {
File(path).useLines { lines ->
return lines.sumBy { it.length }
}
}
item 10: 단위 테스트를 만들어라
- 개발자는 내부적으로 올바르게 작동하는지 확인하는 것이 아니라, 사용자 관점에서 외부적으로 제대로 작동하는지 확인하는 테스트를 만들어야 함
- 이때 단위 테스트가 필요
단위 테스트
단위 테스트는 일반적으로 아래 내용을 확인한다.
- 일반적 use case
- 사용될 것이라 예상되는 일반적인 부분
- 일반적 오류 케이스와 잠재적 문제
- 제대로 동작하지 않을 것이라 예상되는 일반적인 부분
- 과거에 문제가 발생한 부분
- Edge case와 잘못된 argument
- case 1) Int의 경우 Int.MAX_VALUE
- case 2) nullable의 경우 null 또는 null 값으로 채워진 객체
- case 3) 피보나치 수를 음의 정수 argument를 통해 구하는 경우
단위 테스트의 장점
- 요소가 제대로 동작하는지 빠른 피드백 가능
- 회귀 테스트 쉬움
- 수동으로 테스트하기 어려운 것들도 확인 가능
- 테스트 잘 된 요소 신뢰 가능
- 리펙터링의 근거로 사용
단위 테스트의 단점
- 단위 테스트를 만드는 시간 소요
- but, 디버깅 시간과 버그 찾는 시간을 줄여주므로 장기적으로 보면 시간이 절약됨
- 테스트 활용이 가능하도록 코드 조정
- 좋은 테스트를 만드는 것이 어려움
- 개발 과정에 대한 이해 필요
- 단위 테스트를 올바르게 하는 방법을 배워야 함
단위 테스트 하는 방법을 알고 있어야 하는 중요한 코드
- 복잡한 부분
- 빈번한 수정 및 리펙터링이 일어나는 코드
- 비즈니스 로직
- 공용 api
- 문제가 자주 발생하는 부분
- 수정해야 하는 프로덕션 버그
'Kotlin' 카테고리의 다른 글
[이펙티브 코틀린] 가독성 (0) | 2024.02.21 |
---|---|
[kotlin] 코틀린 코루틴 테스트 (0) | 2024.01.07 |
[kotlin] Flow (0) | 2024.01.07 |
[kotlin] Select (0) | 2024.01.07 |
[kotlin] Channel (0) | 2024.01.07 |