Kotlin + Spring 실전 — 자바 개발자가 코틀린으로 전환할 때 알아야 할 것들
"코틀린이 자바와 100% 호환된다고 하는데, 실제로 Spring 프로젝트에 코틀린을 적용하면 예상 못한 문제가 꽤 많습니다. 어떤 것들을 미리 알고 있어야 할까요?"
코틀린으로 Spring 프로젝트를 작성하면 null safety, 간결한 문법, 코루틴 등의 혜택을 받을 수 있습니다. 하지만 코틀린의 설계 철학과 Spring의 동작 방식이 충돌하는 지점이 있어서, 몇 가지 플러그인과 규칙을 알아두어야 합니다.
필수 플러그인 — all-open, no-arg
all-open 플러그인
코틀린의 클래스는 기본적으로 final입니다. 그런데 Spring은 빈을 프록시로 감싸서 AOP, 트랜잭션 등을 구현합니다. final 클래스는 상속할 수 없으니 프록시를 만들 수 없습니다.
// 코틀린에서 이 클래스는 final
@Service
class UserService { // → CGLIB 프록시 생성 불가!
@Transactional
fun createUser(name: String) { /* ... */ }
}
kotlin-spring 컴파일러 플러그인(all-open의 Spring 프리셋)이 이 문제를 해결합니다.
// build.gradle.kts
plugins {
kotlin("plugin.spring") version "1.9.0" // all-open의 Spring 프리셋
}
이 플러그인은 @Component, @Service, @Repository, @Controller, @Configuration, @Transactional 등이 붙은 클래스를 자동으로 open으로 만듭니다.
no-arg 플러그인
JPA 엔티티는 기본 생성자(파라미터 없는 생성자)가 필요합니다. 하지만 코틀린에서는 모든 프로퍼티를 주 생성자에서 선언하는 것이 관례입니다.
// 기본 생성자가 없음 — JPA가 인스턴스를 생성할 수 없음
@Entity
class User(
@Id @GeneratedValue
val id: Long,
val name: String,
val email: String
)
kotlin-jpa 플러그인이 컴파일 시점에 기본 생성자를 자동 생성합니다.
// build.gradle.kts
plugins {
kotlin("plugin.jpa") version "1.9.0"
}
생성된 기본 생성자는 리플렉션으로만 접근 가능하고, 코틀린 코드에서 직접 호출할 수 없습니다.
JPA 엔티티 — data class를 쓰면 안 되는 이유
JPA 엔티티에 data class를 사용하고 싶은 유혹이 있지만, 여러 문제가 있습니다.
문제 1: equals/hashCode
data class의 equals()와 hashCode()는 모든 프로퍼티를 포함합니다. JPA에서는 ID 기반 동등성이 필요합니다.
// 문제가 되는 코드
@Entity
data class User(
@Id @GeneratedValue
val id: Long = 0,
var name: String = ""
)
val user1 = userRepository.findById(1) // name = "김코틀린"
user1.name = "이코틀린"
val user2 = userRepository.findById(1) // name = "이코틀린"
// data class의 equals는 name도 비교 → 같은 엔티티인데 false
println(user1 == user2) // false
문제 2: 지연 로딩과 toString
data class의 toString()이 모든 필드에 접근하므로, 지연 로딩 관계가 강제로 초기화됩니다.
// OneToMany가 LAZY인데 toString()이 강제로 로딩
data class User(
@OneToMany(fetch = FetchType.LAZY)
val orders: List<Order> = emptyList()
)
println(user) // orders 접근 → N+1 쿼리 발생
권장 패턴
@Entity
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
) {
// ID 기반 equals/hashCode
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is User) return false
return id != 0L && id == other.id
}
override fun hashCode(): Int = id.hashCode()
override fun toString(): String = "User(id=$id, name=$name)"
}
어노테이션 use-site target
코틀린에서 주 생성자의 프로퍼티는 파라미터, 필드, getter를 동시에 나타냅니다. 어노테이션이 어디에 붙는지 명시해야 합니다.
// ❌ @NotBlank가 생성자 파라미터에 붙어서 Bean Validation이 동작 안 함
class CreateUserRequest(
@NotBlank
val name: String,
@Email
val email: String
)
// ✅ @field:를 명시하면 필드에 어노테이션이 붙음
class CreateUserRequest(
@field:NotBlank
val name: String,
@field:Email
val email: String
)
자주 사용하는 use-site target을 정리합니다.
| Target | 적용 대상 | 용도 |
|---|---|---|
@field: | 자바 필드 | Bean Validation, JPA |
@get: | getter | Jackson, Spring 직렬화 |
@param: | 생성자 파라미터 | 기본값 |
@set: | setter | 특정 setter 로직 |
nullable과 Spring의 관계
코틀린의 null safety와 Spring이 만나면 주의할 점이 있습니다.
Repository 반환 타입
interface UserRepository : JpaRepository<User, Long> {
// findById는 Optional<User>을 반환 — 코틀린에서는?
// Spring Data가 코틀린을 인식하여 nullable로 반환 가능
fun findByEmail(email: String): User? // null 가능
fun findByName(name: String): User // null이면 예외 발생
}
Spring Data는 코틀린의 nullable 타입을 인식합니다. User?로 선언하면 결과가 없을 때 null을, User로 선언하면 결과가 없을 때 예외를 던집니다.
@RequestParam과 null
@GetMapping("/users")
fun searchUsers(
@RequestParam name: String, // 필수 파라미터
@RequestParam email: String? = null // 선택 파라미터 (null 허용, 기본값 null)
): List<User> { /* ... */ }
코루틴과 Spring WebFlux
Spring WebFlux에서 코루틴을 사용하면 비동기 코드를 동기적으로 작성할 수 있습니다.
suspend Controller
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService
) {
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): UserResponse {
val user = userService.findById(id) // suspend 함수
return UserResponse.from(user)
}
@GetMapping
fun getAllUsers(): Flow<UserResponse> { // Flow 반환
return userService.findAll()
.map { UserResponse.from(it) }
}
}
Spring은 suspend 함수를 인식하고 내부적으로 Mono/Flux로 변환합니다.
코루틴 서비스 계층
@Service
class UserService(
private val userRepository: UserCoroutineRepository,
private val notificationClient: NotificationClient
) {
suspend fun createUser(request: CreateUserRequest): User {
val user = User(name = request.name, email = request.email)
val saved = userRepository.save(user)
// 비동기로 알림 전송 — 실패해도 사용자 생성은 성공
coroutineScope {
launch {
try {
notificationClient.sendWelcome(saved.email)
} catch (e: Exception) {
logger.warn("알림 전송 실패", e)
}
}
}
return saved
}
}
R2DBC 코루틴 Repository
interface UserCoroutineRepository : CoroutineCrudRepository<User, Long> {
suspend fun findByEmail(email: String): User?
fun findByNameContaining(name: String): Flow<User>
}
Jackson 직렬화/역직렬화
코틀린 클래스를 Jackson으로 처리하려면 jackson-module-kotlin이 필요합니다.
// build.gradle.kts
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}
이 모듈이 하는 일은 다음과 같습니다.
- 주 생성자를 통한 역직렬화 지원
- data class의 프로퍼티 인식
- 코틀린 기본값 파라미터 지원
- 코틀린 nullable 타입 인식
// 이 data class가 Jackson으로 정상적으로 직렬화/역직렬화됨
data class UserResponse(
val id: Long,
val name: String,
val email: String,
val createdAt: LocalDateTime = LocalDateTime.now() // 기본값 지원
)
테스트 작성
코틀린의 특성을 활용한 테스트 코드입니다.
@SpringBootTest
class UserServiceTest @Autowired constructor(
private val userService: UserService,
private val userRepository: UserRepository
) {
@Test
fun `사용자를 생성하면 DB에 저장된다`() {
// given
val request = CreateUserRequest(name = "김코틀린", email = "kotlin@test.com")
// when
val user = userService.createUser(request)
// then
val found = userRepository.findById(user.id)
assertThat(found).isNotNull
assertThat(found!!.name).isEqualTo("김코틀린")
}
}
코틀린에서는 백틱으로 한글 테스트 메서드 이름을 작성할 수 있어서, 테스트의 의도를 명확히 전달할 수 있습니다.
전환 시 체크리스트
프로젝트를 자바에서 코틀린으로 전환할 때 확인해야 할 항목입니다.
- 플러그인:
kotlin-spring,kotlin-jpa추가 - Jackson:
jackson-module-kotlin추가 - 엔티티: data class 대신 일반 class 사용, ID 기반 equals/hashCode 구현
- 어노테이션:
@field:,@get:등 use-site target 확인 - nullable: Repository 반환 타입, Controller 파라미터 nullable 여부 결정
- 테스트: 백틱 메서드 이름, 코루틴 테스트(
runTest) 활용
정리
- all-open/no-arg 플러그인은 코틀린의 final 기본값과 Spring의 프록시/리플렉션 요구를 맞추기 위해 필수입니다
- JPA 엔티티에 data class를 쓰지 마세요 — equals/hashCode, 지연 로딩 문제가 발생합니다
- use-site target(
@field:,@get:)을 명시해야 Bean Validation과 직렬화가 정상 동작합니다 - 코루틴 Controller는 suspend fun 또는 Flow 반환으로 비동기 처리를 깔끔하게 작성할 수 있습니다
- jackson-module-kotlin을 추가하면 data class와 기본값 파라미터가 자동으로 지원됩니다