Kotlin Spring Boot | REST API 서버 만들기
이 글의 핵심
Kotlin 클래스는 기본이 final이라 Spring의 프록시 기반 기능이나 JPA 엔티티와 그대로 쓰면 문제가 생깁니다. 이를 해결하는 컴파일러 플러그인의 역할을 짚고, data class와 null 안전성이 Java 대비 서비스 코드를 얼마나 간결하게 만드는지 Todo API를 완성하며 확인하게 합니다.
들어가며
Spring Boot는 Java 기반이지만 Kotlin으로 작성해도 동일한 스타터·빈을 그대로 씁니다. build.gradle.kts에서 의존성을 선언하며, 서비스·리포지토리를 설계도(클래스)에 맞춰 나누는 흐름이 일반적입니다.
Kotlin용 build.gradle.kts와 application.yml
build.gradle.kts
plugins {
kotlin("jvm") version "1.9.0"
kotlin("plugin.spring") version "1.9.0"
kotlin("plugin.jpa") version "1.9.0"
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("com.h2database:h2")
}
의존성에 버전을 적지 않아도 되는 이유는 io.spring.dependency-management 플러그인이 Spring Boot가 검증한 버전 목록(BOM)을 적용해 주기 때문입니다. 이 플러그인을 빠뜨리면 Gradle이 Could not find org.springframework.boot:spring-boot-starter-web:.처럼 버전이 비어 있는 좌표를 찾다가 실패합니다(이전 버전의 이 예제에는 이 줄과 repositories 블록이 빠져 있어 그대로는 빌드되지 않았습니다). 대안으로 플러그인 없이 implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES))로 Gradle 자체의 플랫폼 기능을 쓰는 방법도 있습니다.
kotlin("plugin.spring")과 kotlin("plugin.jpa")는 Kotlin을 Spring과 함께 쓸 때 사실상 필수입니다. Kotlin 클래스와 메서드는 기본이 final인데, Spring은 @Transactional, @Cacheable, @Configuration 같은 기능을 클래스를 상속한 프록시로 구현하기 때문에 final 클래스에는 적용할 수 없습니다. plugin.spring은 Spring 어노테이션이 붙은 클래스를 컴파일 시 자동으로 open으로 만들어 주고, plugin.jpa는 JPA가 리플렉션으로 엔티티를 만들 때 필요한 인자 없는 생성자를 @Entity 클래스에 추가합니다. jackson-module-kotlin은 Jackson이 Kotlin 주 생성자와 기본값, 널 가능성 정보를 이해하게 해 주고, 이 모듈이 내부적으로 kotlin-reflect를 사용합니다. H2는 코드에서 직접 참조하지 않고 실행 시에만 필요하므로 runtimeOnly가 맞습니다.
Application.kt
package com.example.demo
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class Application
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
runApplication<Application>(*args)는 Java의 SpringApplication.run(Application.class, args)를 Kotlin 친화적으로 감싼 함수입니다. *는 배열을 가변 인자로 펼치는 스프레드 연산자입니다. main이 클래스 바깥의 최상위 함수로 선언되어 있는데, 이 파일은 JVM에서 ApplicationKt라는 클래스로 컴파일되므로 Gradle의 bootJar가 메인 클래스를 찾을 때 com.example.demo.ApplicationKt가 됩니다. 수동으로 메인 클래스를 지정할 일이 있다면 Kt 접미사를 빠뜨리지 않도록 주의하세요.
application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:h2:mem:testdb
jpa:
hibernate:
ddl-auto: update
show-sql: true
jdbc:h2:mem:testdb는 메모리 DB라서 애플리케이션을 재시작할 때마다 데이터가 사라집니다. 학습용으로는 편하지만 이 점을 모르고 “저장한 데이터가 없어졌다”고 헤매는 경우가 많습니다. ddl-auto: update는 엔티티를 보고 테이블을 자동으로 만들거나 컬럼을 추가해 주지만, 컬럼 삭제나 타입 변경은 반영하지 않고 운영 DB 스키마를 예측하기 어렵게 바꿀 수 있어서 운영 환경에서는 validate나 none으로 두고 Flyway·Liquibase로 스키마를 관리하는 것이 일반적입니다. show-sql: true는 표준 출력에 SQL을 찍기 때문에 개발 중에만 켜고, 운영에서는 로깅 설정(logging.level.org.hibernate.SQL=debug)으로 제어하는 편이 낫습니다.
REST Controller와 JPA 엔티티
UserController
package com.example.demo.controller
import com.example.demo.model.User
import com.example.demo.service.UserService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {
@GetMapping
fun getUsers(): List<User> {
return userService.findAll()
}
@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): ResponseEntity<User> {
val user = userService.findById(id)
return if (user != null) {
ResponseEntity.ok(user)
} else {
ResponseEntity.notFound().build()
}
}
@PostMapping
fun createUser(@RequestBody user: User): ResponseEntity<User> {
val created = userService.save(user)
return ResponseEntity.status(HttpStatus.CREATED).body(created)
}
@PutMapping("/{id}")
fun updateUser(
@PathVariable id: Long,
@RequestBody user: User
): ResponseEntity<User> {
val updated = userService.update(id, user)
return if (updated != null) {
ResponseEntity.ok(updated)
} else {
ResponseEntity.notFound().build()
}
}
@DeleteMapping("/{id}")
fun deleteUser(@PathVariable id: Long): ResponseEntity<Void> {
val deleted = userService.delete(id)
return if (deleted) {
ResponseEntity.noContent().build()
} else {
ResponseEntity.notFound().build()
}
}
}
class UserController(private val userService: UserService)는 생성자 주입입니다. Kotlin은 주 생성자에 private val을 붙이면 필드 선언과 대입이 한 번에 끝나고, 생성자가 하나뿐인 클래스는 Spring이 @Autowired 없이도 그 생성자로 의존성을 주입합니다. 필드 주입(@Autowired lateinit var)과 비교하면 의존성이 val이라 바뀌지 않고, 테스트에서 UserController(mockService)처럼 스프링 없이도 객체를 만들 수 있다는 장점이 있습니다.
findById가 User?를 반환하기 때문에 컨트롤러는 if (user != null)로 404를 분기합니다. 이 분기가 여러 엔드포인트에 반복되면 서비스에서 NotFoundException 같은 예외를 던지고 @RestControllerAdvice로 한곳에서 404 응답으로 바꾸는 방식이 더 깔끔해집니다. 한 가지 주의할 점은 @RequestBody user: User처럼 JPA 엔티티를 요청 본문으로 직접 받는 구조입니다. 클라이언트가 "id": 5를 넣어 POST하면 save가 새로 삽입하는 대신 기존 5번 사용자를 덮어쓰는 병합(merge)으로 동작할 수 있고, 나중에 엔티티에 비밀번호 같은 필드가 추가되면 응답에 그대로 노출됩니다. 예제 규모를 넘어서면 CreateUserRequest, UserResponse 같은 DTO를 따로 두는 것이 안전합니다.
User 엔티티
package com.example.demo.model
import jakarta.persistence.*
@Entity
@Table(name = "users")
data class User(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(nullable = false)
val name: String,
@Column(nullable = false, unique = true)
val email: String,
val age: Int? = null
)
id를 Long? = null로 둔 것은 “아직 저장되지 않은 엔티티는 ID가 없다”는 사실을 타입으로 표현한 것입니다. @GeneratedValue(strategy = GenerationType.IDENTITY)는 DB의 자동 증가 컬럼에 ID 생성을 맡기며, 저장 후 반환되는 객체에 채워진 ID가 들어 있습니다. name처럼 널이 될 수 없는 String과 @Column(nullable = false)를 함께 쓰면 Kotlin 타입 시스템과 DB 제약이 같은 규칙을 말하게 됩니다.
다만 data class를 JPA 엔티티로 쓰는 것은 많이 알려진 논쟁거리이고, 저도 이 구조로 시작했다가 몇 가지 문제에 부딪히는 경우를 여러 번 봤습니다. data class가 자동으로 만드는 equals/hashCode는 모든 주 생성자 속성을 비교하므로, 저장 전(id = null)과 저장 후의 해시 값이 달라져 HashSet에 넣어 둔 엔티티를 다시 찾지 못합니다. 연관 관계가 있는 엔티티끼리는 자동 생성된 toString이 서로를 무한히 호출해 StackOverflowError를 내거나, 지연 로딩 컬렉션을 건드려 LazyInitializationException을 일으키기도 합니다. 또 plugin.jpa는 기본 생성자만 추가할 뿐 클래스를 open으로 만들지 않기 때문에, Hibernate가 지연 로딩 프록시를 만들 수 없어 @ManyToOne(fetch = LAZY)가 조용히 즉시 로딩처럼 동작할 수 있습니다. 이 글처럼 연관 관계가 없는 단순한 엔티티라면 큰 문제가 없지만, 실무에서는 일반 class에 id 기준의 equals/hashCode를 직접 작성하고, build.gradle.kts에 다음 설정을 추가하는 방식이 권장됩니다.
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
Spring Data JPA Repository
UserRepository
package com.example.demo.repository
import com.example.demo.model.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UserRepository : JpaRepository<User, Long> {
fun findByEmail(email: String): User?
fun findByNameContaining(keyword: String): List<User>
fun findByAgeGreaterThan(age: Int): List<User>
}
Spring Data JPA는 메서드 이름을 파싱해서 쿼리를 만듭니다. findByEmail은 where email = ?, findByNameContaining은 where name like %?%, findByAgeGreaterThan은 where age > ?가 됩니다. 반환 타입을 User?로 선언하면 결과가 없을 때 null을 돌려주므로 Java처럼 Optional을 쓸 필요가 없고, 결과가 두 개 이상이면 IncorrectResultSizeDataAccessException이 납니다. 메서드 이름에 존재하지 않는 속성을 쓰면(예: findByMail) 애플리케이션 시작 시점에 No property 'mail' found for type 'User'로 실패하기 때문에, 오타가 운영까지 가지 않는다는 장점이 있습니다. 조건이 세 개를 넘어가 이름이 너무 길어지면 @Query로 JPQL을 직접 쓰는 편이 읽기 좋습니다. 참고로 JpaRepository를 상속한 인터페이스는 @Repository 없이도 자동으로 빈이 되므로 이 어노테이션은 생략해도 됩니다.
서비스 계층
UserService
package com.example.demo.service
import com.example.demo.model.User
import com.example.demo.repository.UserRepository
import org.springframework.stereotype.Service
@Service
class UserService(private val userRepository: UserRepository) {
fun findAll(): List<User> {
return userRepository.findAll()
}
fun findById(id: Long): User? {
return userRepository.findById(id).orElse(null)
}
fun save(user: User): User {
return userRepository.save(user)
}
fun update(id: Long, user: User): User? {
val existing = findById(id) ?: return null
val updated = existing.copy(
name = user.name,
email = user.email,
age = user.age
)
return userRepository.save(updated)
}
fun delete(id: Long): Boolean {
return if (userRepository.existsById(id)) {
userRepository.deleteById(id)
true
} else {
false
}
}
}
findById(id).orElse(null)은 Java의 Optional을 Kotlin의 널 가능 타입으로 바꾸는 흔한 관용구이며, kotlin-stdlib의 getOrNull() 확장 함수(Kotlin 1.8 이상의 jdk8 확장)를 써도 됩니다. Spring Data는 Kotlin을 위해 findByIdOrNull(id) 확장 함수(org.springframework.data.repository.findByIdOrNull)도 제공합니다.
update에서 existing.copy(...)로 새 객체를 만들어 저장하는 방식은 data class와 불변 val 속성의 조합으로는 자연스럽지만, JPA 관점에서는 영속성 컨텍스트가 관리하던 객체를 버리고 같은 ID의 새 객체를 merge하는 것입니다. 이 예제에는 트랜잭션이 없어서 문제가 드러나지 않지만, @Transactional을 붙인 서비스에서 엔티티의 var 속성을 바꾸기만 하면 커밋 시점에 변경 감지(dirty checking)로 UPDATE가 나가는 것이 JPA가 의도한 사용법입니다. 두 방식 중 어느 쪽을 택할지는 “엔티티를 불변 값처럼 다룰 것인가, JPA가 관리하는 가변 객체로 다룰 것인가”의 선택이고, 연관 관계가 많아질수록 후자가 편해집니다. delete의 existsById 후 deleteById도 두 쿼리 사이에 다른 요청이 같은 행을 지울 수 있다는 점에서 완벽하지는 않지만, 학습용 API에서는 충분합니다.
Kotlin으로 Todo API 만들기
// Todo.kt
@Entity
data class Todo(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val title: String,
val completed: Boolean = false
)
// TodoRepository.kt
@Repository
interface TodoRepository : JpaRepository<Todo, Long> {
fun findByCompleted(completed: Boolean): List<Todo>
}
// TodoService.kt
@Service
class TodoService(private val todoRepository: TodoRepository) {
fun findAll(): List<Todo> = todoRepository.findAll()
fun save(todo: Todo): Todo = todoRepository.save(todo)
fun toggle(id: Long): Todo? {
val todo = todoRepository.findById(id).orElse(null) ?: return null
val updated = todo.copy(completed = !todo.completed)
return todoRepository.save(updated)
}
}
// TodoController.kt
@RestController
@RequestMapping("/api/todos")
class TodoController(private val todoService: TodoService) {
@GetMapping
fun getTodos(): List<Todo> = todoService.findAll()
@PostMapping
fun createTodo(@RequestBody todo: Todo): ResponseEntity<Todo> {
val created = todoService.save(todo)
return ResponseEntity.status(HttpStatus.CREATED).body(created)
}
@PutMapping("/{id}/toggle")
fun toggleTodo(@PathVariable id: Long): ResponseEntity<Todo> {
val updated = todoService.toggle(id)
return if (updated != null) {
ResponseEntity.ok(updated)
} else {
ResponseEntity.notFound().build()
}
}
}
Todo 예제는 앞의 User API와 같은 계층 구조를 한 파일에 모아 보여 줍니다. toggle에서 눈여겨볼 부분은 orElse(null) ?: return null입니다. 값이 없으면 함수에서 바로 빠져나가는 엘비스 연산자 덕분에 Java의 if (!optional.isPresent()) return null; 같은 분기 없이 이후 코드에서 todo를 널이 아닌 타입으로 다룰 수 있습니다.
토글처럼 “읽고, 바꾸고, 저장하는” 작업은 두 요청이 동시에 들어오면 둘 다 completed = false를 읽고 둘 다 true로 저장해 한 번의 토글이 사라질 수 있습니다. 실제 서비스라면 엔티티에 @Version 필드를 두는 낙관적 잠금으로 충돌을 감지하거나, update todo set completed = not completed where id = ? 같은 단일 쿼리로 바꾸는 방법을 고려합니다. 또 POST /api/todos로 {"title": "공부"}만 보내면 completed는 기본값 false로 채워지는데, 이것이 동작하는 것은 jackson-module-kotlin이 Kotlin의 기본 인자를 이해하기 때문입니다. 모듈이 없으면 역직렬화가 실패하거나 기본값이 무시되는 등 원인을 알기 어려운 문제가 생깁니다. 반대로 title을 빠뜨리면 MissingKotlinParameterException(최신 버전에서는 MismatchedInputException 계열)이 나고, 별도 처리가 없으면 400 Bad Request가 반환됩니다.
Kotlin Spring Boot 요약
- Spring Boot: Kotlin 공식 지원 (plugin.spring·plugin.jpa 필요)
- data class: Entity 정의 간편
- 생성자 주입: 불변성 보장
- JpaRepository: CRUD 자동 제공
- ResponseEntity: HTTP 응답 제어
Kotlin의 장점
- Null 안전성:
?연산자 - 간결한 문법: 보일러플레이트 감소
- 확장 함수: 유틸리티 추가 용이
- 코루틴: 비동기 처리 간편
다음 단계
같이 보면 좋은 글
- Java Spring Boot | REST API 서버 만들기
- C++ HTTP 클라이언트 직접 만들기
- C++ JSON 파싱
- [Go 2주 완성 #08] Day 14: 실전 미니 프로젝트 - REST API 서버 구축
- Kotlin 테스팅 | JUnit, MockK, 테스트 작성법
- Java Stream API
자주 묻는 질문 (FAQ)
Q. build.gradle.kts에 plugin.spring과 plugin.jpa는 왜 필요한가요?
A. Kotlin 클래스는 기본이 final이라 Spring이 @Transactional이나 @Configuration을 처리하려고 만드는 프록시 하위 클래스를 만들 수 없습니다. plugin.spring(all-open)은 Spring 어노테이션이 붙은 클래스를 자동으로 open으로 만들어 이 문제를 해결합니다. plugin.jpa(no-arg)는 JPA가 엔티티를 생성할 때 필요한 기본 생성자를 @Entity 클래스에 만들어 줍니다.