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합니다.springplugin은 이 부분을 처리하지 않습니다.-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가 필요로 하는 파라미터 없는 생성자는
jpaplugin에서 생성합니다. 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으로 전환할 때의 대부분의 미묘한 오류를 피할 수 있습니다.