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