woowa Kotlin

코프링 맀우 μ•Œμ€ μ²΄ν•˜κΈ°

μ•„λž˜ ν…Œν¬ μ„Έλ―Έλ‚˜ λ‚΄μš©μ„ μš”μ•½ν•œ λ‚΄μš©μž…λ‹ˆλ‹€.

Kopring Sample Repository

Kotlin?

코틀린은 λ©€ν‹° ν”Œλž«νΌ μ–Έμ–΄

μ½”λ”© μ»¨λ²€μ…˜

컀밋 λ©”μ„Έμ§€

ktlint

μ½”λ“œμ˜ μ»¨λ²€μ…˜ κ·œμ•½

  • ktlintλŠ” Kotlin Coding Conventionκ³Ό Android Kotlin Style Guideλ₯Ό 기본으둜 λ”°λ₯΄κ³  μžˆλ‹€.

plugins {
    ...
    id("org.jlleitschuh.gradle.ktlint") version "10.3.0" // Kotlin μ½”λ“œ μŠ€νƒ€μΌμ„ μžλ™μœΌλ‘œ κ²€μ‚¬ν•˜κ³  ν¬λ§·νŒ…ν•˜λŠ” 도ꡬ   
}

ktlint: μ½”ν‹€λ¦° style, convention κ°€μ΄λ“œ 적용

  • ⭐️ IntelliJ IDEA formatterλ₯Ό ktlint에 맞게 μ„€μ •(ν•΄λ‹Ή ν”„λ‘œμ νŠΈλ§Œ):

    • $ ./gradlew ktlintApplyToIdea

  • IntelliJ μ‚¬μš© λͺ¨λ“  ν”„λ‘œμ νŠΈμ— formatter 적용(λͺ¨λ“  IDEA ν”„λ‘œμ νŠΈμ—):

    • $ ./gradlew ktlintApplyToIdeaGlobally

  • μˆ˜λ™μœΌλ‘œ ktlintλ₯Ό μ΄μš©ν•˜μ—¬ μŠ€νƒ€μΌ 체크: $ ./gradlew clean ktlintCheck

    • ktlInt Check: Tasks β†’ verification β†’ ktlintCheck

  • ⭐️ Git hook을 톡해 ktlint μ„€μ •: 컀밋 전에 ktlintCheck ν…ŒμŠ€νŠΈ μ‹€ν–‰

    $ mkdir .git/hooks
    $ ./gradlew addKtlintCheckGitPreCommitHook

Kotlin/JVM

Basic

코틀린은 μ•ˆμ „ν•˜κ²Œ μ½”λ”©ν•˜λ„λ‘ μ˜μ‹ν•˜μ§€ μ•Šμ•„λ„ μ•ˆμ „ν•˜κ²Œ μ½”λ”©λ˜λ„λ‘ 지원

  • @NotNull, @Nullable, final μžλ™ 적용

  • μ•ˆμ „ν•˜κ²Œ μ½”λ”©ν•˜κ³  μ‹Άμ§€ μ•Šμ„ 경우 μ˜μ‹μ μœΌλ‘œ μ½”λ”©


μ½”ν‹€λ¦° 컴파일

μ½”ν‹€λ¦° μ½”λ“œλ§Œ μ‚¬μš©ν•  경우

μ½”ν‹€λ¦°, μžλ°” μ½”λ“œλ₯Ό ν•¨κ»˜ μ‚¬μš©ν•  경우

  • λ‘¬λ³΅μ—μ„œ μƒμ„±λœ μžλ°” μ½”λ“œλŠ” μ½”ν‹€λ¦° μ½”λ“œμ—μ„œ μ ‘κ·Ό λΆˆκ°€


Item 1. μ½”ν‹€λ¦° ν‘œμ€€ 라이브러리

μ½”ν‹€λ¦° ν‘œμ€€ 라이브러리λ₯Ό 읡히고 μ‚¬μš©ν•˜κΈ°

μ½”ν‹€λ¦°μ—μ„œ μžλ°”μ™€ κ΄€λ ¨λœ importλ₯Ό μ΅œλŒ€ν•œ μ œκ±°ν•˜λ €κ³  λ…Έλ ₯ν•˜μž

ν‘œμ€€ 라이브러리λ₯Ό μ‚¬μš©ν•˜λ©΄ κ·Έ μ½”λ“œλ₯Ό μž‘μ„±ν•œ μ „λ¬Έκ°€μ˜ 지식과 μ—¬λŸ¬λΆ„λ³΄λ‹€ μ•žμ„œ μ‚¬μš©ν•œ λ‹€λ₯Έ ν”„λ‘œκ·Έλž˜λ¨Έλ“€μ˜ κ²½ν—˜μ„ ν™œμš©ν•  수 μžˆλ‹€.

  • AS-IS

    import java.util.Random
    import java.util.concurrent.ThreadLocalRandom
    
    Random.nextInt()
    ThreadLocalRandom.current().nextInt()
  • TO-BE

    import kotlin.random.Random
    
    Random.nextInt() // thread safe

코틀린은 읽기 μ „μš© μ»¬λ ‰μ…˜κ³Ό λ³€κ²½ κ°€λŠ₯ν•œ μ»¬λ ‰μ…˜μ„ ꡬ별해 제곡

  • μΈν„°νŽ˜μ΄μŠ€λ₯Ό λ§Œμ‘±ν•˜λŠ” μ‹€μ œ μ»¬λ ‰μ…˜μ΄ λ°˜ν™˜

  • λ”°λΌμ„œ ν”Œλž«νΌλ³„ μ»¬λ ‰μ…˜μ„ μ‚¬μš© κ°€λŠ₯


Item 2. μžλ°”λ‘œ μ—­μ»΄νŒŒμΌ

μžλ°”λ‘œ μ—­μ»΄νŒŒμΌν•˜λŠ” μŠ΅κ΄€ 듀이기

μ½”ν‹€λ¦° μˆ™λ ¨λ„λ₯Ό λ†’μ΄λŠ” κ°€μž₯ 쒋은 방법은 μ½”ν‹€λ¦°μœΌλ‘œ μž‘μ„±ν•œ μ½”λ“œκ°€ μžλ°”λ‘œ μ–΄λ–»κ²Œ ν‘œν˜„λ˜λŠ”μ§€ ν™•μΈν•˜κΈ°

  • μ—­μ»΄νŒŒμΌλ‘œ 예기치 μ•Šμ€ μ½”λ“œ 생성을 λ°©μ§€

  • κΈ°μ‘΄ μžλ°” λΌμ΄λΈŒλŸ¬λ¦¬μ™€ ν”„λ ˆμž„μ›Œν¬λ₯Ό μ‚¬μš©ν•˜λ©° λ¬Έμ œκ°€ λ°œμƒν•  λ•Œ λΉ λ₯΄κ²Œ 확인 κ°€λŠ₯

Kotlin to Java

  • Tool > Kotlin > Show Kotlin Bytecode > Decompile

Java to Kotlin

  • Convert Java File to Kotlin File


Item 3. λ‘¬λ³΅λŒ€μ‹  데이터 클래슀

둬볡 λŒ€μ‹  데이터 클래슀 μ‚¬μš©ν•˜κΈ°

  • 데이터 클래슀λ₯Ό μ‚¬μš©ν•˜λ©΄ μ»΄νŒŒμΌλŸ¬κ°€ equals(), hashCode(), toString(), copy() 등을 μžλ™ 생성

    • μ£Ό μƒμ„±μžμ˜ λ§€κ°œλ³€μˆ˜λ₯Ό κΈ°μ€€μœΌλ‘œ μƒμ„±ν•˜κ³ , μ£Ό μƒμ„±μžμ—λŠ” ν•˜λ‚˜ μ΄μƒμ˜ λ§€κ°œλ³€μˆ˜κ°€ μžˆμ–΄μ•Ό 함

    • λ§€κ°œλ³€μˆ˜λŠ” val λ˜λŠ” var둜 ν‘œμ‹œ

  • 데이터 ν΄λž˜μŠ€μ— property λ₯Ό μ„ μ–Έν•˜λŠ” μˆœκ°„ ν•΄λ‹Ή property λŠ” field, Getter, Setter, μƒμ„±μž νŒŒλΌλ―Έν„° μ—­ν• 

    val person = Person(name = "Aaron", age = 29)
    val person2 = person.copy(age = 25)
    
    data class Person(val name: String, val age: Int)

plugins

plugins {
    val kotlinVersion = "1.6.21"
    id("org.springframework.boot") version "2.3.3.RELEASE" // μŠ€ν”„λ§ λΆ€νŠΈλ₯Ό μ‚¬μš©ν•˜κΈ° μœ„ν•œ ν”ŒλŸ¬κ·ΈμΈ
    id("io.spring.dependency-management") version "1.0.10.RELEASE" // μŠ€ν”„λ§ λΆ€νŠΈ ν”„λ‘œμ νŠΈμ—μ„œ μ˜μ‘΄μ„± 관리λ₯Ό μ‰½κ²Œ ν•  수 μžˆλ„λ‘ μ§€μ›ν•˜λŠ” ν”ŒλŸ¬κ·ΈμΈ
    kotlin("jvm") version kotlinVersion // Kotlin JVM을 μ‚¬μš©ν•˜λŠ” ν”„λ‘œμ νŠΈλ₯Ό μœ„ν•œ ν”ŒλŸ¬κ·ΈμΈ
    kotlin("plugin.spring") version kotlinVersion
    kotlin("plugin.jpa") version kotlinVersion
    id("org.jlleitschuh.gradle.ktlint") version "10.3.0" // Kotlin μ½”λ“œ μŠ€νƒ€μΌμ„ μžλ™μœΌλ‘œ κ²€μ‚¬ν•˜κ³  ν¬λ§·νŒ…ν•˜λŠ” 도ꡬ
    id("org.asciidoctor.jvm.convert") version "3.3.2" // Asciidoctorλ₯Ό μ‚¬μš©ν•΄μ„œ λ¬Έμ„œλ₯Ό μƒμ„±ν•˜κ³  λ³€ν™˜ν•  수 μžˆλ„λ‘ μ§€μ›ν•˜λŠ” ν”ŒλŸ¬κ·ΈμΈ
    id("org.flywaydb.flyway") version "7.12.0" // Flywayλ₯Ό μ‚¬μš©ν•˜μ—¬ λ°μ΄ν„°λ² μ΄μŠ€ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜μ„ μžλ™μœΌλ‘œ 관리할 수 있게 ν•΄μ£ΌλŠ” ν”ŒλŸ¬κ·ΈμΈ
}

Spring Boot

final 클래슀

  • @SpringBootApplication은 @Configuration을 ν¬ν•¨ν•˜κ³ , μŠ€ν”„λ§μ€ 기본적으둜 CGLIB을 μ‚¬μš©ν•΄μ„œ @Configuration ν΄λž˜μŠ€μ— λŒ€ν•œ ν”„λ‘μ‹œλ₯Ό 생성

  • ν•˜μ§€λ§Œ, final ν΄λž˜μŠ€λŠ” μƒμ†μ΄λ‚˜ μ˜€λ²„λΌμ΄λ“œκ°€ λΆˆκ°€ν•˜λ―€λ‘œ ν”„λ‘μ‹œ 생성이 λΆˆκ°€

  • 상속을 ν—ˆμš©ν•˜κ³  μ˜€λ²„λΌμ΄λ“œλ₯Ό ν—ˆμš©ν•˜λ €λ©΄ open λ³€κ²½μž μΆ”κ°€ ν•„μš”

  • Spring Framework 5.2 λΆ€ν„°λŠ” @Configuration 의 proxyBeanMethod μ˜΅μ…˜μ„ 톡해 ν”„λ‘μ‹œ 생성 λΉ„ν™œμ„±ν™” κ°€λŠ₯

All-open 컴파일러 ν”ŒλŸ¬κ·ΈμΈ

  • 코틀린은 λ‹€μ–‘ν•œ 컴파일러 ν”ŒλŸ¬κ·ΈμΈμ„ 제곡

  • all-open 컴파일러 ν”ŒλŸ¬κ·ΈμΈμ€ μ§€μ •ν•œ μ• λ„ˆν…Œμ΄μ…˜μ΄ μžˆλŠ” ν΄λž˜μŠ€μ™€ λͺ¨λ“  멀버에 open λ³€κ²½μžλ₯Ό μΆ”κ°€

  • μŠ€ν”„λ§μ„ μ‚¬μš©ν•  경우 all-open 컴파일러 ν”ŒλŸ¬κ·ΈμΈμ„ λž˜ν•‘ν•œ kotlin-spring 컴파일러 ν”ŒλŸ¬κ·ΈμΈμ„ μ‚¬μš© κ°€λŠ₯

    • @Component, @Transactional, @Async 등이 기본적으둜 μ§€μ •

  • File > Project Structure > Project Settings > Modules > Kotlin > Compiler Plugins μ—μ„œ μ§€μ •λœ μ• λ„ˆν…Œμ΄μ…˜ 확인 κ°€λŠ₯

plugins {
    kotlin('plugins.spring') version "1.5.21"
}

allOpen {
    annotation("com.my.Annotation")
}

Item 4. μ§€μ—° μ΄ˆκΈ°ν™”

ν•„λ“œ μ£Όμž…μ΄ ν•„μš”ν•˜λ©΄ μ§€μ—° μ΄ˆκΈ°ν™”λ₯Ό μ‚¬μš©ν•˜μž

μ½”ν‹€λ¦°μ—μ„œ lateinit λ³€κ²½μžλ₯Ό 뢙이면 ν”„λ‘œνΌν‹°λ₯Ό λ‚˜μ€‘μ— μ΄ˆκΈ°ν™”ν•  수 μžˆλ‹€. λ‚˜μ€‘μ— μ΄ˆκΈ°ν™”ν•˜λŠ” ν”„λ‘œνΌν‹°λŠ” 항상 var

@Autowired
private lateinit var objectMapper: ObjectMapper

jackson-module-kotlin

  • jackson은 기본적으둜 역직렬화 과정을 μœ„ν•΄ λ§€κ°œλ³€μˆ˜κ°€ μ—†λŠ” μƒμ„±μžκ°€ ν•„μš”ν•˜μ§€λ§Œ

    • μ½”ν‹€λ¦°μ—μ„œ λ§€κ°œλ³€μˆ˜κ°€ μ—†λŠ” μƒμ„±μžλ₯Ό λ§Œλ“€κΈ° μœ„ν•΄ λͺ¨λ“  λ§€κ°œλ³€μˆ˜μ— κΈ°λ³Έ μΈμžκ°€ ν•„μš”

  • jackson-module-kotlin은 λ§€κ°œλ³€μˆ˜κ°€ μ—†λŠ” μƒμ„±μžκ°€ 없더라도 직렬화와 역직렬화λ₯Ό 지원

    • 코틀린은 λ§€κ°œλ³€μˆ˜κ°€ μ—†λŠ” μƒμ„±μžλ₯Ό λ§Œλ“€κΈ° μœ„ν•΄ μƒμ„±μžμ˜ λͺ¨λ“  λ§€κ°œλ³€μˆ˜μ— κΈ°λ³Έ μΈμžκ°€ ν•„μš”

      // Unit Test의 경우 ObjectMapper 직접 호좜 ν•„μš”
      val mapper1 = jacksonObjectMapper()
      val mapper2 = ObjectMapper().registerKotlinModule()
      dependencies {
          implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
      }

Kotlin Annotation

데이터 ν΄λž˜μŠ€μ— propertyλ₯Ό μ„ μ–Έν•˜λŠ” μˆœκ°„ field, Getter, Setter, μƒμ„±μž νŒŒλΌλ―Έν„° μ—­ν•  μˆ˜ν–‰

  • @param.JsonProperty : parameter에 μ‚¬μš©λ  이름

  • @get.JsonProperty : getter에 μ‚¬μš©λ  이름


Item 5. λ³€κ²½ κ°€λŠ₯μ„± μ œν•œ

λ³€κ²½ κ°€λŠ₯성을 μ œν•œν•˜μž

μ½”ν‹€λ¦° ν΄λž˜μŠ€μ™€ 멀버가 final인 κ²ƒμ²˜λŸΌ 일단 val둜 μ„ μ–Έν•˜κ³  ν•„μš” μ‹œ var둜 λ³€κ²½ν•˜μž.

  • μƒμ„±μž 바인딩을 μ‚¬μš©ν•˜λ €λ©΄ @EnableConfigurationProperties

    • λ˜λŠ” @ConfigurationPropertiesScan μ‚¬μš©

    @ConfigurationProperties("application")
    @ConstructorBinding
    data class ApplicationProperties(val url: String)
    
    @ConfigurationPropertiesScan
    @SpringBootApplication
    class Application
  • private property and backing property

    • 곡개 API 와 κ΅¬ν˜„ μ„ΈλΆ€ 사항 ν”„λ‘œνΌν‹°λ‘œ λ‚˜λˆŒ 경우

    • private ν”„λ‘œνΌν‹° μ΄λ¦„μ˜ μ ‘λ‘μ‚¬λ‘œ 밑쀄을 μ‚¬μš©(backing property)

  • JVMμ—μ„œλŠ” κΈ°λ³Έ getter, setterκ°€ μžˆλŠ” private ν”„λ‘œνΌν‹°μ— λŒ€ν•΄ ν•¨μˆ˜ 호좜 μ˜€λ²„ν—€λ“œλ₯Ό λ°©μ§€ν•˜λ„λ‘ μ΅œμ ν™”

    @OneToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE], orphanRemoval = true)
    @JoinColumn(name = "session_id", nullable = false)
    private val _students: MutableSet<Student> = students.toMutableSet() //backing property
    val student: Set<Student> // public
        get() = _students

Persistence

No-arg 컴파일러 ν”ŒλŸ¬κ·ΈμΈ

  • JPAμ—μ„œ μ—”ν‹°ν‹° 클래슀λ₯Ό μƒμ„±ν•˜λ €λ©΄ λ§€κ°œλ³€μˆ˜κ°€ μ—†λŠ” μƒμ„±μžκ°€ ν•„μš”

  • no-arg 컴파일러 ν”ŒλŸ¬κ·ΈμΈμ€ μ§€μ •ν•œ μ• λ„ˆν…Œμ΄μ…˜μ΄ μžˆλŠ” ν΄λž˜μŠ€μ— λ§€κ°œλ³€μˆ˜κ°€ μ—†λŠ” μƒμ„±μžλ₯Ό μΆ”κ°€

    • JPA, Kotlinμ—μ„œ 직접 ν˜ΈμΆœν•  수 μ—†μ§€λ§Œ λ¦¬ν”Œλž™μ…˜μ„ μ‚¬μš©ν•˜μ—¬ 호좜 κ°€λŠ₯

  • kotlin-spring Compiler Pluginκ³Ό λ§ˆμ°¬κ°€μ§€λ‘œ JPAλ₯Ό μ‚¬μš©ν•˜λŠ” 경우 no-arg 컴파일러 ν”ŒλŸ¬κ·ΈμΈμ„ λž˜ν•‘ν•œ kotlin-jpa Compiler Plugin μ‚¬μš© κ°€λŠ₯

    • JPA, Kotlin μ‚¬μš© μ‹œ μžλ™μœΌλ‘œ kotlin-jpa Compiler Plugin μΆ”κ°€

    • @Entity, @Embeddable, @MappedSuperclass κ°€ 기본적으둜 μ§€μ •

plugins {
    kotlin('plugins.spring') version "1.5.21"
    kotlin('plugins.jpa') version "1.5.21"
}

allOpen { // JPA μ§€μ—°λ‘œλ”© μ μš©μ„ μœ„ν•΄ ν”„λ‘μ‹œ 생성 λͺ©μ μœΌλ‘œ μ„€μ •
    annotation("javax.persistence.Entity")
    annotation("javax.persistence.MappedSuperclass")
}

Item 6. μ—”ν‹°ν‹°

엔티티에 데이터 클래슀 μ‚¬μš©μ„ ν”Όν•˜μž.

μ–‘λ°©ν–₯ μ—°κ΄€ κ΄€κ³„μ˜ 경우 toString(), hashcode() 둜 λ¬΄ν•œ μˆœν™˜ μ°Έμ‘°κ°€ λ°œμƒ


Item 7. μ‚¬μš©μž μ§€μ • getter

μ‚¬μš©μž μ§€μ • getterλ₯Ό μ‚¬μš©ν•˜μž.

JPA 에 μ˜ν•΄ μΈμŠ€ν„΄μŠ€ν™” 될 λ•Œ, μ΄ˆκΈ°ν™” 블둝이 ν˜ΈμΆœλ˜μ§€ μ•ŠμœΌλ―€λ‘œ, μ˜μ†ν™”ν•˜μ§€ μ•ŠλŠ” ν•„λ“œλŠ” μ‚¬μš©μž μ§€μ • getterλ₯Ό μ‚¬μš©ν•˜μž.

/**
 * AS-IS: μ΄ˆκΈ°ν™”λœ ν”„λ‘œνΌν‹°
 * μ˜μ†ν™”ν•˜μ§€ μ•Šμ„ λͺ©μ μ΄μ—ˆμ§€λ§Œ, JPA 에 μ˜ν•΄ μΈμŠ€ν„΄μŠ€ν™” 될 λ•Œ
 * null을 ν—ˆμš©ν•˜μ§€ μ•Šμ•˜μŒμ—λ„ λΆˆκ΅¬ν•˜κ³ , μ΄ˆκΈ°ν™” 블둝이 ν˜ΈμΆœλ˜μ§€ μ•ŠμœΌλ―€λ‘œ null이 λ“€μ–΄κ°€κ²Œ λœλ‹€.
 */
@Transient
val fixed: Boolean = startDate.until(endDate).year < 1

---

/**
 * TO-BE: μ‚¬μš©μž μ§€μ • getter
 * λ”°λΌμ„œ, μ‚¬μš©μž μ§€μ • getter λ₯Ό μ •μ˜ν•΄μ„œ ν”„λ‘œνΌν‹°μ— μ ‘κ·Όν•  λ•Œλ§ˆλ‹€ ν˜ΈμΆœλ˜λ„λ‘ ν•˜μž.
 * λ’·λ°›μΉ¨ν•˜λŠ” ν•„λ“œ(backing property)κ°€ μ‘΄μž¬ν•˜μ§€ μ•ŠμœΌλ―€λ‘œ AccessType.FIELD 라도 @Transient λΆˆν•„μš”
 */
val fixed: Boolean
  get() = startDate.until(endDate).year < 1 // μΌμ’…μ˜ λ©”μ„œλ“œλ‘œ μƒκ°ν•˜μž

Item 8. Null νƒ€μž… 제거

Null이 될 수 μžˆλŠ” νƒ€μž…μ€ λΉ λ₯΄κ²Œ μ œκ±°ν•˜μž.

Null이 될 수 μžˆλŠ” νƒ€μž…μ„ μ‚¬μš©ν•˜λ©΄ Null κ²€μ‚¬λ‚˜ !! μ—°μ‚°μžκ°€ ν•„μš”ν•˜λ‹€.

  • μ—”ν‹°ν‹° 클래슀의 idλ₯Ό 0 λ˜λŠ” 빈 λ¬Έμžμ—΄λ‘œ μ΄ˆκΈ°ν™”ν•΄μ„œ Null이 될 수 μžˆλŠ” νƒ€μž…μ„ μ œκ±°ν•˜μž.

  • Optional 보닀 Nullable ν•œ νƒ€μž…μ„ μ‚¬μš©ν•΄μ„œ λΆˆν•„μš”ν•œ java import λ₯Ό μ€„μ΄μž.

    interface ArticleRepository : CrudRepository<Article, Long> {
        fun findBySlug(slug: String): Article? // nullable Article
        fun findAllByOrderByAddedAtDesc(): Iterable<Atricle>
    }
    
    interface UserRepository : CrudRepository<User, Long> {
        fun findByLogin(login: String): User?
    }
  • ν™•μž₯ ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•΄ λ°˜λ³΅λ˜λŠ” Null 검사λ₯Ό μ œκ±°ν•  수 μžˆλ‹€.

    fun MemberRepository.getById(id: Long): Member {
        if (id == 0L) {
            return Member.SINGLE
        }
        return findByIdOrNull(id) ?: throw NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 아이디 μž…λ‹ˆλ‹€. id: $id")
    }
    
    interface MemberRepository : JpaRepository<Member, Long>

Test

λ‹¨μœ„ ν…ŒμŠ€νŠΈ

Junit, Mockito 같은 Java 라이브러리둜 ν…ŒμŠ€νŠΈν•˜λ―€λ‘œ Kotlin λ‹€μš΄ μ½”λ“œ μž‘μ„±μ΄ 어렀움

  • μ˜ˆμ™Έ ν…ŒμŠ€νŠΈλŠ” JUnit5의 assertThrows 및 assertDoesNotThrow 같은 Kotlin ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•˜λ©΄ κ°„κ²°

// Java μŠ€νƒ€μΌ
@DisplayName("νšŒμ›μ˜ λΉ„λ°€λ²ˆν˜Έμ™€ λ‹€λ₯Ό 경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€")
@Test
fun authenticate {
    val user = createUser
    assertThatExceptionOfType(UnidentifiedUserException::class.java)
        .isThrownBy { user.authenticate(WRONG_PASSWORD) }
}

// Kotlin μŠ€νƒ€μΌ
@Test
fun `νšŒμ›μ˜ λΉ„λ°€λ²ˆν˜Έμ™€ λ‹€λ₯Ό 경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€`() {
    val user = createUser
    assertThrows<UnidentifiedUserException> {
        user.authenticate(WRONG_PASSWORD)
    }
}

---

// Kotest
"νšŒμ›μ˜ λΉ„λ°€λ²ˆν˜Έμ™€ λ‹€λ₯Ό 경우 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€" {
    val user = createUser()
    shouldThrow<UnidentifiedUserException> { user.authenticate(WRONG_PASSWORD) }
}

ν…ŒμŠ€νŠΈ νŒ©ν† λ¦¬

  • ν…ŒμŠ€νŠΈ ν”½μŠ€μ²˜λ₯Ό λ°˜ν™˜ν•˜λŠ” νŒ©ν† λ¦¬ ν•¨μˆ˜λ₯Ό λ§Œλ“€ 수 μžˆλ‹€.

    • ν…ŒμŠ€νŠΈ ν”½μŠ€μ²˜: ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•œ μ „μ œ 쑰건

  • Kotlin κΈ°λ³Έ 인자λ₯Ό μ‚¬μš©ν•˜λ©΄ λΉŒλ” νŒ¨ν„΄μ²˜λŸΌ λ‹€μ–‘ν•œ 경우λ₯Ό 처리 κ°€λŠ₯

  • κΈ°λ³Έ μΈμžμ™€ 이름 뢙인 인자λ₯Ό 적절히 μ‚¬μš©ν•˜λ©΄ ν…ŒμŠ€νŠΈν•˜λ €λŠ” 관심사λ₯Ό λ“œλŸ¬λ‚΄λŠ” 데 μ‚¬μš© κ°€λŠ₯

createMission(submittable = true) // 과제 μ œμΆœλ¬Όμ„ μ œμΆœν•  수 μžˆλŠ” 과제
createMission(submittable = false) // 과제 μ œμΆœλ¬Όμ„ μ œμΆœν•  수 μ—†λŠ” 과제
createMission(title = "과제1", evaluationId = 1L) // νŠΉμ • 평가에 λŒ€ν•œ 과제
createMission(hidden = false) // μˆ¨κΈ°μ§€ μ•Šμ€ 과제

// κ΄€λ ¨ μ—†λŠ” μΈμžμ—λŠ” 합리적인 기본값을 μ‚¬μš©
fun createMission(
    title: String = MISSION_TITLE,
    description: String = MISSION_DESCRIPTION,
    evaluationId: Long = 1L,
    startDateTime: LocalDateTime = START_DATE_TIME,
    endDateTime: LocalDateTime = END_DATE_TIME,
    submittable: Boolean = true,
    hidden: Boolean = false,
    id: Long = 0L
): Mission {
    return Mission(title, description, evaluationId, startDateTime, endDateTime, submittable, hidden, id)
}

ν…ŒμŠ€νŠΈ ν™•μž₯ ν•¨μˆ˜

ν™•μž₯ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•˜μ—¬ 값을 더 μ‰½κ²Œ ν‘œν˜„

private val Int.ms: Long get() = milliseconds.inWholeMilliseconds

"μ΄ˆλ‹Ή 1회둜 μ œν•œν•  경우 1초 간격 μš”μ²­μ€ μ„±κ³΅ν•œλ‹€" {
    val limiter = RateLimiter(1)
    listOf(0.ms, 1000.ms, 2000.ms).forAll {
        shouldNotThrowAny { limiter.acquire(it) }
    }
}

"μ΄ˆλ‹Ή 1회둜 μ œν•œν•  경우 μ΄ˆλ‹Ή 2번 μš”μ²­ν•˜λ©΄ μ‹€νŒ¨ν•œλ‹€" {
    val limiter = RateLimiter(1).apply {
        acquire(0.ms)
    }
    listOf(100.ms, 500.ms, 900.ms).forAll {
        shouldThrow<ExceededRequestException> { limiter.acquire(it) }
    }
}

ν™•μž₯ν•¨μˆ˜λ‘œ κ²€μ¦ν•˜κ³ μž ν•˜λŠ” 뢀뢄에 가독성 ν–₯상

private fun ApplicantAndFormResponse.hasKeywordInNameOrEmail(keyword: String): Boolean {
    return name.contains(keyword) || email.contains(keyword)
}

...

When("νŠΉμ • ν‚€μ›Œλ“œλ‘œ νŠΉμ • λͺ¨μ§‘에 μ§€μ›ν•œ 지원 정보λ₯Ό μ‘°νšŒν•˜λ©΄") {
    val actual = applicantService.findAllByRecruitmentIdAndKeyword(recruitmentId, keyword)

    Then("지원 정보 및 λΆ€μ •ν–‰μœ„ μ—¬λΆ€λ₯Ό 확인할 수 μžˆλ‹€") {
        actual shouldHaveSize 2
        actual.filter { it.hasKeywordInNameOrEmail(keyword) } shouldHaveSize 2 // ν™•μž₯ν•¨μˆ˜ 적용
        actual[0].isCheater.shouldBeTrue()
        actual[1].isCheater.shouldBeFalse()
    }
}

Kotest μ–΄μ„€μ…˜

πŸ‘ŽπŸ» ν…ŒμŠ€νŠΈ μ–΄μ„€μ…˜

일뢀 μ–΄μ„€μ…˜μ΄ μ‹€νŒ¨ν•˜λ”λΌλ„ ν…ŒμŠ€νŠΈκ°€ μ¦‰μ‹œ μ€‘μ§€λ˜μ§€ μ•Šκ³ , λκΉŒμ§€ 검증

  • μ–΄λ–€ ν”„λ‘œνΌν‹° λ•Œλ¬Έμ— μ‹€νŒ¨ν–ˆλŠ”μ§€ λ°”λ‘œ μ•Œμ•„μ±„κΈ° 어렀움

val excepted = Speaker("Aaron", "Park", 30)
val actual = Speaker("Aaron", "Kim", 28)
assertAll (
    { assertThal(actual.firstName).isEqualTo(expexted.firstName) },
    { assertThal(actual.lastName).isEqualTo(expexted.lastName) },    
    { assertThal(actual.age).isEqualTo(expexted.age) },
)

μΈμŠ€ν„΄μŠ€λ₯Ό 데이터 클래슀둜 λ§Œλ“€κ³  λΉ„κ΅ν•˜λŠ” 것을 ꢌμž₯

val excepted = Speaker("Aaron", "Park", 30)
val actual = Speaker("Aaron", "Kim", 28)
assertThal(actual).isEqualTo(expexted)

주둜 JUnit의 Assertions λŒ€μ‹  읽기 μ‰¬μš΄ AssertJ의 Assertionsλ₯Ό μ‚¬μš©

πŸ‘πŸΌ Kotest μ–΄μ„€μ…˜

  • Kotest μ–΄μ„€μ…˜μ€ κ°„κ²°ν•˜κ³  κΈ°μ‘΄ JUnit5와 혼용 κ°€λŠ₯

  • 슀마트 캐슀트 같은 Kotlin κΈ°λŠ₯을 지원

    • ν•œ 번 슀마트 μΊμŠ€νŒ… λ˜μ—ˆλ‹€λ©΄ null을 닀루지 μ•Šμ•„λ„ λœλ‹€.

val actual = userRepository.findByEmail("aaron@google.com")
actual.sholudNotBeNull() // 슀마트 캐슀트
actual.name shouldBe "λ°•μ§€ν›ˆ"

Kotlinμ—μ„œ Kotestκ°€ κ°€μž₯ 많이 μ‚¬μš©

  • λ‹€μ–‘ν•œ μŠ€νƒ€μΌμ˜ ν…ŒμŠ€νŠΈ λ ˆμ΄μ•„μ›ƒμ„ 제곡

    • κ·Έ μ€‘μ—μ„œλ„ StringSpec을 μ‚¬μš©ν•˜λ©΄ μ• λ„ˆν…Œμ΄μ…˜μ„ μ‚¬μš©ν•˜μ§€ μ•Šμ•„λ„ λœλ‹€.

// JUnit 5
class AuthenticationCodeTest {
    @Test
    fun `인증 μ½”λ“œλ₯Ό μƒμ„±ν•œλ‹€`() {
        val authenticationCode = AuthenticationCode(EMAIL)
        assertAll (
            { assertThal(actual.firstName).isEqualTo(expexted.firstName) },
            { assertThal(actual.lastName).isEqualTo(expexted.lastName) },    
            { assertThal(actual.age).isEqualTo(expexted.age) },
        )
    }
    ...
}

---

// Kotest
class AuthenticationCodeTest : StringSpec({
    "인증 μ½”λ“œλ₯Ό μƒμ„±ν•œλ‹€" {
        val authenticationCode = AuthenticationCode(EMAIL)
        assertSoftly(authenticationCode) {
            code.shouldNotBeNull()
            authenticated.shouldBeFalse()
            createdDateTime.shouldNotBeNull()
        }
    }

    "μ½”λ“œλ₯Ό μΈμ¦ν•œλ‹€" {
        val authenticationCode = AuthenticationCode(EMAIL, VALID_CODE)
        authenticationCode.authenticate(VALID_CODE)
        authenticationCode.authenticated.shouldBeTrue()
    }
		...
})

λͺ¨μ˜ ν…ŒμŠ€νŠΈ

MockK

πŸ‘ŽπŸ» Mockito

  • Mockito final ν΄λž˜μŠ€μ™€ final λ©”μ„œλ“œλŠ” λͺ¨μ˜ λΆˆκ°€

  • Kotlin ν™•μž₯ ν•¨μˆ˜λŠ” Java의 정적 λ©”μ„œλ“œμ΄λ©° MockitoλŠ” 이λ₯Ό μŠ€ν… λΆˆκ°€

  • μ΅œμƒμœ„ ν•¨μˆ˜λŠ” νŠΉμ • ν΄λž˜μŠ€μ— μ†ν•˜μ§€ μ•ŠκΈ° λ•Œλ¬Έμ— μŠ€ν… λΆˆκ°€

πŸ‘πŸΌ MockK

  • DSL 기반 Kotlin λͺ¨μ˜ 라이브러리

  • μ½”λ“œ 기반, μ• λ„ˆν…Œμ΄μ…˜ 기반 λ“± λŒ€λΆ€λΆ„ Mockito와 동일

val recruitmentRepository = mockk<RecruitmentRepository>()
val recruitmentItemRepository = mockk<RecruitmentItemRepository>()

every { recruitmentRepository.save(any()) } returns recruitment
every { recruitmentItemRepository.findByRecruitmentIdOrderByPosition(any()) } returns emptyList()
every { recruitmentItemRepository.deleteAll(any()) } just Runs

slot<MethodParameter>().also { slot ->
    every { it.supportsParameter(capture(slot)) } answers {
        slot.captured.hasParameterAnnotation(LoginUser::class.java)
    }
}

βœ… MockKλ₯Ό μ΄μš©ν•œ 연쇄 μŠ€ν„°λΉ™

MockK둜 정적 ν•¨μˆ˜λ₯Ό λͺ¨μ˜ν•˜μ§€ μ•Šκ³  ν™•μž₯ ν•¨μˆ˜λ₯Ό μŠ€ν…ν•˜μ—¬ λ‚΄λΆ€μ˜ 멀버 ν•¨μˆ˜λ₯Ό μŠ€ν…ν•˜λŠ” 방법

  • 도ꡬ가 κΈ°λŠ₯을 μ œκ³΅ν•œλ‹€κ³  ν•΄μ„œ 항상 μ‚¬μš©ν•΄μ•Ό ν•˜λŠ” 것은 μ•„λ‹ˆλ‹€

  • κ°€μ§œ 객체λ₯Ό μ‚¬μš©ν•˜λŠ” 것도 쒋은 방법

every { recruitmentRepository.getById(any()) } returns createRecruitment(id = recruitmentId)

fun RecruitmentRepository.getById(id: Long): Recruitment = findByIdOrNull(id)
    ?: throw NoSuchElementException("λͺ¨μ§‘이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. id: $id")

// CrudRepositoryExtensions
fun <T, ID> CrudRepository<T, ID>.findByIdOrNull(id: ID): T? =
    findById(id).orElse(null)

πŸ‘ŽπŸ» Junit 5 + Mockito

πŸ‘πŸΌ Kotest + MockK


Kotestλ₯Ό μ΄μš©ν•œ λͺ¨μ˜ ν…ŒμŠ€νŠΈ

BDDλ₯Ό μ§€μ›ν•˜λŠ” BehaviorSpec, DescribeSpec이 쑴재

  • ν…ŒμŠ€νŠΈκ°€ ν•˜λ‚˜μ˜ λ¬Έμ„œλ‘œμ„œ λ”μš± 풍뢀

  • 쀑첩 ν…ŒμŠ€νŠΈλŠ” ν…ŒμŠ€νŠΈ 수λͺ… μ£ΌκΈ°λ₯Ό μ΄ν•΄ν•˜λŠ” 것이 μ€‘μš”

Kotest 수λͺ…μ£ΌκΈ°

  • Given, When, Then 을 μ΄λ£¨λŠ” λͺ¨λ“  것을 beforeSpec, afterSpec으둜 listen

  • Given, When, Then λ§Œμ„ λ³Έλ‹€λ©΄ Container 라고 λΆ€λ₯΄κ³ 

    • beforeContainer, afterContainer λ©”μ„œλ“œλ‘œ 수λͺ…μ£ΌκΈ° 관리 κ°€λŠ₯

  • Then 은 beforeEach, afterEach λ©”μ„œλ“œλ‘œ 수λͺ…μ£ΌκΈ° 관리 κ°€λŠ₯

Given("νŠΉμ • λͺ¨μ§‘에 λŒ€ν•œ μž„μ‹œ μ§€μ›μ„œλ₯Ό μž‘μ„±ν•œ μ§€μ›μžκ°€ 있고 λͺ¨μ§‘ ν•­λͺ©μ΄ μžˆλŠ” 경우") {
  ...
  When("미제좜 ν•­λͺ©μ΄ μžˆλŠ” μƒνƒœμ—μ„œ μ§€μ›μ„œλ₯Ό μ΅œμ’… μ œμΆœν•˜λ©΄") {
     ..
     Then("μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") {
				...
      }
  }

  When("ν•­λͺ©μ΄ λΉ„μ–΄ μžˆλŠ” μƒνƒœμ—μ„œ μ§€μ›μ„œλ₯Ό μ΅œμ’… μ œμΆœν•˜λ©΄") {
			...
      Then("μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") {
				...
      }
  }
}

Given("νŠΉμ • νšŒμ›μ΄ νŠΉμ • λͺ¨μ§‘에 λŒ€ν•΄ μž‘μ„±ν•œ μž„μ‹œ μ§€μ›μ„œκ°€ μžˆλŠ” 경우") {
		...
    When("ν•΄λ‹Ή μ§€μ›μ„œλ₯Ό μ‘°νšŒν•˜λ©΄") {
				...
        Then("μ§€μ›μ„œλ₯Ό 확인할 수 μžˆλ‹€") {
					...
        }
    }
}

Kotest의 격리 λͺ¨λ“œ

KotestλŠ” SingleInstance, InstancePerLeaf, InstancePerTest μ„Έ κ°€μ§€ 값이 쑴재

  • 기본은 SingleInstance 이며 ν…ŒμŠ€νŠΈ 상황에 맞게 격리 λͺ¨λ“œλ₯Ό 선택

  • ex. ν…ŒμŠ€νŠΈ 클래슀 전체에 λͺ¨μ˜ 객체λ₯Ό λ§Œλ“œλŠ” 데 λΉ„μš©μ΄ 많이 λ“ λ‹€λ©΄ SingleInstanceλ₯Ό μ„ νƒν•˜κ³ , clearMocks()을 ν˜ΈμΆœν•˜λŠ” 것이 더 λ‚˜μ„ 수 μžˆλ‹€.

톡합 ν…ŒμŠ€νŠΈ

Spring 5.2λΆ€ν„° @TestConstructor μ‚¬μš© μ‹œ μƒμ„±μžλ₯Ό ν†΅ν•œ μ£Όμž…μ΄ κ°€λŠ₯

  • KotestλŠ” Spring 톡합 ν…ŒμŠ€νŠΈλ₯Ό μ§€μ›ν•˜κΈ° μœ„ν•΄ SpringExtenstion을 제곡

  • λ³„λ„μ˜ μ• λ„ˆν…Œμ΄μ…˜ 없이 μƒμ„±μž μ£Όμž…μ΄ κ°€λŠ₯ν•˜λ©°, SpringExtenstion을 ν†΅ν•΄μ„œ νŠΈλžœμž­μ…˜ 둀백도 κ°€λŠ₯

인수 ν…ŒμŠ€νŠΈ

μ‚¬μš©μž κ΄€μ μ—μ„œ κΈ°λŠ₯이 μ˜¬λ°”λ₯΄κ²Œ μž‘λ™ν•˜λŠ”μ§€ ν™•μΈν•˜κΈ° μœ„ν•œ ν…ŒμŠ€νŠΈ

  • 일반적으둜 μ‚¬μš©μž μŠ€ν† λ¦¬μ— 따라 Given-When-Then μŠ€νƒ€μΌλ‘œ μž‘μ„±

  • μ—°κ΄€ 관계가 λ³΅μž‘ν• μˆ˜λ‘ ν…ŒμŠ€νŠΈ ν”½μŠ€μ²˜ 생성이 μ–΄λ €μ›Œμ§

인수 ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•œ DSL

  • μ—°κ΄€ 관계λ₯Ό 도메인에 νŠΉν™”λœ μ–Έμ–΄λ‘œ ν‘œν˜„ν•˜κ³ , ν•„μš”ν•œ 데이터가 μƒμ„±λ˜λ„λ‘ ν•  수 μžˆλ‹€.

  • μ½”λ“œλ₯Ό 처음 μ ‘ν•˜λŠ” μ‚¬λžŒλ“€μ˜ 도메인 ν•™μŠ΅μ— λ§Žμ€ 도움이 λœλ‹€.

뢀둝

Kover

  • IntelliJ, JaCoCo μ—μ΄μ „νŠΈλ₯Ό μ‚¬μš©ν•˜λŠ” Kotlin μ½”λ“œ 컀버리지 도ꡬ

  • Kotlin이 μƒμ„±ν•œ λ°”μ΄νŠΈμ½”λ“œλ‘œ μΈ‘μ •ν•˜λ©° 인라인 ν•¨μˆ˜μ™€ 같이 JaCoCo둜 μΈ‘μ •ν•  수 μ—†λŠ” μ˜μ—­λ„ μΈ‘μ •

Last updated