예시로 알아보는 코틀린의 장점

개붕이·2024년 9월 25일

Tech

목록 보기
2/5
post-thumbnail

코틀린이란?

Scala, Groovy, Jython(Python) 과 같이 JVM 기반의 프로그래밍 언어이다. JetBrain 사에서 오픈소스 그룹을 만들어 개발하였다.

특징

  1. 표현력과 간결함 : 매우 간결한 문법을 제공함.
  2. 안전한 코드 : 변수 선언시에 널 허용, 불허용을 구분해서 선언할 수 있음
  3. 상호 운용성 : JVM 기반의 프로그래밍 언어이므로 자바와 100% 호환 가능
  4. 구조화 동시성 : 코루틴(coroutine) 기법을 통해서 비동기 프로그래밍을 간소화 할 수 있다.

사용 예시 4가지

생성시 값이 검증되는 객체 만들기

값을 표현하는 Value Object (이하 VO) 를 만들 때 입력 값을 변환하거나 검증이 필요한 경우 로직을 매번 호출하게 된다면 검증 누락의 가능성이 존재함.
고로 코틀린의 다양한 기능을 활용하여 검증을 보장할 수 있음.

@JvmInline
value class CarNumber(val input: String)

value : wrapper 클래스, 하나의 불변 클래스만 가질 수 있음


아래의 규칙이 존재한다고 가정

  • 공백 포함 금지
  • 자동차 번호 형식은 아래 중 하나여야 함.
    • 12가1234
    • 123가1234
    • 서울1가1234
    • 서울12가1234

CarNumber 인스턴스 생성 시에 아래의 과정을 거치도록 만들어야함.

  • 공백 제거
  • 지역명은 주어진 목록 내에서만 사용 가능
  • 지역명이 없는 경우 앞 숫자는 2~3 자리 뒤 숫자는 4자리
  • 지역명이 있는 경우 앞 숫자는 1~2 자리 뒤 숫자는 4자리

구현 예시

@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
)

실제 스프링 코드에 적용해서 보자면, 이와같이 정보를 받아왔을 때 인스턴스를 생성하기만 해도 검증이 가능하다는 것

Null 확인 여부를 확실하게

값의 불변성, 스마트 캐스팅(컴파일러가 불변값들에 대해서 타입 체크, 명시적 형변환을 트래킹하고, 필요한 경우 묵시적 형변환을 추가하는 코틀린의 주요 기능) 을 통해
로직 작성 및 파악에 용이


기존 요청을 다시 보내는 retryLogic 이 있다고 가정, 이는 아래 역할을 수행함.

  1. 전달받은 카테고리 코드에 걸맞는 retryUseCase 탐색
    • 찾지 못한 경우 예외 발생
  2. 찾은 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 retryUseCaseUseCase? 타입의 값. 이는 해당 케이스가 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\"")
    }

Data Class 를 사용한 간단하고 효율적인 단위 테스트

코틀린의 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()
    • 완전히 동일한 dataclass 인스턴스를 하나생성함. 기존 인스턴스와 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 에 대해서 알아보았다. 성장하는 언어인 만큼 포텐셜도 높다고 평가되며 나의 레거시 프로젝트들도 코틀린을 통해 리팩토링 해보아야 겠다.

출처

profile
based on the records

0개의 댓글