Scala, Groovy, Jython(Python) 과 같이 JVM 기반의 프로그래밍 언어이다. JetBrain 사에서 오픈소스 그룹을 만들어 개발하였다.
값을 표현하는 Value Object (이하 VO) 를 만들 때 입력 값을 변환하거나 검증이 필요한 경우 로직을 매번 호출하게 된다면 검증 누락의 가능성이 존재함.
고로 코틀린의 다양한 기능을 활용하여 검증을 보장할 수 있음.
@JvmInline
value class CarNumber(val input: String)
value : wrapper 클래스, 하나의 불변 클래스만 가질 수 있음
아래의 규칙이 존재한다고 가정
CarNumber 인스턴스 생성 시에 아래의 과정을 거치도록 만들어야함.
@JvmInline
value class CarNumber(val value: String) {
companion object {
private val CAR_NUMBER_REGEX = Regex("(\\d{2,3})([가-힣])(\\d{4})")
private val OLD_CAR_NUMBER_REGEX = Regex("^([가-힣]{1,2})?(\\d{1,2})([가-힣])(\\d{4})\$")
private val LOCATION_NAMES = setOf("서울", "부산", "대구", "인천", "광주", "대전", "울산", "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주")
fun from(carNumber: String): CarNumber {
return CarNumber(carNumber.removeSpaces()) // 공백은 제거
}
}
init {
validateCarNumber(value)
}
private fun validateCarNumber(number: String) {
val oldCarNumberMatch = OLD_CAR_NUMBER_REGEX.matchEntire(number)
if (oldCarNumberMatch != null) {
val (location, _, _) = oldCarNumberMatch.destructured
require(location in LOCATION_NAMES) { "알 수 없는 등록 지역입니다." } // 예전 자동차 번호의 지역명이 목록에 없을 경우 예외 발생
} else {
require(CAR_NUMBER_REGEX.matches(number)) { "자동차 번호 형식을 확인해 주세요." } // 자동차 번호 형식과 다를 경우 예외 발생
}
}
}
CarNumber.from() 팩토리 메서드를 호출하여 하이픈과 공백 제거init {} 블록으로 인스턴스 생성 시에 로직 호출위의 코드처럼 구현했을 때 에러가 발생할 수 있는데
val carNumber = CarNumber("123 가 4567") // 공백이 제거되지 않아 에러 발생
생성자를 사용해서 인스턴스를 생성했을 때 공백 제거가 적절히 이뤄지지 않아 에러가 발생함. (생성자를 private 로 막는다고 하더라도 from 함수를 사용해야하기 때문에 사용자 입장에서는 불편)
이러한 상황을 해결할 수 있는 invoke 함수가 존재하는데 이를 오버로딩하여서 구현하게 되면 아래와 같은 코드가 완성된다.
@JvmInline
value class CarNumber private constructor(val value: String) {
companion object {
// ...
@JsonCreator
fun from(carNumber: String): CarNumber {
return CarNumber(carNumber.removeSpacesAndHyphens())
}
operator fun invoke(carNumber: String): CarNumber = from(carNumber)
}
}
// 사용 예시
val carNumber = CarNumber("123 가 4567") // 실제로는 생성자 대신 from을 호출
이러면 사용자는 생성자를 선언하듯 객체를 생성하지만 실제로는 from 함수를 실행하게 된다.
이와 같이 코틀린으로 코드를 작성할 경우에 VO 에게 검증 및 변환 수행이 가능해짐
@RestController
class CarController {
@PostMapping("/car")
fun carInformation(@RequestBody request: CarInformationRequest) {
// ...
}
}
data class CarInformationRequest(
val carNumber: CarNumber
)
실제 스프링 코드에 적용해서 보자면, 이와같이 정보를 받아왔을 때 인스턴스를 생성하기만 해도 검증이 가능하다는 것
값의 불변성, 스마트 캐스팅(컴파일러가 불변값들에 대해서 타입 체크, 명시적 형변환을 트래킹하고, 필요한 경우 묵시적 형변환을 추가하는 코틀린의 주요 기능) 을 통해
로직 작성 및 파악에 용이
기존 요청을 다시 보내는 retryLogic 이 있다고 가정, 이는 아래 역할을 수행함.
retryUseCase 탐색retryUserCase 를 사용하여 재 조회 요청을 보냄fun retryLogic(
categoryCode: CategoryCode,
transactionId: String,
request: RetryRequest
) {
val retryUseCase: UseCase? = activeUseCases().firstOrNull { it.type == categoryCode }
requireNotNull(retryUseCase) { "현재 가능하지 않은 재조회 요청입니다." }
// 별도 비지니스 로직
return retryUseCase.getPrice(transactionId, request)
}
val retryUseCase 는 UseCase? 타입의 값. 이는 해당 케이스가 UseCase 일 수도 있고, Null 일 수도 있음을 의미함
코틀린에서는 별도로 자료형 뒤에 ? 로 Nullable 을 표현해주지 않는 이상 기본적으로 값에 null 이 들어갈 수 없음.
이렇게 값을 받아오면 다음으로는 NullCheck 를 해줌. 익숙한 방법으로는 if 를 통한 NullCheck 가 가능하지만 코틀린에서는 requireNotNull 이라는 계약(Contracts)을 사용해 똑같이 NullCheck 가 가능함
하지만 검증 이후로도 값이 바뀐다면 이는 null 이 아니라고 확신할 수가 없는데 코틀린은 다름. 코틀린은 val 로 선언한 값은 불변이기 때문에 한 번 null 여부를 확인하게 되면
이후로 계속해서 값에 대한 보장이 가능함.
스마트 캐스팅을 통해 코틀린 컴파일러에서는 UseCase? 로 선언된 값이 검증에 성공한다면 이를 UseCase 객체로 취급하게 됨. 덕분에 불변값이 검증에 통과한다면 null 이 아님을 확인하고 로직 전개가 가능함. 이로 인해 안정적인 서비스 운영이 가능
참고 자료의 필자는 이러한 경험을 예시로 들어주었음
보험 서비스를 개발하며 공통으로 사용하게 되는 여러 유틸리티 코드가 있는데요. 특히 primitive 타입 필드나 String에 대해 무언가 조작을 하는 경우가 많습니다. 이러한 내용을 모아서 insurance-common이라는 라이브러리를 만들게 되었습니다.
코틀린에서는 이런 별도 라이브러리를 만들 때 활용할 수 있는 확장 함수와 object-declaration 가 존재한다고 함.
코틀린의 확장 함수는 별도의 디자인 패턴이나 특정 클래스에 대한 상속이 없이도 메서드를 확장할 수 있게 만들어줌. 객체 선언(Object-Declaration) 은 특정 인스턴스 상태에 독립적인 내용을 담을 때 사용함. 언어 레벨에서
싱글톤으로 선언되며, 같은 내용이 불필요하게 여러 번 생성되는 것을 방지하기 좋음
예시로 문자열 중 특정 패턴이 보일 경우 마스킹하는 maskingName 이라는 메서드를 작성한다고 해보자
private val maskingNameRegex = Regex("(?i)Name=[^,)]++[,)]")
/**
* 문자열 내 "Number="와 "," 또는 ")"으로 둘러싸인 숫자를 첫 자리만 제외하고 마스킹 처리
*/
fun maskingName(input: String): String {
return input.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }
}
// 사용 예시
val maskedValue = maskingName(userName)
위 코드를 코틀린의 확장함수를 이용하여 리팩토링 한다면 이와 같이 수정할 수 있음
fun String.maskingName() = this.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }
// 사용 예시
val maskedValue = userName.maskingName()
String 이라는 클래스에 마스킹 메서드를 확장, 이 메서드는 특정 인스턴스의 상태와는 무관한 메서드임. 싱글톤으로 선언 후 재활용되는 것이 리소스 관리 상 유리.
여기서 코틀린의 ObjectDeclaration 을 사용하여 싱글톤으로 활용할 수 있음 (자바에서는 생성자를 private 로 설정하고 정적 필드에 객체를 저장해놓은 뒤 getInstance() 로 객체를 반환하는 방식으로 싱글톤을 구현함.)
object StringUtils {
private val maskingNameRegex = Regex("(?i)Name=[^,)]++[,)]")
/**
* 문자열 내 "Name="과 "," 또는 ")"으로 둘러싸인 문자열을 첫 자리만 제외하고 마스킹 처리
*/
fun String.maskingName() = this.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }
}
@DisplayName("문자열 내 Number=와 , 또는 )으로 둘러싸인 문자열을 첫 자리만 제외하고 마스킹 처리")
@Test
fun maskingName() {
// given
val name = "김춘식"
val text = "userName=$name, result=\"success\""
val lowerText = "name=$name, result=\"success\""
// when
val result = text.maskingName()
val lowerResult = lowerText.maskingName()
// then
val expectedMaskedResult = "김*"
assertThat(result).isEqualTo("userName=$expectedMaskedResult, result=\"success\"")
assertThat(lowerResult).isEqualTo("name=$expectedMaskedResult, result=\"success\"")
}
코틀린의 DataClass 는 말그대로 데이터를 표현하기 위한 클래스임. 일반 클래스와 다르게 equals(), hashCode() 가 재정의되며 그 밖에 copy() 등 다른 메서드도 자동으로 생성됨. DTO 등 데이터를 나타내는 클래스를 작성할 때 유용함
data class UserInformation(
val name: String,
val age: Int,
val birthDate: LocalDate,
val address: String,
val gender: Gender,
val isDisplay: Boolean
) {
enum class Gender {
MALE,
FEMALE;
}
init {
require(age >= 18)
}
}
위의 DTO 를 테스트하고자 하는 데이터로 지정하고 나이 검증 여부를 테스트 해보자. 나이가 18세 미만인 경우에 예외를 발생시켜야함
class WhateverTest() {
@Test
fun `나이가 18세 미만이면 IllegalArgumentException을 던진다`() {
assertThrows<IllegalArgumentException> {
val userInformation = UserInformation(
name = "정카펀",
age = 17,
birthDate = LocalDate.of(2022, 12, 19),
address = "카카오 판교 아지트",
gender = UserInformation.Gender.MALE,
isDisplay = true
)
}
}
@Test
fun `나이가 18세 이상이면 예외를 던지지 않는다`() {
assertDoesNotThrow {
val userInformation = UserInformation(
name = "정카펀",
age = 18,
birthDate = LocalDate.of(2022, 12, 19),
address = "카카오 판교 아지트",
gender = UserInformation.Gender.MALE,
isDisplay = true
)
}
}
}
문제점으로는 1. 코드가 반복되고 2. 대상이 불분명한 문제점이 존재함.
어떤 값이 예외 발생의 원인이 되는지 테스트 코드만 보고서는 파악하기가 어려움. 이를 해결하기 위해서 copy() 메서드를 사용할 수 있음
copy()equals() 비교 시에 서로 같음copy() 호출 시에 파라미터에 값을 지정해줄 수 있음. 이 경우 파라미터 값만 지정한 값으로 설정하여 복사됨공통 부분을 따로 빼고 테스트 코드를 재작성 해보자.
class WhateverTest() {
@Test
fun `나이가 18세 미만이면 IllegalArgumentException을 던진다`() {
val invalidAge = 17
assertThrows<IllegalArgumentException> {
val userInformation = successUserInformation.copy(age = invalidAge)
}
}
@Test
fun `나이가 18세 이상이면 예외를 던지지 않는다`() {
val validAge = 18
assertDoesNotThrow {
val userInformation = successUserInformation.copy(age = validAge)
}
}
private val successUserInformation = UserInformation(
name = "정카펀",
age = 28,
birthDate = LocalDate.of(2022, 12, 19),
address = "카카오 판교 아지트",
gender = UserInformation.Gender.MALE,
isDisplay = true
)
}
반복되는 코드를 줄이고 테스트 대상을 명확히 드러냄.
자바와 완벽 호환되며 간결함, 향상된 성능 등 여러가지 장점을 가진 Kotlin 에 대해서 알아보았다. 성장하는 언어인 만큼 포텐셜도 높다고 평가되며 나의 레거시 프로젝트들도 코틀린을 통해 리팩토링 해보아야 겠다.