Kotlin Spring Boot 입문: 첫 REST API 만들기

이 글의 핵심

Java로 Spring Boot를 해 봤거나 처음 시작하는 분이 Kotlin으로 첫 API를 띄워 보는 튜토리얼입니다. 같은 엔티티를 Java와 Kotlin으로 나란히 비교해 data class와 null 안전성이 줄여 주는 코드를 확인하고, unresolved reference나 Long? 타입 불일치처럼 입문자가 실제로 부딪히는 에러를 원인과 함께 풀어 줍니다.

들어가며: Kotlin으로 더 쉽게 시작하기

Spring Boot는 Java로 시작하는 경우가 많지만, Kotlin으로 작성해도 같은 애노테이션과 구조를 그대로 씁니다. 이 튜토리얼은 Spring Initializr로 Kotlin 프로젝트를 만들고, data class 모델부터 Repository·Service·Controller까지 차례로 작성해 북마크 REST API를 띄운 뒤 curl로 확인하는 과정을 따라갑니다. 중간에 입문자가 자주 만나는 lateinit, Long? 타입 불일치 같은 에러도 원인과 함께 정리합니다.

Java vs Kotlin 코드 비교

Java:

public class User {
    private Long id;
    private String name;
    
    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }
    
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

Kotlin:

data class User(
    val id: Long,
    var name: String
)

같은 필드와 접근자를 Kotlin에서는 생성자 선언 하나로 표현합니다. val은 getter만, var는 getter와 setter를 만들어 줍니다.

이 튜토리얼에서 만들 것

북마크를 관리하는 간단한 REST API를 만듭니다. 엔드포인트는 다음과 같습니다.

  • GET /api/bookmarks - 북마크 목록 조회
  • GET /api/bookmarks/{id} - 북마크 단건 조회
  • POST /api/bookmarks - 북마크 추가
  • PUT /api/bookmarks/{id} - 북마크 수정
  • DELETE /api/bookmarks/{id} - 북마크 삭제

사전 요구사항

Kotlin은 JVM에서 실행되므로 Java 17 이상(JDK)이 필요하고, IDE는 Kotlin 지원이 기본으로 들어 있는 IntelliJ IDEA Community(무료)를 권장합니다.


1단계: 환경 설정

Java 설치 확인

# 터미널에서 확인
java -version

# 출력 예시:
# openjdk version "17.0.2"

Java가 없다면 다음 방법으로 설치합니다.

  • Windows: https://adoptium.net/ → JDK 17 다운로드
  • Mac: brew install openjdk@17
  • Linux: sudo apt install openjdk-17-jdk

IntelliJ IDEA 설치

https://www.jetbrains.com/idea/download/에서 Community Edition을 내려받으면 됩니다. Kotlin 플러그인은 기본으로 포함되어 있습니다.


2단계: 프로젝트 생성

방법 1: Spring Initializr (웹)

  1. https://start.spring.io/ 접속
  2. 다음과 같이 설정:
    • Project: Gradle - Kotlin
    • Language: Kotlin
    • Spring Boot: Initializr가 기본으로 선택하는 최신 안정 버전(3.x 이상)
    • Packaging: Jar
    • Java: 17
  3. Project Metadata:
    • Group: com.example
    • Artifact: demo
    • Name: demo
    • Package name: com.example.demo
  4. Dependencies 추가:
    • Spring Web
    • Spring Data JPA
    • H2 Database
  5. GENERATE 클릭 → ZIP 다운로드
  6. 압축 해제 후 IntelliJ로 열기

방법 2: IntelliJ IDEA (권장)

  1. New Project → Spring Initializr
  2. 위와 동일하게 설정
  3. Create

3단계: 첫 REST API 만들기

프로젝트 구조

src/main/kotlin/com/example/demo/
├── DemoApplication.kt          # 메인
├── controller/
│   └── BookmarkController.kt   # REST API
├── model/
│   └── Bookmark.kt             # 데이터 모델
├── repository/
│   └── BookmarkRepository.kt   # DB 접근
└── service/
    └── BookmarkService.kt      # 비즈니스 로직

데이터 모델 (Bookmark.kt)

src/main/kotlin/com/example/demo/model/Bookmark.kt:

package com.example.demo.model

import jakarta.persistence.*

@Entity
@Table(name = "bookmarks")
data class Bookmark(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    
    var title: String,
    var url: String,
    var description: String? = null
)

data class는 생성자 매개변수로 선언한 프로퍼티를 기준으로 equals/hashCode/toString/copy를 자동으로 만듭니다. val은 다시 대입할 수 없는 읽기 전용 프로퍼티(Java의 final 필드와 getter), var는 setter까지 있는 가변 프로퍼티이고, String?처럼 ?가 붙은 타입만 null을 담을 수 있습니다. id는 저장 전에는 값이 없고 DB가 채워 주므로 Long? = null로 둡니다.

JPA는 엔티티에 인자 없는 생성자와 상속 가능한(final이 아닌) 클래스를 요구하는데, Kotlin 클래스는 기본이 final이고 이 생성자도 없습니다. Initializr로 만든 Kotlin 프로젝트에는 이를 해결하는 kotlin("plugin.spring"), kotlin("plugin.jpa") 컴파일러 플러그인이 자동으로 들어가므로 따로 설정할 필요가 없습니다. 이 예제의 Bookmark는 연관관계가 없는 단순한 엔티티라 data class로 두었지만, 연관관계가 있는 엔티티에서는 주의할 점이 있습니다(아래 “Java vs Kotlin 실전 비교” 참고).

Repository (BookmarkRepository.kt)

src/main/kotlin/com/example/demo/repository/BookmarkRepository.kt:

package com.example.demo.repository

import com.example.demo.model.Bookmark
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

interface BookmarkRepository : JpaRepository<Bookmark, Long>

인터페이스 선언만 하면 Spring Data JPA가 실행 시점에 findAll, findById, save, deleteById 같은 메서드의 구현체를 만들어 줍니다. JpaRepository를 상속한 인터페이스는 자동으로 빈으로 등록되므로 @Repository 애노테이션은 붙이지 않아도 됩니다.

Service (BookmarkService.kt)

src/main/kotlin/com/example/demo/service/BookmarkService.kt:

package com.example.demo.service

import com.example.demo.model.Bookmark
import com.example.demo.repository.BookmarkRepository
import org.springframework.stereotype.Service
import java.util.NoSuchElementException

@Service
class BookmarkService(
    private val repository: BookmarkRepository
) {
    fun getAllBookmarks(): List<Bookmark> = repository.findAll()
    
    fun getBookmarkById(id: Long): Bookmark? = 
        repository.findById(id).orElse(null)
    
    fun createBookmark(bookmark: Bookmark): Bookmark = 
        repository.save(bookmark)
    
    fun updateBookmark(id: Long, bookmarkDetails: Bookmark): Bookmark {
        val bookmark = repository.findById(id)
            .orElseThrow { NoSuchElementException("Bookmark $id not found") }
        
        bookmark.title = bookmarkDetails.title
        bookmark.url = bookmarkDetails.url
        bookmark.description = bookmarkDetails.description
        
        return repository.save(bookmark)
    }
    
    fun deleteBookmark(id: Long) = repository.deleteById(id)
}

생성자 매개변수에 private val을 붙이면 필드 선언과 초기화가 한 번에 되고, Spring은 생성자가 하나뿐인 클래스에 자동으로 생성자 주입을 합니다. 본문이 식 하나인 함수는 = repository.findAll()처럼 중괄호 없이 쓸 수 있습니다. getBookmarkById의 반환 타입 Bookmark?는 “없을 수 있다”는 사실을 타입으로 드러내므로, 호출하는 쪽이 null 처리를 빠뜨리면 컴파일 에러가 납니다.

Controller (BookmarkController.kt)

src/main/kotlin/com/example/demo/controller/BookmarkController.kt:

package com.example.demo.controller

import com.example.demo.model.Bookmark
import com.example.demo.service.BookmarkService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.NoSuchElementException

@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController(
    private val service: BookmarkService
) {
    // GET /api/bookmarks - 전체 조회
    @GetMapping
    fun getAllBookmarks(): List<Bookmark> = service.getAllBookmarks()
    
    // GET /api/bookmarks/{id} - 단건 조회
    @GetMapping("/{id}")
    fun getBookmarkById(@PathVariable id: Long): ResponseEntity<Bookmark> {
        val bookmark = service.getBookmarkById(id)
        return if (bookmark != null) {
            ResponseEntity.ok(bookmark)
        } else {
            ResponseEntity.notFound().build()
        }
    }
    
    // POST /api/bookmarks - 생성
    @PostMapping
    fun createBookmark(@RequestBody bookmark: Bookmark): Bookmark =
        service.createBookmark(bookmark)
    
    // PUT /api/bookmarks/{id} - 수정
    @PutMapping("/{id}")
    fun updateBookmark(
        @PathVariable id: Long,
        @RequestBody bookmarkDetails: Bookmark
    ): ResponseEntity<Bookmark> {
        return try {
            val updated = service.updateBookmark(id, bookmarkDetails)
            ResponseEntity.ok(updated)
        } catch (e: NoSuchElementException) {
            ResponseEntity.notFound().build()
        }
    }
    
    // DELETE /api/bookmarks/{id} - 삭제
    @DeleteMapping("/{id}")
    fun deleteBookmark(@PathVariable id: Long): ResponseEntity<Void> {
        service.deleteBookmark(id)
        return ResponseEntity.noContent().build()
    }
}

수정 API는 서비스가 던지는 NoSuchElementException만 404로 바꿉니다. RuntimeException 전체를 잡으면 DB 오류 같은 다른 문제까지 404로 둔갑해 원인을 찾기 어려워집니다. 삭제는 응답 본문이 없으므로 204 No Content를 돌려줍니다. Spring Data JPA 3.x의 deleteById는 없는 ID를 받아도 예외 없이 넘어가므로, 없는 ID에 404를 주려면 existsById로 먼저 확인해야 합니다.

application.properties 설정

src/main/resources/application.properties:

# 서버 포트
server.port=8080

# H2 데이터베이스 (개발용)
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA 설정 (Hibernate 6은 DB 방언을 자동 감지하므로 dialect 지정 불필요)
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# H2 콘솔 활성화
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

4단계: 실행 및 테스트

서버 실행

# Gradle
./gradlew bootRun

# 또는 IntelliJ에서 DemoApplication.kt 실행 (▶️ 버튼)

로그 끝에 다음과 비슷한 줄이 보이면 서버가 뜬 것입니다(시간과 세부 문구는 환경과 버전에 따라 다릅니다).

Tomcat started on port 8080 (http) with context path '/'
Started DemoApplicationKt in ... seconds

API 테스트 (curl)

# 1. 북마크 추가
curl -X POST http://localhost:8080/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Kotlin 공식 문서",
    "url": "https://kotlinlang.org",
    "description": "Kotlin 배우기"
  }'

# 응답:
# {"id":1,"title":"Kotlin 공식 문서","url":"https://kotlinlang.org","description":"Kotlin 배우기"}

# 2. 전체 조회
curl http://localhost:8080/api/bookmarks

# 3. 단건 조회
curl http://localhost:8080/api/bookmarks/1

# 4. 수정
curl -X PUT http://localhost:8080/api/bookmarks/1 \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Kotlin 공식 문서 (수정됨)",
    "url": "https://kotlinlang.org/docs",
    "description": "깊이 배우기"
  }'

# 5. 삭제
curl -X DELETE http://localhost:8080/api/bookmarks/1

브라우저에서 테스트

  1. H2 콘솔 접속: http://localhost:8080/h2-console

    • JDBC URL: jdbc:h2:mem:testdb
    • Username: sa
    • Password: (빈칸)
  2. SQL 쿼리:

SELECT * FROM bookmarks;
INSERT INTO bookmarks (title, url, description) 
VALUES ('Google', 'https://google.com', 'Search engine');

5단계: Kotlin의 핵심 문법

data class (자동 생성)

// Kotlin: getter/setter/equals/hashCode/toString/copy 자동 생성
data class User(val id: Long, var name: String)

Null 안전성

// Null 불가 타입
val name: String = "홍길동"
// name = null  // 컴파일 에러!

// Null 가능 타입
val description: String? = null
println(description?.length)  // Safe call: null이면 null 반환

// Elvis 연산자
val length = description?.length ?: 0  // null이면 0

Java에서는 모든 참조가 null일 수 있어 null 검사를 빠뜨리면 실행 중에 NullPointerException이 나지만, Kotlin은 null 가능 여부가 타입에 들어 있어 이런 실수 상당수를 컴파일 단계에서 잡습니다. 다만 Java 라이브러리에서 넘어온 값(플랫폼 타입)은 Kotlin도 null 여부를 알 수 없으므로 여전히 주의해야 합니다.

표현식 함수

// 한 줄 함수
fun add(a: Int, b: Int) = a + b

// 조건식도 표현식
fun max(a: Int, b: Int) = if (a > b) a else b

생성자 주입 (간결함)

// Kotlin: 한 줄
class TodoController(private val service: TodoService)

// Java: 여러 줄
public class TodoController {
    private final TodoService service;
    
    public TodoController(TodoService service) {
        this.service = service;
    }
}

스마트 캐스팅

fun process(value: Any) {
    if (value is String) {
        println(value.length)  // 자동으로 String으로 캐스팅!
    }
}

Kotlin·Spring Boot 자주 하는 실수와 디버깅

자주 하는 실수

data class에서 var 남용

// ❌ 나쁜 예: 모두 가변
data class User(var id: Long, var name: String)

// ✅ 좋은 예: ID는 불변
data class User(val id: Long, var name: String)

!! 연산자 남용 (Null 강제 언래핑)

// ❌ 위험: NullPointerException 가능
val length = description!!.length

// ✅ 안전: Safe call 또는 Elvis
val length = description?.length ?: 0

lateinit 잘못 사용

// ❌ nullable한 변수에는 사용 불가
lateinit var name: String?  // 컴파일 에러

// ✅ Non-null 타입에만 사용
lateinit var name: String

디버깅 팁

# application.properties
logging.level.org.springframework=DEBUG
logging.level.com.example=DEBUG

# SQL 로그 상세히
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
# 바인딩 파라미터 값 (Hibernate 6 / Spring Boot 3 기준)
logging.level.org.hibernate.orm.jdbc.bind=TRACE

IntelliJ 단축키

단축키기능
Ctrl + Alt + L코드 포맷팅
Alt + EnterQuick fix
Ctrl + Space자동 완성

더 배울 것: 예외 처리, 검증, 코루틴, 테스트, 운영 DB

예외 처리

컨트롤러마다 try/catch를 쓰는 대신 @RestControllerAdvice로 예외를 한곳에서 HTTP 응답으로 바꿀 수 있습니다. 이때도 RuntimeException 전체가 아니라 “없음”을 뜻하는 예외만 404로 매핑해야 합니다.

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(NoSuchElementException::class)
    fun handleNotFound(ex: NoSuchElementException) =
        ResponseEntity.status(404).body(ex.message)
}

입력 검증

spring-boot-starter-validation 의존성을 추가하면 Bean Validation 애노테이션을 쓸 수 있습니다. Kotlin에서는 애노테이션이 생성자 매개변수가 아니라 필드에 붙도록 @field: 대상을 지정해야 검증이 실제로 동작합니다.

data class Bookmark(
    @field:NotBlank(message = "제목은 필수입니다")
    var title: String,
    
    @field:URL(message = "올바른 URL을 입력하세요")  // org.hibernate.validator.constraints.URL
    var url: String
)

@PostMapping
fun create(@Valid @RequestBody bookmark: Bookmark) = ...

Kotlin 코루틴

Spring MVC 컨트롤러와 서비스에서도 suspend 함수를 쓸 수 있습니다. 다만 JPA는 블로킹 API이므로, 아래처럼 Dispatchers.IO로 넘겨도 스레드를 점유하는 것은 같고 코드 스타일만 코루틴에 맞춰질 뿐입니다. 논블로킹의 이점을 얻으려면 WebFlux와 R2DBC 같은 논블로킹 스택이 필요합니다.

@Service
class BookmarkService(private val repository: BookmarkRepository) {
    suspend fun getAllBookmarks() = withContext(Dispatchers.IO) {
        repository.findAll()
    }
}

테스트

@SpringBootTest
class BookmarkControllerTest {
    @Test
    fun `북마크 생성 테스트`() {
        // given, when, then
    }
}

운영 DB (PostgreSQL)

// build.gradle.kts
dependencies {
    runtimeOnly("org.postgresql:postgresql")
}
# application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=postgres
spring.datasource.password=${DB_PASSWORD}

비밀번호는 설정 파일에 평문으로 두지 말고 환경 변수 등으로 주입합니다. 운영 DB에서는 ddl-auto=create-drop을 절대 쓰지 말고 validate나 none으로 두며, 스키마 변경은 Flyway·Liquibase 같은 마이그레이션 도구로 관리합니다.


흔한 에러 및 해결

에러 1: unresolved reference: springframework

Spring 클래스를 import했는데 IDE가 찾지 못한다면 대개 의존성이 빠졌거나 Gradle 동기화가 안 된 상태입니다. build.gradle.kts에 스타터가 있는지 확인하고 의존성을 다시 받습니다.

# build.gradle.kts 확인
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
}

# 의존성 다시 다운로드
./gradlew clean build --refresh-dependencies

에러 2: lateinit property has not been initialized

lateinit var로 선언한 프로퍼티를 값이 대입되기 전에 읽으면 런타임에 이 예외가 납니다. Spring 빈 주입에 필드 주입(@Autowired lateinit var)을 쓰다가, 빈이 아닌 곳에서 직접 객체를 만들었을 때 자주 봅니다. 생성자 주입을 쓰면 객체가 만들어지는 시점에 의존성이 반드시 채워지므로 이 문제가 생기지 않습니다.

// ❌ 초기화 전 사용
lateinit var service: BookmarkService
println(service.getAllBookmarks())

// ✅ 생성자 주입 사용
class Controller(private val service: BookmarkService)

에러 3: Type mismatch: inferred type is Long? but Long was expected

엔티티의 id처럼 저장 전에는 null일 수 있어 Long?로 선언한 값을 non-null Long 변수나 인자에 넣으려 할 때 나는 컴파일 오류입니다.

// ❌ Nullable을 Non-null에 할당
val id: Long = bookmark.id  // bookmark.id는 Long?

// ✅ 저장 후라 반드시 값이 있어야 한다면 명확한 예외
val id: Long = bookmark.id ?: error("저장되지 않은 Bookmark입니다")

?: 0L 같은 기본값은 0이 실제로 의미 있는 값일 때만 씁니다. 그렇지 않으면 잘못된 ID로 조회가 진행되어 원인을 찾기 어려워집니다. !!로 에러를 없앨 수도 있지만 null이면 런타임에 NPE가 나므로 원인을 숨기는 방법이 됩니다.


Java vs Kotlin 실전 비교

엔티티 클래스

Java:

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    
    public User() {}
    
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
    
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    
    @Override
    public boolean equals(Object o) { ... }
    
    @Override
    public int hashCode() { ... }
    
    @Override
    public String toString() { ... }
}

Kotlin:

@Entity
@Table(name = "users")
data class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    var name: String,
    var email: String
)

getter·setter·생성자를 직접 쓰지 않아도 되니 엔티티가 훨씬 짧아집니다. 다만 JPA 엔티티에 data class를 쓰면 equals/hashCode/toString이 모든 필드를 기준으로 만들어져, 지연 로딩 연관관계가 있을 때 의도치 않은 쿼리나 순환 참조가 생길 수 있습니다. 연관관계가 있는 엔티티는 일반 class로 두고, data class는 DTO에 쓰는 편이 안전합니다.


Spring Boot 내부와 프로덕션에 가까운 메모

서블릿 스택과 임베디드 Tomcat

Spring Boot Web은 기본적으로 서블릿 컨테이너(임베디드 Tomcat 등) 위에서 동작합니다. HTTP 요청은 커넥터 스레드 풀에서 받으며, 디스패처 서블릿이 컨트롤러 매핑으로 넘깁니다. 블로킹 I/O 모델에서는 동시 처리량이 스레드 풀 크기와 직결되므로, 장시간 점유하는 요청이 많으면 스레드 고갈이 납니다. 이때는 WebFlux·비동기 서블릿·외부 워커 큐 같은 아키텍처 변경을 검토합니다.

설정 계층: application.yml과 프로파일

실제 서비스에서는 application-dev.yml, application-prod.yml처럼 프로파일로 DB URL·로그 레벨을 나눕니다. 시크릿은 저장소에 평문으로 두지 않고 환경 변수·시크릿 매니저로 주입합니다.

관측: Actuator와 헬스

운영 환경에서는 spring-boot-starter-actuator로 /actuator/health, /actuator/metrics 같은 엔드포인트를 열고(기본으로 웹에 노출되는 것은 health뿐이며 나머지는 management.endpoints.web.exposure.include로 지정), Kubernetes라면 liveness/readiness 프로브와 연결합니다. 데이터베이스가 끊긴 상태에서도 프로세스는 살아 있을 수 있으므로 readiness에 DB 검사를 넣는지 팀 규약을 정합니다.

트러블슈팅 빠른 표

증상점검
Whitelabel Error Page 500서버 로그 스택 트레이스, @ControllerAdvice 없을 때 기본 페이지
포트 충돌server.port, 다른 로컬 프로세스 점유
JPA가 테이블을 안 만듦ddl-auto, 엔티티 스캔 패키지, DB 권한
CORS 에러@CrossOrigin 또는 API 게이트웨이에서 헤더 — 브라우저만의 증상임을 기억

다음 단계

여기까지 만든 API는 인메모리 H2와 단순한 계층 구조만 쓰므로, 실제 서비스로 가려면 예외 처리, 입력 검증, 테스트, 운영 DB 연결을 붙여야 합니다. 자세한 내용은 Kotlin 시리즈 #9 Spring Boot에서 이어집니다.

같이 보면 좋은 글