Kotlin으로 Spring Boot 백엔드 만들기

1. 개요

Kotlin은 Spring에서 공식 지원하는 언어입니다. Kotlin을 사용하면 보일러플레이트가 적고, 컴파일 시점에 null safety를 가지며, DTO용 data class를 가집니다. 이 글에서는 Kotlin과 Spring Boot 3.x로 주문(Order)을 관리하는 REST API를 만드는 방법을 안내합니다. JPA entity, type-safe 설정, 그리고 테스트를 포함합니다. 이 글을 읽고 나면 Kotlin 백엔드 프로젝트를 직접 시작할 수 있고, Java에서 전환할 때의 가장 흔한 오류들을 피할 수 있습니다.

2. Maven 프로젝트 설정

2.1. 의존성

익숙한 starter들 외에도, Kotlin 프로젝트에는 세 가지가 더 필요합니다. kotlin-stdlib은 표준 라이브러리입니다. kotlin-reflect는 Spring이 Kotlin 메타데이터를 읽기 위해 사용합니다. jackson-module-kotlin은 Jackson이 data class와 파라미터 기본값을 이해하도록 돕습니다.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.0</version>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.module</groupId>
        <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Kotlin artifact의 버전을 따로 지정할 필요가 없습니다. Spring Boot parent가 kotlin.version을 이미 관리합니다. 또한 parent가 java.version에 따라 kotlin.compiler.jvmTarget을 자동으로 설정하므로, 생성되는 바이트코드는 Java 21을 대상으로 합니다.

2.2. kotlin-maven-plugin 설정

이 부분이 가장 중요합니다. Kotlin에서는 기본적으로 class와 method가 final입니다. 하지만 Spring은 @Configuration, @Transactional, @Async 등에 대해 proxy(CGLIB)를 만들어야 하므로 이 class들은 open이어야 합니다. 또한 Hibernate는 파라미터가 없는 생성자가 필요합니다. allopen과 noarg 두 compiler plugin이 이 두 가지 문제를 모두 해결합니다:

<build>
    <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
    <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <configuration>
                <args>
                    <arg>-Xjsr305=strict</arg>
                </args>
                <compilerPlugins>
                    <plugin>spring</plugin>
                    <plugin>jpa</plugin>
                    <plugin>all-open</plugin>
                </compilerPlugins>
                <pluginOptions>
                    <option>all-open:annotation=jakarta.persistence.Entity</option>
                    <option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
                    <option>all-open:annotation=jakarta.persistence.Embeddable</option>
                </pluginOptions>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-allopen</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-noarg</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

각 부분의 의미:

  • spring: @Component, @Configuration, @Transactional, @Async, @Cacheable 및 @Service, @RestController 같은 파생 annotation이 있는 class를 자동으로 open합니다.
  • jpa: @Entity, @Embeddable, @MappedSuperclass가 있는 class에 파라미터 없는 생성자를 생성합니다.
  • all-open과 세 개의 option: Hibernate가 lazy loading proxy를 만들 수 있도록 entity도 open합니다. spring plugin은 이 부분을 처리하지 않습니다.
  • -Xjsr305=strict: Kotlin이 Spring의 nullability annotation(@Nullable, @NonNullApi)을 실제 Kotlin 타입으로 읽습니다. Spring API가 @Nullable을 반환하면 T?가 되고, null을 반드시 처리해야 합니다.

3. 애플리케이션 시작 클래스

package dev.nonla.shop

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class ShopApplication

fun main(args: Array<String>) {
    runApplication<ShopApplication>(*args)
}

runApplication<ShopApplication>은 reified generic을 가진 확장 함수입니다. SpringApplication.run(ShopApplication::class.java, *args)과 동일합니다. main 함수는 top-level에 있으므로 companion object가 필요하지 않습니다. @ConfigurationPropertiesScan은 섹션 7에서 사용됩니다.

4. JPA Entity와 repository

package dev.nonla.shop.order

import jakarta.persistence.*
import java.math.BigDecimal

enum class OrderStatus { NEW, PAID, CANCELLED }

@Entity
@Table(name = "orders")
class Order(
    var customerName: String,
    var amount: BigDecimal,

    @Enumerated(EnumType.STRING)
    var status: OrderStatus = OrderStatus.NEW,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
)

주의할 사항들:

  • 모든 필드는 primary constructor에서 선언됩니다. getter/setter를 작성할 필요가 없습니다.
  • id는 Long? 타입이고 기본값이 null입니다. entity는 저장된 후에만 id를 가집니다.
  • Hibernate가 필요로 하는 파라미터 없는 생성자는 jpa plugin에서 생성합니다. Kotlin 코드에는 보이지 않습니다.
  • 이것은 일반 class이지, data class가 아닙니다. 이유는 섹션 9에 있습니다.

Repository는 Java처럼 간결하게 작성합니다:

package dev.nonla.shop.order

import org.springframework.data.jpa.repository.JpaRepository

interface OrderRepository : JpaRepository<Order, Long> {
    fun findByStatus(status: OrderStatus): List<Order>
}

5. DTO와 data class 및 null safety

DTO는 data class를 사용하기에 가장 적합한 곳입니다. Kotlin이 equals, hashCode, toString, copy를 자동으로 생성합니다.

package dev.nonla.shop.order

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Positive
import java.math.BigDecimal

data class CreateOrderRequest(
    @field:NotBlank
    val customerName: String,

    @field:Positive
    val amount: BigDecimal,
)

data class OrderResponse(
    val id: Long,
    val customerName: String,
    val amount: BigDecimal,
    val status: OrderStatus,
)

fun Order.toResponse() = OrderResponse(
    id = requireNotNull(id) { "Order chưa được lưu" },
    customerName = customerName,
    amount = amount,
    status = status,
)

@NotBlank 대신 @field:NotBlank를 작성해야 합니다. use-site target field:가 없으면 Kotlin이 annotation을 constructor 파라미터에 붙입니다. Bean Validation이 annotation을 찾지 못하고 validation이 조용히 실행되지 않습니다.

확장 함수 Order.toResponse()는 entity를 DTO에 의존하지 않게 하면서도 method처럼 호출할 수 있게 합니다: order.toResponse(). requireNotNull은 id가 null이면 IllegalArgumentException을 던집니다. 그 다음 줄부터 컴파일러는 id를 Long으로 취급하며, 더 이상 Long?이 아닙니다.

6. Service와 controller

6.1. Service

package dev.nonla.shop.order

import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class OrderService(private val orderRepository: OrderRepository) {

    @Transactional
    fun create(request: CreateOrderRequest): OrderResponse {
        val order = Order(
            customerName = request.customerName.trim(),
            amount = request.amount,
        )
        return orderRepository.save(order).toResponse()
    }

    fun findById(id: Long): OrderResponse? =
        orderRepository.findByIdOrNull(id)?.toResponse()
}

Bean은 primary constructor를 통해 주입됩니다. 클래스가 유일한 생성자를 가지면 @Autowired가 필요하지 않습니다. 필드는 private val이므로 재할당할 수 없습니다.

findByIdOrNull은 Kotlin 전용 Spring Data 확장입니다. Optional<Order> 대신 Order?를 반환합니다. ?. 연산자와 결합하면 findById 함수가 한 줄로 간결해집니다. OrderResponse? 반환 타입은 호출자에게 결과가 없을 수 있음을 명확히 알려줍니다.

spring plugin 덕분에 OrderService는 자동으로 open됩니다. Spring이 @Transactional에 대한 proxy를 만들 수 있으며, open class를 작성할 필요가 없습니다.

6.2. Controller

package dev.nonla.shop.order

import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody request: CreateOrderRequest): OrderResponse =
        orderService.create(request)

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): ResponseEntity<OrderResponse> =
        orderService.findById(id)
            ?.let { ResponseEntity.ok(it) }
            ?: ResponseEntity.notFound().build()
}

Elvis 연산자 ?:는 if (x != null) … else …를 대체합니다: 주문이 있으면 200, 없으면 404를 반환합니다. curl로 시도해보세요:

curl -s -X POST localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyễn Văn An","amount":250000}'
{"id":1,"customerName":"Nguyễn Văn An","amount":250000,"status":"NEW"}

요청에서 customerName이 완전히 없으면 Jackson(Kotlin용)은 deserialize할 때 즉시 오류를 보고합니다. non-null 파라미터에 값이 없기 때문입니다. Spring은 validation 단계 전에 400 Bad Request를 반환합니다. "customerName": " "를 보내면 deserialize는 통과하지만 @field:NotBlank에 의해 차단되며, 역시 400을 받습니다.

7. @ConfigurationProperties를 사용한 type-safe 설정

payment.provider=vnpay
payment.timeout=3s
package dev.nonla.shop.payment

import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration

@ConfigurationProperties(prefix = "payment")
data class PaymentProperties(
    val provider: String,
    val timeout: Duration = Duration.ofSeconds(5),
    val retryCount: Int = 2,
)

Spring Boot 3부터는 유일한 생성자를 가진 클래스가 constructor를 통해 바인딩됩니다. @ConstructorBinding이 필요하지 않습니다. properties 파일에 없는 속성(여기서는 retryCount)은 Kotlin의 기본값을 얻습니다. val이므로 설정 객체는 시작 후 불변입니다.

service에 일반 bean처럼 주입합니다:

@Service
class PaymentService(private val props: PaymentProperties) {
    fun describe() = "Thanh toán qua ${props.provider}, timeout ${props.timeout.seconds}s"
}
Thanh toán qua vnpay, timeout 3s

payment.provider(기본값 없음)가 없으면 애플리케이션이 시작되지 않습니다. 이것이 원하는 동작입니다: 잘못된 설정은 배포할 때 바로 드러나야 하고, runtime을 기다리지 않아야 합니다.

8. 테스트 작성

spring-boot-starter-test에는 이미 JUnit 5와 AssertJ가 포함되어 있습니다. Kotlin에서는 backtick 안에 설명 문장으로 테스트 이름을 지정할 수 있습니다:

package dev.nonla.shop.order

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.math.BigDecimal

@DataJpaTest
@Import(OrderService::class)
class OrderServiceTest(@Autowired private val orderService: OrderService) {

    @Test
    fun `tạo đơn hàng mới với trạng thái NEW và tên đã được trim`() {
        val response = orderService.create(
            CreateOrderRequest(customerName = "  Trần Thị Bình ", amount = BigDecimal("99000"))
        )

        assertThat(response.id).isPositive()
        assertThat(response.customerName).isEqualTo("Trần Thị Bình")
        assertThat(response.status).isEqualTo(OrderStatus.NEW)
    }

    @Test
    fun `trả về null khi không tìm thấy đơn hàng`() {
        assertThat(orderService.findById(9_999)).isNull()
    }
}

@DataJpaTest는 작은 context를 구축합니다. JPA와 H2 in-memory만 포함됩니다. @Import는 OrderService를 context에 추가합니다. test class도 constructor injection을 사용합니다: 테스트에서 JUnit 5가 Spring에서 값을 가져와야 하므로 파라미터에 @Autowired가 필요합니다. 각 테스트는 transaction 내에서 실행되며, 끝날 때 rollback되므로 데이터가 다른 테스트로 새지 않습니다.

OrderServiceTest > tạo đơn hàng mới với trạng thái NEW và tên đã được trim() PASSED
OrderServiceTest > trả về null khi không tìm thấy đơn hàng() PASSED

9. 흔한 오류들

Entity에 data class 사용. equals/hashCode는 모든 필드(null일 수 있는 id 및 lazy 관계 포함)를 기반으로 자동 생성됩니다. 그 결과 entity는 저장된 후 “hash가 바뀌어” HashSet을 손상시킵니다. toString()은 lazy loading을 트리거하거나 양방향 관계에서 무한 재귀를 일으킬 수 있습니다.

참고: data class는 DTO와 설정에만 사용합니다. Entity의 경우 일반 class를 사용합니다. 필요하면 id를 기반으로 equals/hashCode를 직접 작성하세요.

spring 또는 all-open plugin 누락. plugin이 없으면 Spring이 @Configuration class may not be final 같은 오류를 보고합니다. 더 심각하게는 final method의 @Transactional이 경고 없이 무시됩니다. Entity의 경우 all-open이 없으면 Hibernate가 proxy를 만들 수 없고, LAZY 관계가 조용히 EAGER가 됩니다.

Validation annotation에 field: 누락. Validation이 실행되지 않고 오류도 보고되지 않습니다. 잘못된 데이터의 경우에 항상 테스트를 작성하여 이 오류를 잡으세요.

lateinit var를 injection에 사용. 동작하지만 불변성을 잃고 UninitializedPropertyAccessException을 만날 수 있습니다. private val을 사용한 constructor injection을 우선하세요.

-Xjsr305=strict 누락. 이 flag가 없으면 Spring API의 반환 타입은 platform type(T!)입니다. 컴파일러가 null 검사를 강제하지 않고, Java처럼 NullPointerException이 여전히 발생할 수 있습니다.

언제 아직 Kotlin으로 전환하지 말아야 할까? 팀에 Kotlin에 익숙한 사람이 없고 프로젝트가 급할 때는 학습 비용(coroutine, extension, scope function)이 이득보다 클 수 있습니다. Kotlin 코드는 Java보다 컴파일이 약간 느립니다. 또한 Lombok이나 MapStruct 같이 reflection에 크게 의존하는 일부 라이브러리는 특정 설정에서 Kotlin과 잘 맞지 않습니다. 이 경우 Kotlin을 점진적으로 도입할 수 있습니다: 기존 Java 코드를 유지하면서 테스트나 새 모듈을 Kotlin으로 작성하는 것입니다.

10. 결론

Kotlin으로 Spring Boot 3 백엔드를 구축했습니다. 포함 사항:

  • spring, jpa, all-open, -Xjsr305=strict를 사용한 kotlin-maven-plugin 설정;
  • 일반 class를 사용한 JPA entity, @field:를 사용한 DTO의 data class;
  • null safety, findByIdOrNull, Elvis 연산자를 활용한 service와 controller;
  • data class의 @ConfigurationProperties를 사용한 불변 설정;
  • JUnit 5와 AssertJ를 사용한 테스트, backtick의 설명 문장으로 테스트 이름 지정.

기존의 Spring 지식은 그대로 사용할 수 있습니다. Kotlin으로 전환할 때 기억할 세 가지 차이점이 있습니다: class의 기본값이 final, annotation에는 use-site target이 필요하며, nullable 타입을 명시적으로 선언해야 합니다. 이 세 가지를 확실히 숙지하면 Java에서 Kotlin으로 전환할 때의 대부분의 미묘한 오류를 피할 수 있습니다.

kotlinspring-bootjparest-apibackend