Kim-Baek 개발자 이야기

Spring Boot와 Kotlin | 실전 백엔드 개발 완벽 가이드 본문

개발/java basic

Spring Boot와 Kotlin | 실전 백엔드 개발 완벽 가이드

김백개발자 2026. 1. 2. 09:03
반응형

이 글을 읽으면: Java 중심의 Spring Boot를 Kotlin으로 더 간결하고 안전하게 작성하는 방법을 배울 수 있습니다. Controller부터 JPA, 코루틴 비동기 처리까지 실전 REST API 구현을 완벽하게 마스터하세요.


📌 목차

  1. 들어가며 - 왜 Spring Boot + Kotlin인가?
  2. 프로젝트 설정 - Gradle + Kotlin DSL
  3. Controller와 REST API
  4. JPA with Kotlin - Entity 설계
  5. Service와 비즈니스 로직
  6. 코루틴으로 비동기 처리
  7. 실전 REST API 구현
  8. 마무리 - 다음 편 예고

 

들어가며 - 왜 Spring Boot + Kotlin인가?

실제 프로젝트에서 겪은 변화

2024년 11월, 레거시 전환 프로젝트

기존: Java + Spring Boot
- Getter/Setter 보일러플레이트 500줄
- Null 체크 코드 도처에 산재
- Builder 패턴 수동 작성

전환 후: Kotlin + Spring Boot
- Data class로 보일러플레이트 제거
- Null Safety로 NPE 90% 감소
- 코드량 40% 감소

결과: 개발 속도 2배, 버그 절반

Java vs Kotlin 코드 비교

Entity 클래스 비교

// Java - 50줄
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    protected User() {}  // JPA 기본 생성자
    
    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 - 8줄!
@Entity
@Table(name = "users")
data class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    @Column(nullable = false)
    var name: String,
    
    @Column(nullable = false, unique = true)
    var email: String
)

장점 정리

측면 Java Kotlin

코드량 100% 40%
Null 안전성 @Nullable 힌트 타입 시스템
불변성 final 명시 val 기본
Data class Lombok 의존 언어 내장

프로젝트 설정 - Gradle + Kotlin DSL

Spring Initializr로 시작하기

1. https://start.spring.io 접속

설정:
- Project: Gradle - Kotlin
- Language: Kotlin
- Spring Boot: 3.2.x (최신 안정 버전)
- Java: 17 이상

Dependencies:
- Spring Web
- Spring Data JPA
- H2 Database (테스트용)
- Validation

build.gradle.kts 설정

Kotlin DSL로 작성한 Gradle

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.2.0"
    id("io.spring.dependency-management") version "1.1.4"
    kotlin("jvm") version "1.9.20"
    kotlin("plugin.spring") version "1.9.20"  // Spring 어노테이션 open
    kotlin("plugin.jpa") version "1.9.20"     // JPA Entity open
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot
    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")
    
    // Kotlin
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    
    // 코루틴 (선택)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    
    // Database
    runtimeOnly("com.h2database:h2")
    
    // Test
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += "-Xjsr305=strict"  // Null Safety 강화
        jvmTarget = "17"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

왜 kotlin.plugin.spring이 필요할까?

Spring은 프록시 생성을 위해 클래스가 open이어야 함
Kotlin은 기본적으로 final

kotlin.plugin.spring:
→ @Component, @Service 등에 자동으로 open 추가

application.yml 설정

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
    
  h2:
    console:
      enabled: true
      path: /h2-console
      
  jpa:
    hibernate:
      ddl-auto: create-drop  # 개발용 (운영에선 validate)
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE

Controller와 REST API

기본 Controller

Java vs Kotlin

// Java
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping
    public List<User> getUsers() {
        return userService.findAll();
    }
}
// Kotlin - 간결함!
@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService  // 생성자 주입
) {
    @GetMapping
    fun getUsers() = userService.findAll()
}

왜 더 간결할까?

1. 생성자 주입이 클래스 선언과 동시에
2. return 타입 추론
3. 단일 표현식 함수

REST API 완성형

@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {
    
    // 전체 조회
    @GetMapping
    fun getUsers(): List<UserResponse> {
        return userService.findAll()
            .map { it.toResponse() }
    }
    
    // 단건 조회
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): UserResponse {
        return userService.findById(id).toResponse()
    }
    
    // 생성
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun createUser(
        @RequestBody @Valid request: CreateUserRequest
    ): UserResponse {
        return userService.create(request).toResponse()
    }
    
    // 수정
    @PutMapping("/{id}")
    fun updateUser(
        @PathVariable id: Long,
        @RequestBody @Valid request: UpdateUserRequest
    ): UserResponse {
        return userService.update(id, request).toResponse()
    }
    
    // 삭제
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun deleteUser(@PathVariable id: Long) {
        userService.delete(id)
    }
}

DTO 설계

Request/Response 분리

// Request DTO
data class CreateUserRequest(
    @field:NotBlank(message = "이름은 필수입니다")
    val name: String,
    
    @field:Email(message = "올바른 이메일 형식이 아닙니다")
    val email: String,
    
    @field:Min(value = 14, message = "만 14세 이상만 가입 가능합니다")
    val age: Int
)

data class UpdateUserRequest(
    val name: String?,
    val email: String?
)

// Response DTO
data class UserResponse(
    val id: Long,
    val name: String,
    val email: String,
    val age: Int
)

// Extension 함수로 변환
fun User.toResponse() = UserResponse(
    id = id,
    name = name,
    email = email,
    age = age
)

왜 @field를 붙일까?

Kotlin의 프로퍼티는 필드 + getter/setter

@NotBlank를 그냥 쓰면:
→ getter에 붙음

@field:NotBlank를 쓰면:
→ 필드에 붙음 (Validation이 원하는 것)

예외 처리

// Custom Exception
class UserNotFoundException(id: Long) : 
    RuntimeException("사용자를 찾을 수 없습니다: $id")

class DuplicateEmailException(email: String) : 
    RuntimeException("이미 사용 중인 이메일입니다: $email")

// Global Exception Handler
@RestControllerAdvice
class GlobalExceptionHandler {
    
    @ExceptionHandler(UserNotFoundException::class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun handleUserNotFound(e: UserNotFoundException): ErrorResponse {
        return ErrorResponse(
            status = 404,
            message = e.message ?: "Not Found"
        )
    }
    
    @ExceptionHandler(DuplicateEmailException::class)
    @ResponseStatus(HttpStatus.CONFLICT)
    fun handleDuplicateEmail(e: DuplicateEmailException): ErrorResponse {
        return ErrorResponse(
            status = 409,
            message = e.message ?: "Conflict"
        )
    }
    
    @ExceptionHandler(MethodArgumentNotValidException::class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    fun handleValidation(e: MethodArgumentNotValidException): ErrorResponse {
        val errors = e.bindingResult.fieldErrors
            .associate { it.field to (it.defaultMessage ?: "Invalid") }
        
        return ErrorResponse(
            status = 400,
            message = "입력값 검증 실패",
            errors = errors
        )
    }
}

data class ErrorResponse(
    val status: Int,
    val message: String,
    val errors: Map<String, String>? = null
)

JPA with Kotlin - Entity 설계

Entity 기본 설계

주의할 점들

// ❌ 나쁜 예
@Entity
data class User(
    @Id @GeneratedValue
    val id: Long,  // var여야 함 (JPA가 값 할당)
    val name: String  // 불변이면 수정 불가
)

// ✅ 좋은 예
@Entity
data class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,  // 기본값 제공
    
    var name: String,  // 수정 가능하게 var
    var email: String
) {
    // JPA를 위한 기본 생성자
    constructor() : this(0, "", "")
}

더 나은 방법: allopen + noarg 플러그인

// build.gradle.kts에 이미 추가됨
kotlin("plugin.jpa") version "1.9.20"

// 이제 JPA 전용 기본 생성자 자동 생성!
@Entity
data class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    var name: String,
    var email: String,
    var age: Int
)

실전 Entity 예제

@Entity
@Table(
    name = "users",
    indexes = [
        Index(name = "idx_email", columnList = "email")
    ]
)
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    @Column(nullable = false, length = 50)
    var name: String,
    
    @Column(nullable = false, unique = true, length = 100)
    var email: String,
    
    @Column(nullable = false)
    var age: Int,
    
    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
    val orders: MutableList<Order> = mutableListOf(),
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    val createdAt: LocalDateTime = LocalDateTime.now(),
    
    @LastModifiedDate
    @Column(nullable = false)
    var updatedAt: LocalDateTime = LocalDateTime.now()
) {
    // 비즈니스 메서드
    fun addOrder(order: Order) {
        orders.add(order)
        order.user = this
    }
}

@Entity
@Table(name = "orders")
data class Order(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    @Column(nullable = false)
    var productName: String,
    
    @Column(nullable = false)
    var amount: Int,
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    var user: User
)

Repository 인터페이스

interface UserRepository : JpaRepository<User, Long> {
    
    // 메서드 이름으로 쿼리 생성
    fun findByEmail(email: String): User?
    
    fun existsByEmail(email: String): Boolean
    
    fun findByAgeGreaterThanEqual(age: Int): List<User>
    
    // @Query로 명시적 쿼리
    @Query("SELECT u FROM User u WHERE u.name LIKE %:keyword%")
    fun searchByName(@Param("keyword") keyword: String): List<User>
    
    // Native Query
    @Query(
        value = "SELECT * FROM users WHERE age BETWEEN :min AND :max",
        nativeQuery = true
    )
    fun findByAgeRange(
        @Param("min") min: Int,
        @Param("max") max: Int
    ): List<User>
}

Service와 비즈니스 로직

Service 계층 설계

@Service
@Transactional(readOnly = true)  // 기본은 읽기 전용
class UserService(
    private val userRepository: UserRepository
) {
    
    // 전체 조회
    fun findAll(): List<User> {
        return userRepository.findAll()
    }
    
    // 단건 조회
    fun findById(id: Long): User {
        return userRepository.findById(id)
            .orElseThrow { UserNotFoundException(id) }
    }
    
    // 생성
    @Transactional  // 쓰기는 명시적으로
    fun create(request: CreateUserRequest): User {
        // 이메일 중복 체크
        if (userRepository.existsByEmail(request.email)) {
            throw DuplicateEmailException(request.email)
        }
        
        val user = User(
            name = request.name,
            email = request.email,
            age = request.age
        )
        
        return userRepository.save(user)
    }
    
    // 수정
    @Transactional
    fun update(id: Long, request: UpdateUserRequest): User {
        val user = findById(id)
        
        request.name?.let { user.name = it }
        request.email?.let { 
            // 이메일 변경 시 중복 체크
            if (it != user.email && userRepository.existsByEmail(it)) {
                throw DuplicateEmailException(it)
            }
            user.email = it
        }
        
        return user  // Dirty Checking으로 자동 업데이트
    }
    
    // 삭제
    @Transactional
    fun delete(id: Long) {
        val user = findById(id)
        userRepository.delete(user)
    }
}

Dirty Checking이 뭐길래?

JPA의 마법:

@Transactional 안에서 Entity 수정하면
→ 트랜잭션 종료 시 자동으로 UPDATE 쿼리 실행
→ save() 호출 불필요!

비유: 문서 자동 저장
- 편집하면 자동으로 저장됨
- 저장 버튼 누를 필요 없음

복잡한 비즈니스 로직 예제

@Service
@Transactional(readOnly = true)
class OrderService(
    private val orderRepository: OrderRepository,
    private val userRepository: UserRepository,
    private val productRepository: ProductRepository
) {
    
    @Transactional
    fun createOrder(request: CreateOrderRequest): Order {
        // 1. 사용자 조회
        val user = userRepository.findById(request.userId)
            .orElseThrow { UserNotFoundException(request.userId) }
        
        // 2. 상품 조회
        val product = productRepository.findById(request.productId)
            .orElseThrow { ProductNotFoundException(request.productId) }
        
        // 3. 재고 확인
        if (product.stock < request.quantity) {
            throw InsufficientStockException(product.name)
        }
        
        // 4. 주문 생성
        val order = Order(
            productName = product.name,
            amount = product.price * request.quantity,
            user = user
        )
        
        // 5. 재고 차감
        product.stock -= request.quantity
        
        // 6. 저장
        return orderRepository.save(order)
    }
    
    fun getUserOrders(userId: Long): List<OrderResponse> {
        val user = userRepository.findById(userId)
            .orElseThrow { UserNotFoundException(userId) }
        
        return user.orders.map { it.toResponse() }
    }
}

코루틴으로 비동기 처리

왜 코루틴을 쓸까?

동기 vs 비동기

// ❌ 동기 - 느림
@GetMapping("/dashboard")
fun getDashboard(userId: Long): Dashboard {
    val user = userService.findById(userId)        // 300ms
    val orders = orderService.findByUser(userId)   // 400ms
    val products = productService.getRecommended() // 500ms
    
    return Dashboard(user, orders, products)
}
// 총 시간: 1200ms 😢
// ✅ 비동기 - 빠름
@GetMapping("/dashboard")
suspend fun getDashboard(userId: Long): Dashboard = coroutineScope {
    val userDeferred = async { userService.findById(userId) }
    val ordersDeferred = async { orderService.findByUser(userId) }
    val productsDeferred = async { productService.getRecommended() }
    
    Dashboard(
        userDeferred.await(),
        ordersDeferred.await(),
        productsDeferred.await()
    )
}
// 총 시간: 500ms (가장 느린 것만) 😊

Spring Boot + 코루틴 설정

build.gradle.kts에 의존성 추가

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
}

Controller에서 suspend 사용

@RestController
@RequestMapping("/api/async")
class AsyncController(private val asyncService: AsyncService) {
    
    @GetMapping("/users/{id}")
    suspend fun getUser(@PathVariable id: Long): UserResponse {
        return asyncService.getUserAsync(id)
    }
    
    @PostMapping("/batch")
    suspend fun batchProcess(
        @RequestBody ids: List<Long>
    ): List<UserResponse> = coroutineScope {
        ids.map { id ->
            async { asyncService.getUserAsync(id) }
        }.awaitAll()
    }
}

비동기 Service

@Service
class AsyncService(
    private val userRepository: UserRepository,
    private val externalApiClient: ExternalApiClient
) {
    
    suspend fun getUserAsync(id: Long): UserResponse = withContext(Dispatchers.IO) {
        val user = userRepository.findById(id)
            .orElseThrow { UserNotFoundException(id) }
        
        user.toResponse()
    }
    
    suspend fun enrichUserData(userId: Long): EnrichedUser = coroutineScope {
        // 동시에 여러 API 호출
        val userDeferred = async { getUserAsync(userId) }
        val profileDeferred = async { externalApiClient.getProfile(userId) }
        val statsDeferred = async { externalApiClient.getStats(userId) }
        
        EnrichedUser(
            user = userDeferred.await(),
            profile = profileDeferred.await(),
            stats = statsDeferred.await()
        )
    }
}

실전 REST API 구현

프로젝트 구조

src/main/kotlin/com/example/demo
├── DemoApplication.kt
├── config
│   └── WebConfig.kt
├── controller
│   ├── UserController.kt
│   └── OrderController.kt
├── service
│   ├── UserService.kt
│   └── OrderService.kt
├── repository
│   ├── UserRepository.kt
│   └── OrderRepository.kt
├── domain
│   ├── User.kt
│   └── Order.kt
├── dto
│   ├── UserDto.kt
│   └── OrderDto.kt
└── exception
    ├── CustomExceptions.kt
    └── GlobalExceptionHandler.kt

완성된 CRUD API

1. Entity

@Entity
@Table(name = "products")
data class Product(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    @Column(nullable = false)
    var name: String,
    
    @Column(nullable = false)
    var price: Int,
    
    @Column(nullable = false)
    var stock: Int,
    
    @Column(length = 1000)
    var description: String? = null
)

2. Repository

interface ProductRepository : JpaRepository<Product, Long> {
    fun findByNameContaining(keyword: String): List<Product>
    fun findByPriceBetween(minPrice: Int, maxPrice: Int): List<Product>
}

3. Service

@Service
@Transactional(readOnly = true)
class ProductService(private val productRepository: ProductRepository) {
    
    fun findAll() = productRepository.findAll()
    
    fun findById(id: Long) = productRepository.findById(id)
        .orElseThrow { ProductNotFoundException(id) }
    
    fun search(keyword: String) = 
        productRepository.findByNameContaining(keyword)
    
    @Transactional
    fun create(request: CreateProductRequest): Product {
        val product = Product(
            name = request.name,
            price = request.price,
            stock = request.stock,
            description = request.description
        )
        return productRepository.save(product)
    }
    
    @Transactional
    fun update(id: Long, request: UpdateProductRequest): Product {
        val product = findById(id)
        
        request.name?.let { product.name = it }
        request.price?.let { product.price = it }
        request.stock?.let { product.stock = it }
        request.description?.let { product.description = it }
        
        return product
    }
    
    @Transactional
    fun delete(id: Long) {
        productRepository.deleteById(id)
    }
}

4. Controller

@RestController
@RequestMapping("/api/products")
class ProductController(private val productService: ProductService) {
    
    @GetMapping
    fun getProducts(
        @RequestParam(required = false) keyword: String?
    ): List<ProductResponse> {
        return if (keyword != null) {
            productService.search(keyword).map { it.toResponse() }
        } else {
            productService.findAll().map { it.toResponse() }
        }
    }
    
    @GetMapping("/{id}")
    fun getProduct(@PathVariable id: Long) = 
        productService.findById(id).toResponse()
    
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun createProduct(@RequestBody @Valid request: CreateProductRequest) =
        productService.create(request).toResponse()
    
    @PutMapping("/{id}")
    fun updateProduct(
        @PathVariable id: Long,
        @RequestBody @Valid request: UpdateProductRequest
    ) = productService.update(id, request).toResponse()
    
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun deleteProduct(@PathVariable id: Long) = 
        productService.delete(id)
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • 프로젝트 설정 - Gradle Kotlin DSL
  • Controller - REST API 구현
  • JPA Entity - Kotlin으로 간결하게
  • Service - 트랜잭션과 비즈니스 로직
  • 코루틴 - 비동기 처리
  • 실전 CRUD - 완전한 API 구현

다음 편에서 배울 것 📚

17편: 테스트 코드 작성 | JUnit5 + MockK로 안전한 코드 만들기

  • JUnit5 기본
  • MockK로 목 객체 만들기
  • Controller 테스트
  • Service 테스트
  • Repository 테스트
  • 통합 테스트

핵심 정리

Kotlin + Spring Boot 장점

측면 개선

코드량 40% 감소
Null 안전성 NPE 90% 감소
불변성 val/var로 명확
비동기 코루틴으로 간결

JPA with Kotlin 주의사항

  1. Entity는 var 사용 (수정 가능하게)
  2. 기본값 제공 (id = 0)
  3. plugin.jpa 사용 (기본 생성자 자동)

💬 댓글로 알려주세요!

  • Kotlin으로 Spring Boot 개발해보셨나요?
  • 어떤 점이 가장 편하셨나요?
  • 이 글이 도움이 되셨나요?

 

반응형
Comments