BLOG.JETBRAINS.COM
Case Study: Why Kakao Pay Chose Kotlin for Backend Development
This blog post is a JetBrains translation of the original post by katfun.joy, a backend developer at Kakao Pay. Kakao Pay leverages Kotlin with Spring for backend development across various services, including its insurance offerings. Check out Kakao Pays story to see how Kotlin helps it address the complex requirements of the insurance industry and enables the reliable development and operation of services.Note: At Kakao Pay, each employees have their own nicknames to represent themselves. They address each other by calling their nickname instead of the actual name. Kuyho Chung, the writer of this post, uses katfun.joy as his nickname.katfun.joy, a backend developer at Kakao Pay, uses Kotlin to develop stable backend services. In this post, he introduces some of Kotlins most impressive features, such as value validation object creation, safe null handling, utility library creation using extension functions, and efficient unit tests, based on direct usage experience. If you are a backend developer interested in developing stable web services, it is recommended to read this. Reviewers One-Line Reviewyun.cheese: Gain Kotlin tips that can be directly applied to actual service development through the vivid experiences of a developer deeply immersed in Kotlins charm!noah.you: A compelling Kotlin use case to address the complex requirements of insurance! There are many good examples, so take a look!ari.a: Why does Kakao Pay use Kotlin for the backend development of its services? For those curious, we recommend Katfuns Kotlin use case!Getting StartedHello. I am Katfun, and I develop and operate services such as insurance comparison recommendations and car management services at Kakao Pay Insurance Market department.At Kakao Pay, we use Kotlin for backend service development, including for our insurance services and various other services.Before joining Kakao Pay, I had no experience with Kotlin, but as I used it, I became captivated by its convenience and various advantages. For example, here are some of Kotlins charms.Using Kotlin allows us to make the services we operate more robust, stable, and efficient.We easily gathered and managed content for specific domains or created libraries exclusively for our services.Writing test codes with clear purposes and targets was also easily done through Kotlin.After joining Kakao Pay and developing and operating several services with Kotlin, I wanted to introduce the charm of Kotlin that I experienced firsthand. Of the many great things about Kotlin, I have focused on four of its key charming aspects. This is a particularly good read for backend developers interested in developing stable web services, like me.Creating objects with validated values at creationWhen creating a VO(1) representing a value, there are cases where input values need to be transformed or validated. Although validation logic can be separated into a different class, if you have to call the validation logic separately each time, there is a possibility of skipping validation by mistake. Here is an example of concise code that guarantees validation using various Kotlin features.In this VO representing a car number, we use Kotlins value class.@JvmInlinevalue class CarNumber(val input: String)A value class is a wrapper class for representing values in Kotlin. It can only have a single immutable field, and in the JVM environment, the class is unwrapped during compilation and replaced with its internal value. Thanks to this, primitive type values can be handled like objects, and it also solves the overhead problem of using wrapper classes. For more details, please refer to the Project Valhalla content in the References section.Lets assume the following rules for license plate numbers (car numbers).Spaces arent allowedThe car number format must be one of the following121234123123411234121234When creating a CarNumber instance, we want to remove the spaces and implement the following criteria:The region name must be from the given list (Seoul, Gyeonggi, Daejeon, etc.), otherwise, an exception occurs.If there is no region name, the first number must be 23 digits, and the last number must be 4 digits.If there is a region name, the first number must be 12 digits, and the last number must be 4 digits.This was added as validation in the factory method.@JvmInlinevalue 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())// Remove spaces } } 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) { "Unknown registration region." }// Exception occurs if the region name of the old car number is not in the list } else { require(CAR_NUMBER_REGEX.matches(number)) { "Please check the car number format." }// Exception occurs if it does not match the car number forma } }}Although the code may seem a bit complex, the steps are as follows.Call the CarNumber.from() factory method to remove hyphens and spaces.Invoke the logic during instance creation using the init { } block.Verify whether it matches one of the two regular expressions (new car number, old car number).If it is an old car number, check whether the region name is in the list.The regular expressions (regex) and region names used for validation are written in the companion object and used as a singleton.Does writing the code like this solve all the problems? Unfortunately, there is still the issue of the constructor being exposed.val carNumber = CarNumber("123 4567") // Exception occurs because spaces are not removed.If the constructor is called directly, the space removal process created in the factory method cannot be applied. Fortunately, it is possible to prevent the constructor from being called directly. You can add the private constructor access modifier to it.value class CarNumber private constructor(val value: String) {By adding private constructor, you can enforce the creation of CarNumber instances only through the factory method. However, this implementation may be somewhat unfriendly to CarNumber users. Users may attempt to create an instance using the constructor like CarNumber("123 4567"), but until they write the code, they wont know that the constructor is blocked with private and that they need to use the factory method like CarNumber.from("123 4567") instead.This can be resolved by overloading Kotlins invoke operator. Kotlin provides guidance that function-type values can be called using the invoke operator.@JvmInlinevalue 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) }}// Usage exampleval carNumber = CarNumber("123 4567")// Actually calls from instead of the constructor.This allows users to create CarNumber instances as if they are directly calling the constructor, but internally, it hides the call to the factory method from.In some cases, the factory method from can also be hidden with private. This makes it so users no longer need to worry about whether to use the constructor or the factory method when creating a CarNumber instance. At the same time, it also prevents the creation of CarNumber instances with broken consistency.Finally, when using Jackson for serialization and deserialization, add the @JsonCreator annotation to the from factory method to use it. This completes a VO that satisfies all intended conditions. Below is the final completed CarNumber class.@JvmInlinevalue class CarNumber private constructor(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("", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "") @JsonCreator fun from(carNumber: String): CarNumber { return CarNumber(carNumber.removeSpacesAndHyphens()) } operator fun invoke(carNumber: String): CarNumber = from(carNumber) } 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) { "Unknown registration region." } } else { require(CAR_NUMBER_REGEX.matches(number)) { "Please check the car number format." } } }}When writing code like the example above in Kotlin, you can create VOs for various values and perform validation and transformation before instance creation. Writing the code this way allows you to delegate all roles and responsibilities related to car numbers to the VO. If policies related to car numbers change, you only need to check the CarNumber class. This naturally prevents unintended values from being used as car numbers and helps create stable services.ExampleLets take a closer look with a simple example. There is an API that receives input from users as shown below. If you declare the carNumber field in the request class as a CarNumber VO instead of a string, an exception will be immediately raised when the API is called with a car number that does not meet the conditions. There is no need to call separate validation logic, and if a CarNumber instance is successfully created, it guarantees that the car number value is valid.@RestControllerclass CarController { @PostMapping("/car") fun carInformation(@RequestBody request: CarInformationRequest) { // ... }}data class CarInformationRequest( val carNumber: CarNumber)Ensuring null safetyKotlin provides various ways to handle null safely. Heres an example of one that helps with writing and understanding logic through immutability and smart casting(2)There is a retryLogic method that resends an existing request. It performs the following actions:Explore the retryUseCase that matches the received category code. Throw an exception if not found.Send a retry request using the found retryUseCase.The code is as follows:fun retryLogic( categoryCode: CategoryCode, transactionId: String, request: RetryRequest) { val retryUseCase: UseCase? = activeUseCases().firstOrNull { it.type == categoryCode } requireNotNull(retryUseCase) { "The retry request is currently unavailable." } // Separate business logic return retryUseCase.getPrice(transactionId, request)}val retryUseCase is a value of type UseCase?. This indicates that the value could either be a UseCase or null. In Kotlin, unless you explicitly specify that the values type is nullable by adding a ? after it, the value cannot hold null by default.Next, perform a null check on the received value. The commonly used method is to check for nullability using if.if (retryUseCase == null) throw IllegalArgumentException("The retry request is currently unavailable.")In Kotlin, you can write the exact same functionality using a contract called requireNotNull.Even after checking for nullability, the problem is not completely resolved. If the value changes in the middle after the null check, then even if you previously checked for null on retryUseCase, you cannot be certain that it is still not null. In the above code, this is represented as the separate business logic section.The reason Kotlin is particularly strong in this area is because of Kotlins immutability. Values are declared using either val or var, and values declared with val are immutable. In other words, once a value is assigned, it does not change. This also applies when checking for null; once a value is confirmed to be not null, it is guaranteed to remain not null. In the example above, after requireNotNull(retryUseCase), the value is guaranteed not to be null.This is also confirmed from a Kotlin language perspective through smart casting.Through smart casting, the Kotlin compiler treats the type of retryUseCase as UseCase rather than UseCase? after requireNotNull(retryUseCase). The green-highlighted part in the image above represents this. Thanks to this, when writing or debugging code, you can confirm that a value that could be null is not null and proceed with the logic with confidence. This is, of course, a great help in the stable operation of services.Creating a utility library using extension functionsSeveral utility codes are commonly used in the development of insurance services. In particular, there are many cases where something is manipulated for primitive type fields or strings. These were gathered to create a library called insurance-common.Kotlins extension functions and object declarations can be used in the creation of such a library. Kotlins extension functions allow you to extend methods without separate design patterns or the inheritance of specific classes. Object declarations are used to contain content independent of the state of a specific instance. At the language level, they are declared as singletons, which is good for preventing the unnecessary creation of the same content multiple times.For example, a method called maskingName is written to mask the corresponding code when a specific pattern is found in strings.private val maskingNameRegex = Regex("(?i)Name=[^,)]++[,)]")/*** Masks numbers surrounded by "Number=" and "," or ")" in a string, except for the first digit*/fun maskingName(input: String): String { return input.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }}// Usage exampleval maskedValue = maskingName(userName)If the above code is refactored using Kotlins extension functions, it can be modified as follows.fun String.maskingName() = this.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }// Usage exampleval maskedValue = userName.maskingName()The masking method was extended to the string class. This method is unrelated to the state of a specific instance. In other words, declaring it as a singleton(3)and reusing it is advantageous for resource management. It can be used as a singleton by using a Kotlin object declaration.object StringUtils { private val maskingNameRegex = Regex("(?i)Name=[^,)]++[,)]") /** * Masks strings surrounded by "Number=" and "," or ")", except for the first character */ fun String.maskingName() = this.replace(maskingNameRegex) { "${it.value.substring(0, 6)}*${it.value.last()}" }}Examples of its usage can be confirmed through unit tests.@DisplayName("Masks strings surrounded by Number= and , or ) except for the first character")@Testfun maskingName() { // given val name = "Kim Chun-sik" val text = "userName=$name, result=\"success\"" val lowerText = "name=$name, result=\"success\"" // when val result = text.maskingName() val lowerResult = lowerText.maskingName() // then val expectedMaskedResult = "Kim*" assertThat(result).isEqualTo("userName=$expectedMaskedResult, result=\"success\"") assertThat(lowerResult).isEqualTo("name=$expectedMaskedResult, result=\"success\"") }In this way, functions used throughout the insurance service are being turned into libraries. This has reduced duplicate code across multiple ongoing projects and allowed their use without any performance degradation or other drawbacks. All of this is thanks to Kotlins extension functions and object declarations. Code managed this way is easy to read and maintain, greatly aiding in service operation.Simple and efficient unit testing using data classesWhen writing unit tests, data classes can be useful for setting up situations for testing. A data class in Kotlin is, as the name suggests, a class for representing data. Unlike regular classes, equals() and hashCode() are redefined, and other methods like copy() are automatically generated. Using data classes is useful when writing classes that represent data, such as DTOs(4).This is an example of a DTO that needs to be tested.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) }}The goal is to test whether age validation works. If the age is 18 or older, no exception should be thrown, and if it is under 18, an IllegalArgumentException should be thrown. Heres one way to write the test:class WhateverTest() { @Test fun `Throws IllegalArgumentException if age is under 18`() { assertThrows { val userInformation = UserInformation( name = "Chung Katfun", age = 17, birthDate = LocalDate.of(2022, 12, 19), address = "Kakao Pangyo Agit", gender = UserInformation.Gender.MALE, isDisplay = true ) } } @Test fun `Does not throw exception if age is 18 or older`() { assertDoesNotThrow { val userInformation = UserInformation( name = "Chung Katfun", age = 18, birthDate = LocalDate.of(2022, 12, 19), address = "Kakao Pangyo Agit", gender = UserInformation.Gender.MALE, isDisplay = true ) } }}Do you see the problem?The target of the test is unclear.Unnecessary code is repeated.It is difficult to determine from the test code above which values in UserInformation contribute to the exception. Adding comments could partially resolve this, but if the DTO has dozens of fields, it would be hard to identify which fields have comments at a glance. Additionally, to create a UserInformation instance, appropriate values must be assigned to fields other than age.To address this, the copy() function of the data class can be used. copy() has the following characteristics:A completely identical data class instance is created. When compared with the original instance using equals(), they are considered equal.When calling copy(), you can specify values for parameters. In this case, only the specified values of the corresponding parameters are copied.Returning to the code above, lets separate the common parts and revise it to make the test target clearer.class WhateverTest() { @Test fun `Throws IllegalArgumentException if age is under 18`() { val invalidAge = 17 assertThrows { val userInformation = successUserInformation.copy(age = invalidAge) } } @Test fun `Does not throw exception if age is 18 or older`() { val validAge = 18 assertDoesNotThrow { val userInformation = successUserInformation.copy(age = validAge) } } private val successUserInformation = UserInformation( name = "Chung Katfun", age = 28, birthDate = LocalDate.of(2022, 12, 19), address = "Kakao Pangyo Agit", gender = UserInformation.Gender.MALE, isDisplay = true )}Here are the two versions of the code arranged side by side for comparison.Do you notice the difference? Using copy():Reduced repeated code.Clearly highlighted the target affecting the test.This lowers the barrier to writing tests and improves their readability. As a result, tests can better serve as documentation, and they can be used to write stable services. This is also valid when writing tests in a BDD(5) style, such as given-when-then or arrange-act-assert.ConclusionAs someone responsible for the server side of our system, my priority is building services that are stable, readable, and scalable. Since I started using Kotlin two years ago at Kakao Pay, Ive been able to meet these goals step by step while developing a variety of applications.I wrote this blog post to share my experience in the hope that it helps others build and operate reliable backend systems. Whether youre exploring Kotlin or aiming to create more stable backends, I hope you find it useful.References[Project Valhalla: Value Class][Effective Kotlin Item 48: Consider Using Object Declarations][Kotlin Operator Overloading invoke operator][Kotlin Smart Casts]Value Object an object that represents a specific value (entity).This refers to a key feature of Kotlin where the compiler tracks type checks and explicit casting for immutable values and adds implicit casting when necessary.Singleton Pattern a design pattern where only one instance of a specific class is created and used globally.Data Transfer Object an object used for transferring data.Behaviour Driven Development Describing the behavior of code using domain language when writing tests. About Kuyho Chung (katfun.joy) A server developer at Kakao Pay, I really enjoy solving difficulties and inconveniences through technology. I strive to write each line of code with a solid rationale.
0 Σχόλια 0 Μοιράστηκε 18 Views