Kotlin - 에러 방지(2): 자원 해제('use()'), Logging, 단위 테스트
in DEV on Kotlin, Use(), Uselines(), Foreachline(), Authcloseable, Logging, Kotlin.test, Junit5
이 포스트에서는 자원 해제, 로깅, 단위 테스트에 대해 알아본다.
소스는 github 에 있습니다.
목차
개발 환경
- 언어: kotlin 1.9.23
- IDE: intelliJ
- SDK: JDK 17
- 의존성 관리툴: Gradle 8.5
1. 자원 해제: use()
1.5. 자원 관리를 위한
inline
된 람다 사용:withLock()
,use()
와 함께 보면 도움이 됩니다.
1.4. 자원 해제: finally
에서 본 것처럼 finally
절은 try 블록이 어떤 식으로 끝나는지 관계없이 자원을 해제해줄 수 있다.
하지만 자원을 닫는 도중 예외가 발생한다면 결국 finally
절 안에서 다른 try 블록이 필요해지고, 예외가 발생하여 이를 처리하는 상황이라면 finally
블록의 try 안에서 예외가 발생한 경우 나중에 발생한 예외가 최초 발생했던 예외를 감춰서는 안된다.
결국 finally
를 사용하여 자원을 해제하면 제대로 자원을 해제하는 과정이 매우 복잡해진다.
이런 복잡도를 낮추기 위해 코틀린은 use()
를 제공한다.
use()
함수는 닫을 수 있는 자원을 제대로 해제해주고, 자원 해제 코드를 직접 작성하지 않아도 되게 해준다.
즉, use()
를 사용하면 자원을 생성하는 시점에서 자원 해제를 확실히 보장할 수 있으며, 자원 사용을 끝낸 시점에 직접 자원 해제 코드를 작성하지 않아도 된다.
자바의
try-with-resources
와 비슷한 기능임
try-with-resoueces
에 대한 내용은try-with-resources
개선 을 참고하세요.
use()
는 자바의 AutoCloseable
인터페이스를 구현하는 모든 객체에 작용할 수 있다.
use()
는 인자로 받은 코드 블록을 실행한 후, 그 블록을 어떻게 빠져나왔는지와 관계없이 객체의 close()
를 호출한다.
use()
는 모든 예외를 다시 던져주기 때문에 프로그램에서는 여전히 예외를 처리해야 한다.
예를 들어 File
에서 한 줄씩 문자열을 읽고 싶다면 BufferedReader
에 대해 use()
를 사용하면 된다.
import java.io.File
var targetDir = File("DataFiles")
class DataFile(val fileName: String) : File(targetDir, fileName) {
init {
if (!targetDir.exists()) {
targetDir.mkdir()
}
}
fun erase() {
if (exists()) {
delete()
}
}
fun reset(): File {
erase()
createNewFile()
return this
}
}
fun main() {
// result.txt 의 내용은 아래와 같음
// result
// #ok
// ddd
val result =
DataFile("result.txt")
.bufferedReader()
.use { it.readLines().first() }
println(result) // result
}
result.txt
result
#ok
ddd
1.1. useLines()
useLines()
는 File 객체를 열고, 파일에서 모든 줄을 읽은 후에 대상 함수 (보통은 람다) 에 모든 줄을 전달한다.
모든 작업은 useLines()
에 전달된 람다 내부에서 이루어진다.
useLines()
는 파일을 닫고 람다가 반환하는 결과를 반환한다.
DataFile 클래스는 1. 자원 해제:
use()
에서 작성한 클래스임
fun main() {
val result1 =
DataFile("result.txt")
.useLines {
it.joinToString()
}
val result2 =
DataFile("result.txt")
.useLines { it ->
// 왼쪽의 it 은 파일에서 읽은 줄을 모아둔 컬렉션을 가리키고,
// 오른쪽의 it 은 개별적인 줄을 뜻함
it.filter { "#" in it }.first()
}
val result3 =
DataFile("result.txt")
.useLines { lines -> // 이렇게 람다에 이름을 붙이면 it 이 많아서 생기는 혼동을 줄일 수 있음
lines.filter { line ->
"#" in line
}.first()
}
println(result1) // result, #ok, ddd
println(result2) // #ok
println(result3) // #ok
}
1.2. forEachLine()
forEachLine()
은 파일의 각 줄에 대해 작업을 쉽게 적용할 수 있다.
forEachLine()
에 전달된 람다는 Unit 을 반환한다.
이 말은 이 람다 안에서는 원하는 일을 부수 효과를 통해 달성해야한다는 의미이다.
함수형 프로그래밍에서는 부수 효과보다는 결과를 반환하는 쪽을 더 선호하므로 useLines()
이 forEachLine()
보다 더 함수형인 접근 방법이다.
하지만 간단한 처리를 해야 하는 경우는 forEachLine()
이 더 빠른 방법이 될 수 있다.
DataFile 클래스는 1. 자원 해제:
use()
에서 작성한 클래스임
fun main() {
val result =
DataFile("result.txt").forEachLine {
if (it.startsWith("#")) {
println("it's $it")
it
}
}
println(result)
}
1.3. AutoCloseable
인터페이스를 구현하여 커스텀 클래스 생성
AutoCloseable
인터페이스를 구현하면 use()
에 사용할 수 있는 커스텀 클래스를 생성할 수 있다.
AutoCloseable
인터페이스
package java.lang;
public interface AutoCloseable {
void close() throws Exception;
}
마지막에 close()
를 호출하는 것을 알 수 있다.
class Usable : AutoCloseable {
fun func() = println("func()~")
override fun close() = println("close()~")
}
fun main() {
// func()~
// close()~
Usable().use { it.func() }
}
2. Logging
kotlin-logging 오픈 소스 로깅 패키지를 사용하여 로깅을 할 수 있다.
대부분의 경우 로거를 파일 영역에 정의해서 같은 파일에 있는 모든 컴포넌트가 로거를 사용할 수 있도록 한다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web") {
exclude(group = "ch.qos.logback", module = "logback-classic")
}
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Logging
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.slf4j:slf4j-simple:2.0.13")
}
import mu.KLogging
private val log = KLogging().logger
fun main() {
val msg = "hello~"
log.trace(msg)
log.debug(msg)
log.info(msg) // [main] INFO mu.KLogging - hello~
log.warn { msg } // [main] WARN mu.KLogging - hello~
log.error { msg } // [main] ERROR mu.KLogging - hello~
}
kotlin-logging 라이브러리는 SLF4J 위에 만든 퍼사드(facade)이다. SLF4J 자체는 여러 가지 로깅 프레임워크 위에 만들어진 추상화이다.
위에선 slf4j-simple
을 구현으로 선택하였다.
퍼사드 (facade)
복잡한 서브 시스템을 단순하게 제공하기 위한 인터페이스 제공
즉, 시스템 구현 내부 내용을 몰라도 쉽게 사용할 수 있도록 하는 패턴
디폴트 설정이 info level 이상 출력하도록 되어있기 때문에 trace() 와 debug() 는 출력되지 않는다.
3. 단위 테스트
테스트에 대한 좀 더 상세한 내용은 Spring Boot - 스프링 부트 테스트 를 참고하세요.
단위 테스트는 프로젝트를 빌드할 때마다 실행되기 때문에 실행 속도가 아주 빨라야 한다.
많은 단위 테스트 프레임워크가 있지만 JUnit
이 가장 유명하다.
코틀린 전용 단위 테스트 프레임워크도 있다. 코틀린 표준 라이브러리에는 여러 테스트 라이브러리에 대한 facade 를 제공하는 kotlin.test
가 포함되어 있다.
따라서 어느 한 라이브러리에 구속될 필요가 없다.
kotlin.test
를 사용하려면 build.gradle.kt 의 dependencies 에 아래 내용을 추가한다.
그러면 코틀린 플러그인이 자동으로 코틀린 테스트 관련 의존 관계를 처리해준다.
// for Kotlin test
implementation(kotlin("test"))
단위 테스트안에서는 여러 예상 동작을 검증하기 위해 단언문 함수를 실행한다.
단언문 함수로는 실제값과 예상값을 비교하는 assertEquals()
, 첫 번째 파라메터로 들어오는 Boolean 식이 참인지 검증하는 assertTrue()
등이 있다.
아래 코드에서 test 로 시작하는 함수들이 단위 테스트이다.
import kotlin.test.assertEquals
import kotlin.test.assertTrue
fun fortyTwo() = 42
// 단위 테스트
fun testFortyTwo(n: Int = 42) {
assertEquals(
expected = n,
actual = fortyTwo(),
message = "incorrect,",
)
}
fun allGood(b: Boolean = true) = b
fun testAllGood(b: Boolean = true) {
assertTrue(actual = allGood(b), message = "not good")
}
fun main() {
testFortyTwo()
testAllGood()
// Exception in thread "main" java.lang.AssertionError: incorrect,. Expected <11>, actual <42>.
testFortyTwo(11)
// Exception in thread "main" java.lang.AssertionError: not good
// testAllGood(false)
}
실패한 단언문은 AssertionError 를 발생시킨다.
3.1. kotlin.test
kotlin.test 는 assert 로 시작하는 여러 함수를 제공한다.
assertEquals()
,assertNotEquals()
assertTrue()
,assertFalse()
assertNull()
,assertNotNull()
assertFails()
,assertFailsWith()
kotlin.test 에 있는 expect()
함수는 코드 블록을 실행하고 그 결과를 예상값과 비교한다.
expect() 시그니처
inline fun <@OnlyInputTypes T> expect(expected: T, message: String?, block: () -> T) {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
assertEquals(expected, block(), message)
}
아래는 expect()
를 사용하여 3. 단위 테스트 의 testFortyTwo() 를 다시 구성한 예시이다.
assertFails()
, assertFailsWith()
의 예시도 들어있다.
import kotlin.test.assertFails
import kotlin.test.assertFailsWith
import kotlin.test.expect
fun fortyTwo2() = 42
fun testFortyTwo2(n: Int = 42) {
expect(expected = n, message = "Incorrect,") { fortyTwo2() }
}
fun main() {
testFortyTwo2()
// Exception in thread "main" java.lang.AssertionError:
// Incorrect,. Expected <11>, actual <42>.
// testFortyTwo2(11)
assertFails { testFortyTwo2(11) }
// Exception in thread "main" java.lang.AssertionError:
// Expected an exception to be thrown, but was completed successfully.
// assertFails { testFortyTwo2() }
assertFailsWith<AssertionError> { testFortyTwo2(11) }
// Exception in thread "main" java.lang.AssertionError:
// Expected an exception of class java.lang.AssertionError to be thrown, but was completed successfully.
// 던져진 예외의 타입까지 검사함
assertFailsWith<AssertionError> { testFortyTwo2() }
}
3.2. 테스트 프레임워크: JUnit5, @Test
이번 예시는 kotlin.test 의 하부 라이이브러리로 JUnit5 를 사용한다.
프로젝트에 JUnit5 를 추가하려면 build.gradle.kts 의 dependencies 에 아래를 추가한다.
dependencies {
...
// For tests in Tests
testImplementation(kotlin("test-junit5"))
testImplementation("org.junit.platform:junit-platform-launcher")
testImplementation("org.junit.platform:junit-platform-engine")
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
kotlin.test 는 일반적으로 사용되는 함수에 대해 facade 를 제공한다.
예) kotlin.test 의 assertEquals()
는 org.junit.jupiter.api.Assertions 클래스의 assertEquals()
를 사용
코틀린은 정의와 식에 애너테이션을 허용하는데 예를 들어 @Test
애너테이션은 일반 함수를 테스트 함수로 변경해준다.
테스트 러너를 실행하면 러너가 모든 클래스를 뒤지면서
@Test
애너테이션이 붙은 함수를 찾아 실행하기 때문에@Test
가 일반 함수를 테스트 함수로 지정해주는 효과가 있음
단,@Test
애너테이션이 붙은 함수를 main() 에서 호출하면 그냥 일밤 함수처럼 실행됨
아래는 3. 단위 테스트 의 fortyTwo() 와 allGood() 을 @Test
를 사용하여 작성한 예시이다.
/test/unitTesting/SampleTest.kt
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.test.expect
class SampleTest {
@Test
fun testFortyTwo() {
expect(expected = 42, message = "Incorrect,") { fortyTwo() }
}
@Test
fun testAllGood() {
assertTrue(actual = allGood(), "not good")
}
}
intelliJ 는 실패한 테스트만 따로 실행할 수 있게 해준다.
아래는 On, Off, Paused 3가지 상태가 있고, start(), pause(), resume(), finish() 가 이 상태를 제어하는 코드 예시이다.
import assu.study.kotlinme.chap06.unitTesting.State.Off
import assu.study.kotlinme.chap06.unitTesting.State.On
import assu.study.kotlinme.chap06.unitTesting.State.Paused
enum class State { On, Off, Paused }
class StateMachine {
var state: State = Off
private set
private fun transition(
new: State,
current: State = On,
) {
if (new === Off && state !== Off) {
state = Off
} else if (state == current) {
state = new
}
}
fun start() = transition(On, Off)
fun pause() = transition(Paused, On)
fun resume() = transition(On, Paused)
fun finish() = transition(Off)
}
setter 를 private 를 지정하는 private set 에 대한 내용은 9. 프로퍼티 접근자:
field
를 참고하세요.
위의 StateMachine 을 테스트하기 위해 테스트 클래스 안에 sm 프로퍼티를 만들어본다.
테스트 러너는 다른 테스트가 실행될 때마다 새로운 StateMachineTest 객체를 생성한다.
/test/unitTesting/StateMachineTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
class StateMachineTest {
val sm = StateMachine()
@Test
fun start() {
sm.start()
assertEquals(expected = State.On, actual = sm.state)
}
@Test
fun `pause and resume`() {
sm.start()
sm.pause()
assertEquals(expected = State.Paused, actual = sm.state)
sm.resume()
assertEquals(expected = State.On, actual = sm.state)
sm.pause()
assertEquals(expected = State.Paused, actual = sm.state)
}
}
Teamcity 같은 CI 서버를 사용하면 자동으로 모든 테스트가 실행되고, 잘못된 경우 알림을 받을 수 있다.
아래와 같이 여러 프로퍼티가 있는 데이터 클래스가 있다고 하자.
enum class Language {
Kotlin,
Java,
Go,
Python,
}
data class Leaner(
val id: Int,
val name: String,
val surname: String,
val language: Language,
)
테스트 데이터를 생성하기 위한 유틸리티 함수를 추가하면 도움이 될 때가 많은데 특히 테스트를 진행하는 과정에서 디폴트 값이 동일한 객체를 많이 생성해야 하는 경우가 특히 그렇다.
아래에서 makeLeaner() 는 디폴트 값을 지정한 객체를 여러 개 생성한다.
/test/unitTesting/LeanerTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
fun makeLeaner(
id: Int,
language: Language = Language.Kotlin,
name: String = "Test Name: $id",
surname: String = "Test Surname: $id",
) = Leaner(id, name, surname, language)
class LeanerTest {
@Test
fun `single Learner`() {
val leaner = makeLeaner(10, Language.Java)
assertEquals(expected = "Test name: 10", actual = leaner.name)
}
@Test
fun `multiple Learners`() {
val learners = (1..9).map(::makeLeaner)
assertTrue(learners.all { it.language == Language.Kotlin })
}
}
all()
에 대한 내용은 3.6.any()
,all()
,maxByOrNull()
을 참고하세요.
참고 사이트 & 함께 보면 좋은 사이트
본 포스트는 브루스 에켈, 스베트라아 이사코바 저자의 아토믹 코틀린 과 드리트리 제메로프, 스베트라나 이사코바 저자의 Kotlin In Action 을 기반으로 스터디하며 정리한 내용들입니다.
- 아토믹 코틀린
- 아토믹 코틀린 예제 코드
- Kotlin In Action
- Kotlin In Action 예제 코드
- Kotlin Github
- 코틀린 doc
- 코틀린 lib doc
- 코틀린 스타일 가이드
- kotlin-logging
- Teamcity