Spring Boot + Kotlin 실전 가이드

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 classequals/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 사용: findByIdOptional을 반환하지만, Kotlin 확장 함수인 findByIdOrNullT?를 반환하여 더 자연스럽습니다.
  • 생성자 주입: Kotlin에서는 주 생성자에 의존성을 선언하면 @Autowired 없이 자동 주입됩니다.
  • data class는 DTO에만: JPA 엔티티에는 일반 class를 사용하고, 요청/응답 DTO에만 data class를 사용합니다.

이 글이 도움이 되었나요?