TechFeedTechFeed
Programming Languages

Kotlin + Spring Boot 3 실전 튜토리얼 — Coroutines, JWT 인증, PostgreSQL, Docker 배포

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 funwithContext(Dispatchers.IO) 패턴으로 콜백 지옥 없이 비동기 처리가 가능하다. Spring MVC 위에서도 동작하므로 기존 코드베이스에 점진적으로 도입할 수 있다는 점이 실무에서 중요하다.

프로젝트 초기 설정 — start.spring.io 구성

start.spring.io에서 프로젝트를 생성한다. 설정 기준은 아래와 같다.

  • Project: Gradle - Kotlin
  • Language: Kotlin
  • Spring Boot: 3.3.x
  • Packaging: Jar
  • Java: 21
  • 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여야 하고, @Idvar로 선언해야 프록시 생성이 가능하다. kotlin("plugin.jpa") 플러그인이 이 작업을 자동으로 처리해준다.

블로그 포스트 CRUD API를 예시로 작성한다. 먼저 데이터소스 설정부터 시작한다.

application.yml — PostgreSQL 데이터소스 & JWT 설정
spring: datasource: url: jdbc:postgresql://localhost:5432/spring_demo username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} driver-class-name: org.postgresql.Driver jpa: hibernate: ddl-auto: validate show-sql: false properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-here-minimum-32-chars} expiration: 86400000
Kotlin Spring Boot 프로젝트 레이어드 아키텍처 — 컨트롤러, 서비스, 리포지토리 구조
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에서 반드시 붙인다.

PostService.kt + PostController.kt — Coroutines 비동기 CRUD
// 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 Security JWT 인증 흐름 다이어그램 — Bearer 토큰 요청, 필터 검증, SecurityContext 저장
JWT 인증 흐름: 클라이언트 Bearer 토큰 → JwtAuthenticationFilter → SecurityContextHolder → Controller

JWT 인증 구현 — Spring Security 설정

Spring Boot 3에서 Spring Security 설정 방식이 변경됐다. WebSecurityConfigurerAdapter는 deprecated됐고, SecurityFilterChain을 Bean으로 등록하는 방식을 써야 한다. JWT는 JJWT 0.12.x 버전으로 생성·검증한다. 0.11.x와 API가 다르므로 버전을 반드시 확인한다.

인증 흐름은 두 단계다. 로그인 시 JWT를 발급하고, 이후 요청마다 OncePerRequestFilter를 구현한 필터에서 토큰을 검증한다. 검증에 성공하면 SecurityContextHolder에 인증 정보를 저장한다.

JwtService.kt + JwtAuthenticationFilter.kt + SecurityConfig.kt
// 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_onhealthcheck를 결합하면 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:
Docker Compose Spring Boot PostgreSQL 컨테이너 실행 — 헬스체크 통과 후 앱 기동 로그
멀티스테이지 빌드: 빌드 이미지(~1.2GB) → 런타임 이미지(~320MB). 이미지 크기 약 73% 감소.
프로덕션 배포 전 반드시 변경할 3가지
  • JWT_SECRET: 최소 32자 이상 랜덤 문자열을 환경 변수로 주입 (코드에 하드코딩 금지)
  • ddl-auto: validate: 프로덕션에서 create, update 절대 사용 금지. Flyway 또는 Liquibase로 마이그레이션 관리
  • DB 비밀번호: Docker Secret, AWS Parameter Store, Vault 등 외부 시크릿 매니저 사용
KotlinSpring Boot 3CoroutinesJWTREST APIPostgreSQLDockerSpring SecurityJPA백엔드

관련 도구

관련 포스트

Axum 실전 튜토리얼 — Rust 비동기 웹 서버, JWT 인증, PostgreSQL 연동, Docker 배포2026-04-19Go 언어 REST API 서버 튜토리얼 — 환경 설정부터 DB 연동, Docker 배포까지2026-04-05Go로 마이크로서비스 구축하기2026-03-01Kotlin Multiplatform 실전 가이드2026-03-10