Notice
Recent Posts
Recent Comments
Link
반응형
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Tags
- Sort
- Effective Java 3
- 오블완
- 김영한
- Spring
- 스프링핵심원리
- effectivejava
- 자바스크립트
- 티스토리챌린지
- Effective Java
- 스프링
- Kotlin
- 예제로 배우는 스프링 입문
- 알고리즘
- 카카오
- 이펙티브 자바
- 엘라스틱서치
- 스프링부트
- ElasticSearch
- java
- JavaScript
- 이차전지관련주
- 스프링 핵심원리
- springboot
- k8s
- 클린아키텍처
- 이펙티브자바
- 알고리즘정렬
- kubernetes
- 자바
Archives
- Today
- Total
Kim-Baek 개발자 이야기
Spring Boot와 Kotlin | 실전 백엔드 개발 완벽 가이드 본문
반응형
이 글을 읽으면: Java 중심의 Spring Boot를 Kotlin으로 더 간결하고 안전하게 작성하는 방법을 배울 수 있습니다. Controller부터 JPA, 코루틴 비동기 처리까지 실전 REST API 구현을 완벽하게 마스터하세요.
📌 목차
- 들어가며 - 왜 Spring Boot + Kotlin인가?
- 프로젝트 설정 - Gradle + Kotlin DSL
- Controller와 REST API
- JPA with Kotlin - Entity 설계
- Service와 비즈니스 로직
- 코루틴으로 비동기 처리
- 실전 REST API 구현
- 마무리 - 다음 편 예고

들어가며 - 왜 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 주의사항
- Entity는 var 사용 (수정 가능하게)
- 기본값 제공 (id = 0)
- plugin.jpa 사용 (기본 생성자 자동)
💬 댓글로 알려주세요!
- Kotlin으로 Spring Boot 개발해보셨나요?
- 어떤 점이 가장 편하셨나요?
- 이 글이 도움이 되셨나요?
반응형
'개발 > java basic' 카테고리의 다른 글
| Kotlin 코루틴 심화 | Flow, Channel로 데이터 스트림 다루기 (0) | 2026.01.01 |
|---|---|
| Kotlin 코루틴 기초 | launch, async, suspend로 비동기 정복하기 (0) | 2025.12.29 |
| Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result로 안전한 코드 만들기 (0) | 2025.12.28 |
| Kotlin Delegation 완벽 가이드 | by 키워드로 보일러플레이트 제거하기 (0) | 2025.12.28 |
| Kotlin 상속과 인터페이스 완벽 가이드 | open, abstract, sealed (1) | 2025.12.27 |
Comments
