Kotlin과 Spring Boot 3로 Coroutines 기반 비동기 REST API를 단계별로 구현한다. data class로 DTO를 간소화하고, suspend fun + withContext(Dispatchers.IO)로 비동기 처리, JWT 인증, 전역 예외 처리, Docker 멀티스테이지 빌드까지 프로덕션 수준의 백엔드 구조를 완성한다.
한 줄 요약: Kotlin + Spring Boot 3로 Coroutines 기반 비동기 REST API를 단계별로 구현하고, JWT 인증·PostgreSQL 연동·Docker 배포까지 완성한다.
이 글이 필요한 사람
Java Spring 백엔드 개발자인데 Kotlin으로 전환을 고민 중인 분
Spring Boot 3 + Coroutines 조합을 처음 시도하는 분
Kotlin 문법은 아는데 실전 API 서버 구조를 잡고 싶은 분
※ Spring Boot 3.3, Kotlin 2.1, PostgreSQL 16, Docker 기준 / 2026-04-26 작성
Java 대신 Kotlin을 선택해야 하는 이유
Spring Boot 팀이 공식 지원하는 언어는 Java와 Kotlin 둘뿐이다. 2023년부터 Spring 공식 샘플 코드도 Kotlin이 기본으로 바뀌는 추세다. Kotlin이 실무에서 Java를 대체하는 이유는 세 가지다.
null safety: NullPointerException이 컴파일 타임에 잡힌다. String?과 String은 타입 수준에서 다르다. if (value != null) 방어 코드가 사라진다.
data class: DTO 하나를 만들기 위해 getter/setter/equals/hashCode/toString을 모두 작성할 필요가 없다. data class User(val id: Long, val email: String) 한 줄로 끝난다.
Coroutines: WebFlux 없이 논블로킹 코드를 작성할 수 있다. suspend fun과 withContext(Dispatchers.IO) 패턴으로 콜백 지옥 없이 비동기 처리가 가능하다. Spring MVC 위에서도 동작하므로 기존 코드베이스에 점진적으로 도입할 수 있다는 점이 실무에서 중요하다.
Dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, Spring Security, Validation
다운로드 후 IntelliJ IDEA로 열면 build.gradle.kts가 Kotlin DSL로 작성된 것을 확인할 수 있다. Coroutines와 JWT 의존성은 수동으로 추가해야 한다.
build.gradle.kts — Coroutines & JWT 의존성 추가
plugins {
id("org.springframework.boot") version "3.3.5"
id("io.spring.dependency-management") version "1.1.6"
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "2.1.0"
kotlin("plugin.jpa") version "2.1.0"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
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-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("org.postgresql:postgresql")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.1")
// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}
데이터 모델 & PostgreSQL 연동
application.yml에서 데이터소스를 설정하고, JPA Entity와 Repository를 작성한다. Kotlin에서 JPA Entity를 쓸 때 주의점이 있다. @Entity 클래스는 반드시 open class여야 하고, @Id는 var로 선언해야 프록시 생성이 가능하다. kotlin("plugin.jpa") 플러그인이 이 작업을 자동으로 처리해준다.
Spring Boot 레이어드 아키텍처. Kotlin data class로 DTO 보일러플레이트가 크게 줄어든다.
Post.kt — JPA Entity & Repository
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import java.time.LocalDateTime
@Entity
@Table(name = "posts")
class Post(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0,
@Column(nullable = false)
var title: String,
@Column(columnDefinition = "TEXT", nullable = false)
var content: String,
@Column(name = "author_id", nullable = false)
val authorId: Long,
@Column(name = "created_at")
val createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at")
var updatedAt: LocalDateTime = LocalDateTime.now()
)
interface PostRepository : JpaRepository<Post, Long> {
fun findByAuthorId(authorId: Long): List<Post>
@Query("SELECT p FROM Post p WHERE p.title LIKE %:keyword% OR p.content LIKE %:keyword%")
fun searchByKeyword(keyword: String): List<Post>
}
REST API 구현 — Controller & Service
Service 레이어에 suspend fun을 사용하면 Coroutines 기반 비동기 처리가 가능하다. Spring MVC에서 Coroutines를 사용하려면 kotlinx-coroutines-reactor 의존성이 필요하고, Controller에서 suspend fun을 직접 선언할 수 있다.
DTO는 data class로 간결하게 정의한다. @field:NotBlank처럼 필드 수준 어노테이션을 명시해야 Kotlin에서 Bean Validation이 올바르게 동작한다. @Valid를 빠뜨리면 검증이 실행되지 않으니 Controller에서 반드시 붙인다.
// DTO
data class CreatePostRequest(
@field:NotBlank(message = "제목을 입력하세요")
val title: String,
@field:NotBlank(message = "내용을 입력하세요")
val content: String
)
data class PostResponse(
val id: Long,
val title: String,
val content: String,
val authorId: Long,
val createdAt: String
) {
companion object {
fun from(post: Post) = PostResponse(
id = post.id, title = post.title,
content = post.content, authorId = post.authorId,
createdAt = post.createdAt.toString()
)
}
}
// Service
@Service
@Transactional
class PostService(private val postRepository: PostRepository) {
@Transactional(readOnly = true)
suspend fun findAll(): List<PostResponse> =
withContext(Dispatchers.IO) {
postRepository.findAll().map { PostResponse.from(it) }
}
@Transactional(readOnly = true)
suspend fun findById(id: Long): PostResponse =
withContext(Dispatchers.IO) {
val post = postRepository.findById(id)
.orElseThrow { PostNotFoundException(id) }
PostResponse.from(post)
}
suspend fun create(request: CreatePostRequest, authorId: Long): PostResponse =
withContext(Dispatchers.IO) {
val post = Post(title = request.title, content = request.content, authorId = authorId)
PostResponse.from(postRepository.save(post))
}
suspend fun delete(id: Long, authorId: Long) =
withContext(Dispatchers.IO) {
val post = postRepository.findById(id)
.orElseThrow { PostNotFoundException(id) }
if (post.authorId != authorId) throw ForbiddenException()
postRepository.delete(post)
}
}
// Controller
@RestController
@RequestMapping("/api/posts")
class PostController(private val postService: PostService) {
@GetMapping
suspend fun getAll(): ResponseEntity<List<PostResponse>> =
ResponseEntity.ok(postService.findAll())
@GetMapping("/{id}")
suspend fun getById(@PathVariable id: Long): ResponseEntity<PostResponse> =
ResponseEntity.ok(postService.findById(id))
@PostMapping
suspend fun create(
@Valid @RequestBody request: CreatePostRequest,
@AuthenticationPrincipal user: UserDetails
): ResponseEntity<PostResponse> {
val authorId = user.username.toLong()
return ResponseEntity.status(201).body(postService.create(request, authorId))
}
@DeleteMapping("/{id}")
suspend fun delete(
@PathVariable id: Long,
@AuthenticationPrincipal user: UserDetails
): ResponseEntity<Void> {
postService.delete(id, user.username.toLong())
return ResponseEntity.noContent().build()
}
}
Coroutines + JPA 조합 주의사항 Spring Data JPA는 블로킹 I/O다. withContext(Dispatchers.IO)로 감싸서 별도 스레드 풀에서 실행해야 코루틴 컨텍스트를 막지 않는다. suspend fun만 붙인다고 비동기가 되지 않는다. 진짜 논블로킹이 필요하다면 R2DBC로 전환해야 하지만, 생태계가 JPA보다 좁으므로 신중히 결정한다.
Spring Boot 3에서 Spring Security 설정 방식이 변경됐다. WebSecurityConfigurerAdapter는 deprecated됐고, SecurityFilterChain을 Bean으로 등록하는 방식을 써야 한다. JWT는 JJWT 0.12.x 버전으로 생성·검증한다. 0.11.x와 API가 다르므로 버전을 반드시 확인한다.
인증 흐름은 두 단계다. 로그인 시 JWT를 발급하고, 이후 요청마다 OncePerRequestFilter를 구현한 필터에서 토큰을 검증한다. 검증에 성공하면 SecurityContextHolder에 인증 정보를 저장한다.
// JwtService.kt
@Service
class JwtService {
@Value("\${jwt.secret}")
private lateinit var secret: String
@Value("\${jwt.expiration}")
private var expiration: Long = 86400000L
private val key: SecretKey by lazy {
Keys.hmacShaKeyFor(secret.toByteArray(Charsets.UTF_8))
}
fun generateToken(userId: Long): String = Jwts.builder()
.subject(userId.toString())
.issuedAt(Date())
.expiration(Date(System.currentTimeMillis() + expiration))
.signWith(key)
.compact()
fun validateToken(token: String): Boolean = runCatching {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
true
}.getOrDefault(false)
fun extractUserId(token: String): String =
Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).payload.subject
}
// JwtAuthenticationFilter.kt
@Component
class JwtAuthenticationFilter(
private val jwtService: JwtService
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
val token = authHeader.substring(7)
if (jwtService.validateToken(token)) {
val userId = jwtService.extractUserId(token)
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(
User(userId, "", emptyList()), null, emptyList()
)
}
filterChain.doFilter(request, response)
}
}
// SecurityConfig.kt
@Configuration
@EnableWebSecurity
class SecurityConfig(private val jwtAuthFilter: JwtAuthenticationFilter) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests {
it.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
.build()
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}
전역 예외 처리 — @RestControllerAdvice
@RestControllerAdvice로 전역 예외 핸들러를 구성하면 컨트롤러마다 try-catch를 반복할 필요가 없다. Kotlin의 sealed class로 커스텀 예외를 계층화하면 when 표현식으로 모든 경우를 빠짐없이 처리할 수 있다. 처리하지 않은 케이스가 있으면 컴파일 에러가 난다는 점이 Java와의 차이다.
GlobalExceptionHandler.kt — sealed class 예외 계층 & 전역 처리
// 커스텀 예외 계층
sealed class AppException(message: String, val status: HttpStatus) : RuntimeException(message)
class PostNotFoundException(id: Long) : AppException("포스트를 찾을 수 없습니다: $id", HttpStatus.NOT_FOUND)
class ForbiddenException : AppException("접근 권한이 없습니다", HttpStatus.FORBIDDEN)
class UnauthorizedException : AppException("인증이 필요합니다", HttpStatus.UNAUTHORIZED)
// 에러 응답 DTO
data class ErrorResponse(
val status: Int,
val message: String,
val timestamp: String = LocalDateTime.now().toString()
)
// 전역 예외 핸들러
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(AppException::class)
fun handleAppException(e: AppException): ResponseEntity<ErrorResponse> =
ResponseEntity.status(e.status).body(
ErrorResponse(status = e.status.value(), message = e.message ?: "오류가 발생했습니다")
)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
val message = e.bindingResult.fieldErrors
.joinToString(", ") { "${it.field}: ${it.defaultMessage}" }
return ResponseEntity.badRequest().body(ErrorResponse(status = 400, message = message))
}
@ExceptionHandler(Exception::class)
fun handleUnknownException(e: Exception): ResponseEntity<ErrorResponse> =
ResponseEntity.internalServerError().body(
ErrorResponse(status = 500, message = "서버 내부 오류가 발생했습니다")
)
}
sealed class 예외 계층의 장점 when (exception) { is PostNotFoundException -> ... is ForbiddenException -> ... }처럼 exhaustive 분기가 가능하다. else 없이 모든 경우를 처리하지 않으면 컴파일 에러가 난다. 런타임에 예외 케이스를 빠뜨리는 실수를 컴파일 타임에 차단한다.
Docker 멀티스테이지 빌드 & 배포
Spring Boot 3 + Kotlin 앱의 Dockerfile은 멀티스테이지 빌드로 작성한다. 빌드 스테이지는 Gradle을 실행하고, 런타임 스테이지에는 JRE만 포함한다. 이렇게 하면 최종 이미지 크기를 ~1.2GB에서 ~320MB 수준으로 줄일 수 있다.
docker compose로 애플리케이션과 PostgreSQL을 함께 실행한다. depends_on에 healthcheck를 결합하면 DB가 준비되기 전에 앱이 먼저 기동하는 문제를 방지한다.
Dockerfile + docker-compose.yml
# Dockerfile
FROM gradle:8.10-jdk21 AS builder
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY gradle ./gradle
RUN gradle dependencies --no-daemon
COPY src ./src
RUN gradle bootJar --no-daemon -x test
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
COPY --from=builder /app/build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: spring_demo
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
ports:
- "8080:8080"
environment:
DB_USERNAME: postgres
DB_PASSWORD: postgres
JWT_SECRET: change-this-to-32-char-minimum-secret
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/spring_demo
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
멀티스테이지 빌드: 빌드 이미지(~1.2GB) → 런타임 이미지(~320MB). 이미지 크기 약 73% 감소.
프로덕션 배포 전 반드시 변경할 3가지
JWT_SECRET: 최소 32자 이상 랜덤 문자열을 환경 변수로 주입 (코드에 하드코딩 금지)
ddl-auto: validate: 프로덕션에서 create, update 절대 사용 금지. Flyway 또는 Liquibase로 마이그레이션 관리
DB 비밀번호: Docker Secret, AWS Parameter Store, Vault 등 외부 시크릿 매니저 사용