Spring Boot와 Kotlin
Spring Boot는 Kotlin을 공식 지원합니다. Kotlin의 간결한 문법, null 안전성, 데이터 클래스를 Spring Boot와 조합하면 Java 대비 보일러플레이트가 크게 줄고, 안전한 코드를 작성할 수 있습니다.
이 글에서는 REST API 구축, 데이터 클래스 활용, 확장 함수를 이용한 유틸리티 구현 등 실전 패턴을 정리합니다.
프로젝트 설정
Spring Initializr에서 언어를 Kotlin으로 선택하면 필요한 플러그인이 자동 설정됩니다. 핵심 Gradle 설정을 살펴봅니다.
// build.gradle.kts — 핵심 설정
plugins {
id("org.springframework.boot") version "3.3.0"
id("io.spring.dependency-management") version "1.1.5"
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0" // open 클래스 자동 처리
kotlin("plugin.jpa") version "2.0.0" // no-arg 생성자 자동 생성
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
// Kotlin 컴파일 옵션
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict") // null 안전성 강화
}
}
kotlin-spring 플러그인은 Spring이 요구하는 open 키워드를 자동으로 추가하고, kotlin-jpa 플러그인은 JPA 엔티티에 필요한 no-arg 생성자를 자동 생성합니다.
엔티티와 DTO — 데이터 클래스 활용
Kotlin의 data class로 DTO를 정의하면 equals, hashCode, toString, copy가 자동 생성됩니다.
import jakarta.persistence.*
import jakarta.validation.constraints.*
import java.time.LocalDateTime
// JPA 엔티티 — data class 대신 일반 class 사용 (JPA 권장)
@Entity
@Table(name = "articles")
class Article(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
var title: String,
@Column(nullable = false, columnDefinition = "TEXT")
var content: String,
@Column(nullable = false)
var author: String,
@Column(nullable = false, updatable = false)
val createdAt: LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
)
// 요청 DTO — data class로 간결하게 정의
data class CreateArticleRequest(
@field:NotBlank(message = "제목은 필수입니다")
@field:Size(max = 200, message = "제목은 200자 이하여야 합니다")
val title: String,
@field:NotBlank(message = "내용은 필수입니다")
val content: String,
@field:NotBlank(message = "작성자는 필수입니다")
val author: String
)
data class UpdateArticleRequest(
val title: String?, // nullable — 부분 업데이트 지원
val content: String?
)
// 응답 DTO
data class ArticleResponse(
val id: Long,
val title: String,
val content: String,
val author: String,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
) {
companion object {
// 엔티티를 응답 DTO로 변환하는 팩토리 메서드
fun from(article: Article) = ArticleResponse(
id = article.id,
title = article.title,
content = article.content,
author = article.author,
createdAt = article.createdAt,
updatedAt = article.updatedAt
)
}
}
// 페이지네이션 응답
data class PageResponse<T>(
val content: List<T>,
val page: Int,
val size: Int,
val totalElements: Long,
val totalPages: Int
)
JPA 엔티티에는 data class보다 일반 class를 사용하는 것이 권장됩니다. data class의 equals/hashCode가 모든 필드를 비교하여 지연 로딩과 충돌할 수 있기 때문입니다.
REST Controller — Kotlin 스타일
Kotlin의 표현식 함수와 null 안전성을 활용한 Controller를 작성합니다.
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
import jakarta.validation.Valid
@RestController
@RequestMapping("/api/articles")
@Validated
class ArticleController(
private val articleService: ArticleService // 생성자 주입 (autowired 불필요)
) {
// 목록 조회 — 페이지네이션
@GetMapping
fun getArticles(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "10") size: Int
): ResponseEntity<PageResponse<ArticleResponse>> {
val result = articleService.getArticles(PageRequest.of(page, size))
return ResponseEntity.ok(result)
}
// 단건 조회
@GetMapping("/{id}")
fun getArticle(@PathVariable id: Long): ResponseEntity<ArticleResponse> =
ResponseEntity.ok(articleService.getArticle(id))
// 생성
@PostMapping
fun createArticle(
@Valid @RequestBody request: CreateArticleRequest
): ResponseEntity<ArticleResponse> =
ResponseEntity
.status(HttpStatus.CREATED)
.body(articleService.createArticle(request))
// 수정
@PutMapping("/{id}")
fun updateArticle(
@PathVariable id: Long,
@RequestBody request: UpdateArticleRequest
): ResponseEntity<ArticleResponse> =
ResponseEntity.ok(articleService.updateArticle(id, request))
// 삭제
@DeleteMapping("/{id}")
fun deleteArticle(@PathVariable id: Long): ResponseEntity<Unit> {
articleService.deleteArticle(id)
return ResponseEntity.noContent().build()
}
}
Kotlin에서는 @Autowired 대신 생성자 주입을 기본으로 사용합니다. Spring이 생성자가 하나면 자동으로 주입합니다.
Service 계층 — 확장 함수 활용
확장 함수와 let/apply 등 스코프 함수를 활용하여 서비스 로직을 간결하게 작성합니다.
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class ArticleService(
private val articleRepository: ArticleRepository
) {
fun getArticles(pageable: Pageable): PageResponse<ArticleResponse> {
val page = articleRepository.findAll(pageable)
return PageResponse(
content = page.content.map { ArticleResponse.from(it) },
page = page.number,
size = page.size,
totalElements = page.totalElements,
totalPages = page.totalPages
)
}
fun getArticle(id: Long): ArticleResponse {
// findByIdOrNull — Kotlin 확장 함수 (Spring Data 제공)
val article = articleRepository.findByIdOrNull(id)
?: throw ArticleNotFoundException(id)
return ArticleResponse.from(article)
}
@Transactional
fun createArticle(request: CreateArticleRequest): ArticleResponse {
val article = Article(
title = request.title,
content = request.content,
author = request.author
)
return ArticleResponse.from(articleRepository.save(article))
}
@Transactional
fun updateArticle(id: Long, request: UpdateArticleRequest): ArticleResponse {
val article = articleRepository.findByIdOrNull(id)
?: throw ArticleNotFoundException(id)
// apply로 부분 업데이트 — null이 아닌 필드만 갱신
article.apply {
request.title?.let { title = it }
request.content?.let { content = it }
updatedAt = LocalDateTime.now()
}
return ArticleResponse.from(articleRepository.save(article))
}
@Transactional
fun deleteArticle(id: Long) {
if (!articleRepository.existsById(id)) {
throw ArticleNotFoundException(id)
}
articleRepository.deleteById(id)
}
}
// 커스텀 예외
class ArticleNotFoundException(id: Long) :
RuntimeException("게시글을 찾을 수 없습니다: id=$id")
전역 예외 처리
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
data class ErrorResponse(
val status: Int,
val message: String,
val errors: List<String> = emptyList()
)
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ArticleNotFoundException::class)
fun handleNotFound(e: ArticleNotFoundException) =
ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(404, e.message ?: "리소스를 찾을 수 없습니다"))
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
val errors = e.bindingResult.fieldErrors.map {
"${it.field}: ${it.defaultMessage}"
}
return ResponseEntity
.badRequest()
.body(ErrorResponse(400, "입력값이 유효하지 않습니다", errors))
}
}
실전 팁
- kotlin-spring 플러그인 필수: Spring 프록시가 요구하는
open키워드를 자동 추가합니다. - kotlin-jpa 플러그인 필수: JPA 엔티티에 필요한 no-arg 생성자를 자동 생성합니다.
-Xjsr305=strict: Java의@Nullable/@NotNull어노테이션을 Kotlin 타입 시스템에 반영합니다.findByIdOrNull사용:findById는Optional을 반환하지만, Kotlin 확장 함수인findByIdOrNull은T?를 반환하여 더 자연스럽습니다.- 생성자 주입: Kotlin에서는 주 생성자에 의존성을 선언하면
@Autowired없이 자동 주입됩니다. - data class는 DTO에만: JPA 엔티티에는 일반 class를 사용하고, 요청/응답 DTO에만 data class를 사용합니다.