프로그래밍/스프링부트

코프링 개발을 위한 가이드

silbaram 2025. 2. 28. 20:39
728x90

자바 스프링부트 개발자를 위한 코틀린 전환 가이드

기존 자바(Spring) 기반으로 개발하던 프로젝트를 코틀린(Kotlin)으로 전환하는 방법을 단계별 실습 형태로 설명합니다. 이 가이드는 코틀린 언어의 기본 개념부터 Spring Boot 환경에서 REST API 개발, 데이터베이스 연동, 비동기 처리, 그리고 테스트에 이르기까지를 다룹니다. 각 섹션은 자바와의 비교를 통해 코틀린의 특징을 이해하고, 예제 코드를 통해 실제 프로젝트에 적용하는 방식을 보여줍니다.

1. 코틀린 기본 개념 및 자바와의 차이점

코틀린 주요 문법 및 특징 – 코틀린은 간결함과 안정성을 강조한 JVM 언어입니다. 대표적인 특징으로 Null 안전성, 데이터 클래스, 람다식 등을 들 수 있습니다.

  • Null 안전성: 코틀린의 타입 시스템은 컴파일 단계에서 null 가능성을 처리하여 NullPointerException(NPE)을 예방합니다. 변수 타입에 ?를 붙이면 null을 허용하고, 그렇지 않으면 컴파일러가 null에 대한 조치를 강제합니다. 예를 들어, val name: String? = null처럼 선언하면 안전 호출(?.)이나 엘비스 연산자(?:)로 NPE 없이 처리할 수 있습니다. 자바는 컴파일러 수준의 null 체크가 없어서 런타임 NPE 위험이 있지만, 코틀린은 아예 코드상에서 null 처리 규칙을 적용합니다.
  • 데이터 클래스(Data Class): 코틀린의 data class는 불변 객체를 간단히 정의할 때 사용하며, 객체 생성 시 자동으로 equals, hashCode, toString 등의 메서드를 생성해줍니다. 예를 들어 data class User(val id: Long, val name: String)이라고 선언하면 필드 값을 기반으로 동등성 비교와 문자열 표현 등이 자동 구현됩니다. 자바에서는 동일 기능을 위해 보일러플레이트 코드를 직접 작성하거나 Lombok 같은 라이브러리를 사용해야 했지만, 코틀린 데이터 클래스는 한 줄 선언으로 충분합니다. 또한 copy() 메서드가 제공되어 객체를 손쉽게 복사하면서 일부 필드만 변경할 수도 있습니다.
  • 람다 표현식 및 함수형 프로그래밍: 코틀린은 고차 함수와 람다식을 활용한 함수형 프로그래밍을 지원합니다. 자바 8의 람다에 비해 코틀린 람다는 문법이 더 간결하며, 컬렉션 처리를 위한 풍부한 표준 라이브러리 함수를 제공합니다. 예를 들어 리스트의 필터링을 자바에서는 list.stream().filter(x -> x > 5).collect(...)처럼 작성하지만, 코틀린에서는 list.filter { it > 5 }처럼 람다를 이용한 간단한 표현이 가능합니다. 이 밖에도 확장 함수, 스마트 캐스팅, 기본 파라미터 값 등 코틀린만의 유용한 문법들이 있어 자바 대비 코드를 더욱 간결하게 만들어줍니다.

자바 코드의 코틀린 변환 – 기존 자바 코드를 코틀린으로 변환할 때는 IntelliJ IDEA의 자동 변환 도구를 활용하면 도움이 됩니다. IntelliJ에서는 Java 파일을 열고 Code > Convert Java File to Kotlin File 메뉴를 선택하거나 Ctrl + Alt + Shift + K 단축키로 자바 코드를 코틀린 코드로 변환할 수 있습니다. 이 도구를 사용하면 대부분의 자바 구문을 대응되는 코틀린 구문으로 바꿔주지만, 완벽하게 idiomatic Kotlin 스타일로 바뀌지는 않으므로 변환 결과를 수동으로 다듬는 것이 좋습니다. 프로젝트를 점진적으로 이전하는 경우, 코틀린과 자바 코드를 동일 프로젝트에 혼용할 수도 있습니다. 코틀린은 JVM 바이트코드 수준에서 자바와 100% 호환되므로, 기존 자바 클래스를 코틀린에서 호출하거나 그 반대도 자유롭게 가능합니다. 예를 들어, 새로운 기능이나 테스트 코드를 코틀린으로 작성하면서 기존 자바 코드를 그대로 둘 수도 있고, 점차 자바 클래스를 코틀린으로 마이그레이션할 수 있습니다.

코틀린에서의 Spring Bean과 의존성 주입 – 스프링 프레임워크 사용 시 코틀린의 큰 차이 중 하나는 클래스가 기본적으로 final이라는 점입니다. 자바에서는 별도 지정이 없으면 클래스를 상속 가능하지만, 코틀린은 명시적으로 open을 붙이지 않는 한 상속이나 프록시 생성이 불가능합니다. Spring은 AOP나 설정 클래스(@Configuration) 등을 위해 런타임에 CGLIB 프록시를 만들어야 하는데, 코틀린 클래스가 final이면 프록시 생성이 실패합니다. 이를 해결하기 위해 코틀린 Spring 컴파일러 플러그인(kotlin-spring)을 사용합니다. 이 플러그인은 특정 애노테이션이 붙은 클래스들을 컴파일 시 자동으로 open으로 변경해주며, Spring Initializr로 생성한 코틀린 프로젝트에는 해당 플러그인이 기본 적용됩니다. 예를 들어 @Component, @Service, @Controller, @Repository처럼 Spring 구성요소 애노테이션이 붙은 클래스와 그 멤버 함수들은 컴파일될 때 자동으로 open으로 처리되어 프록시 생성이 가능해집니다. 따라서 대부분의 경우 코틀린으로 Spring Bean을 작성할 때 개발자가 별도로 open을 붙일 필요는 없습니다. 다음은 코틀린에서 서비스 클래스를 정의하고 의존성을 생성자 주입하는 예입니다:

@Service
class UserService(private val userRepository: UserRepository) {
    fun getUserNames(): List<String> {
        // 리포지토리에서 모든 User를 불러와 이름 목록을 반환
        return userRepository.findAll().map { it.name }
    }
}

위 코드처럼 주 생성자(primary constructor)private val 형태로 의존성을 선언하면 Spring이 자동으로 해당 빈을 주입해줍니다. 코틀린에서는 가시성을 위해 필요한 경우를 제외하면 필드 주입(@Autowired 필드)을 잘 사용하지 않고, 주 생성자 주입을 선호합니다. 생성자에 파라미터가 하나뿐이면 @Autowired 애노테이션을 생략해도 Spring이 자동으로 주입해주므로 더욱 간결합니다. 그리고 JUnit 5 등의 테스트에서도 생성자 주입을 활용할 수 있는데, 이를 통해 테스트 클래스의 프로퍼티를 val로 선언하여 불변성을 유지할 수 있습니다.

2. REST API 개발

이 섹션에서는 Spring Web MVC를 이용해 RESTful API 서버를 코틀린으로 구현하는 방법을 알아봅니다. 프로젝트를 Spring Boot로 시작했다면 spring-boot-starter-web 의존성을 추가하여 REST 컨트롤러를 만들 수 있습니다. 전형적인 레이어드 아키텍처Controller, Service, Repository 계층을 구성하고, JSON 요청/응답 매핑, DTO와 검증 활용 방법을 다룹니다.

Controller, Service, Repository 패턴 적용

Spring MVC에서 REST API는 주로 @RestController 클래스 내에 엔드포인트 메서드로 정의됩니다. 아래는 컨트롤러, 서비스, 레포지토리 레이어를 코틀린으로 구현한 간단한 예제입니다:

// Repository 계층: JPA 레포지토리 인터페이스
@Repository
interface UserRepository : JpaRepository<User, Long>

// Service 계층: 비즈니스 로직 처리
@Service
class UserService(private val userRepository: UserRepository) {
    fun findUser(id: Long): User? {
        return userRepository.findById(id).orElse(null)
    }

    fun createUser(user: User): User {
        return userRepository.save(user)
    }
}

// Controller 계층: REST API 엔드포인트 정의
@RestController
@RequestMapping("/users")
class UserController(private val userService: UserService) {

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): ResponseEntity<UserResponse> {
        val user = userService.findUser(id) 
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")
        // 엔티티를 DTO로 변환하여 반환
        return ResponseEntity.ok(UserResponse.from(user))
    }

    @PostMapping
    fun createUser(@Valid @RequestBody request: CreateUserRequest): ResponseEntity<UserResponse> {
        val saved = userService.createUser(request.toEntity())
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(saved))
    }
}

위 코드에서 볼 수 있듯이 코틀린에서는 클래스와 생성자를 간략하게 선언하고, 메서드 본문도 표현식으로 바로 반환하도록 구현했습니다. UserRepositoryJpaRepository를 상속하여 기본적인 CRUD 메서드를 자동 구현하며, 자바와 동일하게 사용할 수 있습니다. UserService는 생성자에 UserRepository를 주입받고 (코틀린에서는 이 경우에도 별도 @Autowired 없이 주입 가능), 비즈니스 로직을 구현합니다. UserController@RestController와 요청 매핑 애노테이션을 사용해 HTTP 요청을 처리하며, 서비스 계층을 통해 받은 엔티티를 가공해 응답을 만듭니다.

코틀린으로 작성한 컨트롤러는 자바와 거의 동일한 어노테이션을 사용하지만, 코드량이 줄어들고 가독성이 높아집니다. 예를 들어, 자바에서는 ResponseEntity.ok(userService.findUser(id))와 같이 체이닝하거나 빌더를 사용해야 할 것을 코틀린에서는 표준 함수를 활용해 ResponseEntity.ok(...).body(...) 형태로 더 명확하게 표현했습니다. 또한, 위 예제처럼 널 처리를 연계하면, userService.findUser(id)의 결과가 null인 경우 엘비스 연산자(?:)를 사용해 바로 예외를 던지는 등 코틀린의 장점을 살릴 수 있습니다. 서비스 계층 없이 컨트롤러에서 레포지토리를 바로 호출할 수도 있지만, 유지보수를 위해 논리 분리를 권장합니다.

Jackson을 활용한 JSON 직렬화/역직렬화

스프링 부트에서 REST API는 기본적으로 Jackson 라이브러리를 통해 JSON을 처리합니다. 코틀린 데이터 클래스를 요청/응답에 사용하려면 Jackson 코틀린 모듈을 추가하는 것이 중요합니다. jackson-module-kotlin을 의존성에 포함하면 Jackson이 코틀린 클래스(특히 data class)를 제대로 직렬화/역직렬화할 수 있도록 지원합니다. 스프링 부트의 기본 설정에서는 클래스패스에 해당 모듈이 존재하면 자동으로 ObjectMapper에 등록해주므로 별도 설정이 필요 없습니다. 예를 들어 Gradle 설정에 아래와 같이 추가합니다:

dependencies {
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}

Jackson 코틀린 모듈을 추가하면 단일 생성자를 갖는 코틀린 클래스는 JSON 매핑 시 추가 설정 없이도 사용 가능합니다. 또한 데이터 클래스의 불변 객체 특성도 존중되어, 생성자에 맞춰 JSON이 매핑됩니다. (만약 커스텀 ObjectMapper를 사용하는 경우 registerKotlinModule()를 호출하여 수동으로 등록해야 함을 유의하세요.

요청/응답 DTO와 데이터 검증 – 엔드포인트에서는 엔티티 객체를 직접 노출하기보다는 별도의 DTO(Data Transfer Object)를 주고받는 것이 일반적입니다. DTO를 사용하면 불필요한 정보 노출을 막고, 요청 입력값에 대한 검증(Validation)도 함께 할 수 있습니다. 코틀린에서 DTO를 작성할 때 자바와 다른 점은 Bean Validation 애노테이션의 사용 방법입니다. 예를 들어 새로운 사용자 생성을 위한 요청 DTO를 코틀린으로 작성해보겠습니다:

data class CreateUserRequest(
    @field:NotBlank(message = "name must not be blank")
    val name: String,

    @field:Email(message = "email format is invalid")
    val email: String
) {
    fun toEntity(): User {
        return User(name = name, email = email)
    }
}

data class UserResponse(val id: Long, val name: String, val email: String) {
    companion object {
        fun from(user: User) = UserResponse(user.id!!, user.name, user.email)
    }
}

위 코드에서 CreateUserRequest 클래스의 프로퍼티에 @NotBlank, @Email 등의 검증 애노테이션을 붙일 때 @field: 프리픽스를 사용한 것에 주목하세요. 코틀린에서는 클래스의 주 생성자에 선언된 프로퍼티에 어노테이션을 직접 붙이면 기본적으로 getter/setter에 적용되기 때문에, Bean Validation이 기대하는 필드 수준(@Field)에 적용되지 않습니다. @field:NotBlank처럼 사용하면 해당 프로퍼티의 백킹 필드에 애노테이션이 적용되어 검증이 정상 동작합니다. 자바에서는 그냥 @NotBlank private String name;으로 필드에 붙이는 것이었으나, 코틀린에서는 위와 같이 명시적으로 필드를 지정해야 한다는 점이 차이입니다.

CreateUserRequest DTO에는 toEntity()라는 편의 함수를 넣어, 서비스나 컨트롤러에서 이 DTO를 엔티티 객체(User)로 변환하기 쉽게 했습니다. UserResponse DTO는 정적 팩토리 메서드(from)를 통해 엔티티를 DTO로 변환합니다. 이렇게 DTO를 사용하면 컨트롤러에서는 @RequestBody로 입력을 받고 (@Valid를 통해 검증도 수행), 서비스 계층까지는 DTO나 엔티티로 처리한 뒤, 최종적으로 응답 시에 DTO로 변환하여 반환합니다. 위 UserController.createUser 메서드를 보면 @Valid @RequestBody request: CreateUserRequest로 입력을 받고, 서비스에서 저장된 User 엔티티를 UserResponse.from()으로 DTO로 변환한 후 ResponseEntity로 감싸 반환하고 있습니다. @Valid를 사용하면 스프링이 자동으로 CreateUserRequest 내의 검증 애노테이션들을 체크하며, 유효하지 않은 경우 400 에러와 함께 검증 실패 내용을 응답하게 됩니다.

또한 Jackson이 코틀린 객체를 처리할 때 기본 생성자가 없으면 곤란하지 않을까 걱정할 수 있는데, 데이터 클래스의 경우 앞서 추가한 코틀린 모듈이 단일 생성자를 통해 역직렬화를 지원하므로 문제가 없습니다. 기본 생성자가 필요한 JPA와는 다르게, JSON 처리에는 주 생성자만 있어도 충분합니다. 다만, 클라이언트와 통신하는 DTO에는 가급적 nullable 타입을 쓰지 않고 필수값은 non-null로, 선택값은 기본값을 지정하여 설계하는 것이 NPE를 줄이는 데 도움이 됩니다.

3. 데이터베이스 연동

스프링 부트에서는 Spring Data JPA를 통해 데이터베이스 ORM(Object-Relational Mapping)을 쉽게 구현할 수 있고, 코틀린에서도 이를 자연스럽게 활용할 수 있습니다. 이 절에서는 코틀린으로 JPA 엔티티와 레포지토리를 정의하는 방법, Kotlin에서 JPA 사용 시 고려사항, 그리고 QueryDSL을 이용한 동적 쿼리, 추가적으로 R2DBC를 통한 비동기 DB 연동에 대해 알아봅니다.

Spring Data JPA와 코틀린 – 코틀린으로 JPA 엔티티를 정의할 때 자바와 다른 점은 앞서 언급했듯 클래스 기본이 final이라 프록시 생성 문제가 있다는 것과, JPA 스펙상 요구되는 디폴트 생성자(no-arg)의 처리입니다. 다행히 Spring Boot 코틀린 프로젝트에서는 kotlin-jpa 플러그인이 적용되어, @Entity로 표시한 코틀린 클래스에 자동으로 파라미터 없는 생성자를 추가해줍니다 . 또한 이 플러그인은 JPA 엔티티에서 non-null 프로퍼티를 사용할 수 있도록 지원하므로, 코틀린의 null 안전성을 엔티티에도 적용할 수 있습니다.

엔티티 클래스는 코틀린의 일반 클래스나 데이터 클래스로 정의할 수 있습니다. 데이터 클래스로 정의하면 equals/hashCode 등이 자동 생성되지만, JPA 프록시와의 호환 문제로 경우에 따라 피하는 게 좋다는 의견도 있습니다. 실무에서는 id 등 PK 필드는 var 또는 val?로 nullable 하게 두어, JPA가 할당 후 값이 채워질 수 있도록 설계합니다. 예를 들어 다음과 같이 User 엔티티를 정의할 수 있습니다:

@Entity
class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var name: String,

    var email: String
)

위 코드는 코틀린의 일반 클래스로 엔티티를 정의했습니다. id는 데이터베이스에서 자동 생성하므로 최초에는 null일 수 있게 Long?으로 선언했고, nameemail은 null이 될 수 없다고 가정하여 non-null String으로 선언했습니다. 코틀린 JPA 플러그인이 적용되어 있으므로 이 엔티티에 자동으로 기본 생성자가 생성되며, JPA가 이를 이용해 객체를 생성할 수 있습니다. JpaRepository<User, Long> 인터페이스를 통해 기본적인 데이터 접근 메서드를 사용할 수 있고, 앞서 본 것처럼 findById(id: Long): Optional<User> 등의 메서드를 호출하면 자바의 Optional을 반환합니다. 코틀린에서는 이 Optional을 처리하기 위해 orElse(null)로 받아서 nullable 타입으로 취급하거나, Spring Data Kotlin 확장 함수를 활용해 바로 nullable 타입을 리턴받는 방법도 있습니다 (Spring Data JPA 2.x부터 findByIdOrNull(id) 코틀린 확장 함수를 제공하여 Optional을 생략할 수 있습니다).

QueryDSL을 활용한 동적 쿼리 작성 – 복잡한 조회나 동적 필터링이 필요할 경우 Spring Data JPA의 메서드 이름 쿼리로는 한계가 있습니다. QueryDSL은 타입 안전한 Java/Kotlin 기반의 DSL로 JPQL/SQL 쿼리를 생성할 수 있는 라이브러리입니다. Kotlin에서 QueryDSL을 사용하려면 Gradle에서 kapt(코틀린 어노테이션 프로세서)를 통해 Q클래스(메타모델)를 생성해야 합니다. 예를 들어 Gradle Kotlin DSL에서 아래와 같이 설정합니다:

plugins {
    kotlin("jpa") version "<kotlin-version>"
    kotlin("kapt") version "<kotlin-version>"
}
dependencies {
    implementation("com.querydsl:querydsl-jpa:<version>")
    kapt("com.querydsl:querydsl-apt:<version>:jakarta")
}

이렇게 하면 ./gradlew build 시 코틀린 엔티티에 대응하는 Q타입들이 생성됩니다. 생성된 Q클래스를 사용하여 쿼리를 작성하면 됩니다. QueryDSL을 Spring과 함께 사용하는 방법은 두 가지 정도인데, 하나는 Spring Data JPA가 제공하는 QuerydslPredicateExecutor를 레포지토리에 상속받아 사용하는 방법이고, 다른 하나는 직접 JPAQueryFactory 빈을 주입받아 사용하는 방법입니다. 코틀린에서도 문법만 다를 뿐 사용법은 동일합니다. 예를 들어 UserRepositoryQuerydslPredicateExecutor<User>를 구현하면, 컨트롤러나 서비스 계층에서 userRepository.findAll(predicate) 형태로 BooleanExpression을 만들어 조회할 수 있습니다. 또는 아래와 같이 직접 JPAQueryFactory를 써서 동적 쿼리를 만들 수도 있습니다:

@Service
class UserQueryService(@PersistenceContext private val em: EntityManager) {
    private val queryFactory = JPAQueryFactory(em)

    fun searchUsers(namePrefix: String?, emailDomain: String?): List<User> {
        val qUser = QUser.user
        return queryFactory.selectFrom(qUser)
            .where(
                namePrefix?.let { qUser.name.startsWith(it) },
                emailDomain?.let { qUser.email.endsWith(emailDomain) }
            )
            .fetch()
    }
}

위 예시는 namePrefixemailDomain 파라미터가 있을 때만 where 절에 조건을 추가하도록 let을 이용해 BooleanExpression?을 구성한 모습입니다. QueryDSL 덕분에 문자열 기반이 아닌, 컴파일 시점에 검증되는 필드 참조(qUser.name 등)를 사용하므로 타입 안전성이 보장됩니다. 또한 Kotlin의 null 처리를 활용해 깔끔하게 동적 조건을 작성할 수 있습니다. QueryDSL 자체는 Java로 작성된 라이브러리지만 Kotlin에서도 무리 없이 활용할 수 있습니다 (코틀린에서 람다를 사용하여 QueryDSL의 BooleanBuilder를 조립하는 등 패턴도 가능합니다).

R2DBC를 이용한 비동기 DB 연동 (선택) – R2DBC(Reactive Relational Database Connectivity)는 Reactive Streams 기반으로 DB에 non-blocking 접근을 가능케 하는 스펙입니다. Spring Data R2DBC는 JPA와 유사한 리포지토리 추상화를 제공하되, 반환 타입으로 FluxMono를 사용하여 Reactive하게 동작합니다. 코틀린과 함께 사용할 경우, Spring Data R2DBC 모듈은 코루틴 지원도 포함하고 있어서 CoroutineCrudRepository 인터페이스를 제공하며 코틀린 suspend 함수 형태로 쿼리를 작성할 수 있습니다. 예를 들어 R2DBC 리포지토리를 코틀린으로 작성하면 다음처럼 됩니다:

interface UserR2dbcRepository : CoroutineCrudRepository<User, Long> {
    suspend fun findByName(name: String): User?
}

이 Repository는 findByName같은 메서드를 suspend 함수로 선언하며, 구현체는 Spring Data가 런타임에 만들어줍니다. 호출 측에서는 일반 함수 호출처럼 val user = userRepository.findByName("Alice")라고 하면, 이 함수가 suspend이므로 호출자도 suspend 컨텍스트여야 하고 논블로킹으로 결과를 얻을 수 있습니다. 만약 R2DBC를 좀 더 낮은 수준에서 제어하고 싶다면 DatabaseClientR2dbcEntityTemplate를 사용할 수도 있습니다. Spring Data R2DBC는 이러한 클래스들에 대해 코틀린 확장 함수를 제공하여, awaitOne(), awaitOneOrNull(), awaitMany() 등의 suspend 함수를 활용할 수 있습니다. 예를 들어, DatabaseClient로 쿼리를 날려서 단건 조회를 할 때 .fetch().awaitOneOrNull() 형태로 결과를 받아올 수 있습니다.

참고: R2DBC는 현재 JPA만큼 성숙하지는 않지만, 트랜잭션 지원이나 R2DBC 전용 API들이 계속 발전 중입니다. 완전히 Reactive한 스택(Spring WebFlux + R2DBC)을 구축하면 애플리케이션이 end-to-end로 non-blocking 처리가 가능해집니다. 다만 기존 JPA와는 작동 방식이 다르므로 (예: 지연 로딩 미지원 등) 필요에 따라 선택적으로 도입하면 됩니다.

4. 비동기 프로그래밍 및 코루틴 활용

코틀린 도입의 하이라이트 중 하나는 코루틴(Coroutine)을 통한 비동기 처리입니다. 스프링 프레임워크에서도 코루틴을 지원하여, 기존의 CompletableFuture나 Reactor 기반 코드와 비교해 더 간결한 비동기 코드를 작성할 수 있습니다. 이 절에서는 Spring WebFlux 환경에서의 코루틴 사용, suspend 함수와 async/await 패턴, 그리고 코루틴을 기존 스프링의 CompletableFuture 및 Reactor와 비교해 설명합니다.

Spring WebFlux와 코틀린 코루틴Spring WebFlux는 스프링 5부터 제공된 Reactive 웹 프레임워크로, 논블로킹 Netty 서버 등을 기반으로 합니다. WebFlux는 기본적으로 Reactor 라이브러리MonoFlux를 반환 타입으로 사용하지만, 코틀린을 사용할 경우 코루틴을 이용한 방식도 공식 지원합니다. Spring Framework 5.2부터는 컨트롤러 메서드를 suspend 함수로 정의하고 Kotlin Flow를 반환 타입으로 사용할 수 있게 되었는데, 이를 통해 마치 MVC 방식의 동기 코드처럼 보이는 비동기 코드를 작성할 수 있습니다. Spring WebFlux는 내부적으로 여전히 Reactor를 사용하여 논블로킹을 처리하지만, 개발자 입장에서는 코루틴으로 작성된 함수도 자연스럽게 WebFlux에 연결됩니다. 예를 들어, 코틀린 WebFlux 컨트롤러를 아래처럼 작성할 수 있습니다:

@RestController
@RequestMapping("/api/items")
class ItemController(private val service: ItemService) {

    @GetMapping("/")
    fun getAllItems(): Flow<Item> = service.findAll()  // Flow를 반환 (스트림)

    @GetMapping("/{id}")
    suspend fun getItemById(@PathVariable id: String): Item? = service.findOne(id)

    @PostMapping("/")
    suspend fun createItem(@RequestBody item: Item): Item = service.save(item)
}

위 컨트롤러에서 getAllItems()Flow<Item>을 반환하고 있고, getItemByIdcreateItemsuspend 함수로 선언되어 Item을 직접 반환합니다. 이런 방식으로 WebFlux는 컨트롤러에서 Mono/Flux를 직접 다루지 않고도, 코루틴 suspend 함수만으로 비동기 처리를 합니다. Spring이 해당 함수를 호출할 때 코루틴 컨텍스트에서 실행하고, 응답을 보내기까지의 처리를 모두 비동기로 수행해줍니다. 공식 Spring 예제에서도 Flow는 Reactor의 Flux에 대응되는 개념으로, 스트림의 아이템들을 비동기로 처리할 때 사용됩니다. 단건 조회나 생성의 경우 suspend fun으로 작성하면 Reactor의 Mono 대신 실제 타입을 반환하게 되는데, Spring이 그 반환값을 감지해서 적절히 HTTP Response로 변환해줍니다.

코루틴을 사용할 때는 빌드 스크립트에 org.jetbrains.kotlinx:kotlinx-coroutines-reactor 의존성을 추가해야 하며, 이것이 Reactor와 코틀린 코루틴 간 브릿지 역할을 합니다. 추가로, WebClient 같은 웹 클라이언트에도 코루틴 확장이 제공되어 awaitBody<T>() 등의 suspend 함수를 사용할 수 있습니다.

코루틴의 동작과 async/await – 코루틴은 경량 스레드라고 불릴 정도로 수많은 병행 작업을 적은 스레드 풀로 처리할 수 있게 해줍니다. suspend 키워드가 붙은 함수는 일시 중단할 수 있는 함수로, 호출 시 실제 스레드를 블로킹하지 않고도 결과를 기다릴 수 있습니다. 코루틴 내부에서 병렬 실행이 필요하면 launchasync 빌더를 사용합니다. 예를 들어 앞서 WebFlux 컨트롤러에서, 어떤 요청에 대해 외부 서비스를 두 번 호출하고 그 결과를 합쳐야 한다고 가정해보겠습니다. Reactor를 사용하면 Mono.zip이나 flatMap 연쇄로 구현할 로직을, 코틀린에서는 다음과 같이 작성할 수 있습니다:

suspend fun fetchCombinedData(): CombinedData = coroutineScope {
    val deferred1 = async { webClient.get().uri("/service1").retrieve().bodyToMono<Data1>().awaitSingle() }
    val deferred2 = async { webClient.get().uri("/service2").retrieve().bodyToMono<Data2>().awaitSingle() }
    // 두 요청을 비동기로 실행하고 둘 다 완료될 때까지 대기
    val data1 = deferred1.await()
    val data2 = deferred2.await()
    return CombinedData(data1, data2)
}

위 코드에서는 coroutineScope 내에서 async를 두 번 호출하여 외부 서비스 호출을 병렬로 실행합니다. await()를 호출하면 각 결과가 도착할 때까지 비동기로 기다리며, 둘 다 완료되면 CombinedData를 생성하여 반환합니다. 이 전체 과정에서 현재 스레드는 블록되지 않고, 백그라운드에서 논블로킹 I/O를 처리합니다. async/await 패턴은 JavaScript나 C#의 그것과 유사하게 동작하지만, Kotlin에서는 코루틴 빌더를 명시적으로 호출한다는 점이 다릅니다 (또한 예제에서 awaitSingle()은 Reactor Mono를 코루틴에서 받기 위한 Reactor 코틀린 확장 함수입니다).

CompletableFuture 및 Reactor와의 비교 – 자바에서 비동기를 구현할 때는 종종 CompletableFuture를 사용하거나 Spring에서는 @Async를 통해 백그라운드 쓰레드에서 작업을 수행하곤 합니다. CompletableFuture는 계산 파이프라인을 정의할 수 있고 결과를 비동기로 받을 수 있지만, 콜백 지옥이 되거나 예외 처리 등이 번잡해질 수 있습니다. 반면 코루틴은 언어 수준에서 지원되므로 예외 처리도 일반 코드처럼 try/catch를 쓰면 되고, 여러 비동기 작업의 조합도 동기 코드처럼 순차적으로 작성할 수 있어 가독성이 높습니다. Spring의 Reactor (Mono/Flux)와 비교하면, Reactor는 스트림 및 비동시성을 다루는 강력한 연산자들을 제공하지만 함수형 프로그래밍 패러다임에 익숙해야 하고 연산자 체인을 따라가는 부담이 있습니다. 코틀린 코루틴은 이를 명령형 프로그래밍 스타일로 사용할 수 있게 해주므로, 기존에 동기 코드를 작성하던 개발자라도 쉽게 비동기 코드를 구현할 수 있다는 장점이 있습니다. 스프링 팀에서도 Reactor 기반의 내부 동작 위에 코루틴을 얹어 쓸 수 있도록 설계했기 때문에, 결국 둘은 동등한 결과를 얻는 두 가지 방법이라고 볼 수 있습니다. 예를 들어 Reactor로 작성된 Mono.flatMap 연쇄 로직은 코틀린에선 suspend 함수 내에서 순차 코드로 작성할 수 있고, Reactor의 Flux 스트림 처리도 코틀린의 Flow에서 collectmap 연산으로 처리할 수 있습니다. 실제로 Spring WebFlux의 코루틴 지원은 Flux/Mono와 상호운용되도록 만들어져 있어, 필요하면 코루틴 코드에서 Reactor MonoawaitSingle()로 받고, Reactor 코드에서 코루틴 FlowFlux.from(flow) 등으로 변환할 수 있습니다.

요약하면, CompletableFuture vs Reactor vs 코루틴 세 가지 모두 Spring에서 사용 가능하지만, 코틀린 코루틴은 코드를 동기식처럼 직관적으로 작성하면서도 논블로킹의 이점을 취할 수 있어 전환을 고려하는 자바 개발자에게 생산성을 높여줄 수 있습니다. 물론 코루틴을 활용하기 위해서는 프로젝트에 KotlinX Coroutines 라이브러리를 추가해야 하고, 디버깅 시 실행 흐름이 숨겨지는 등의 학습 곡선도 조금 있지만, 전반적으로 Spring과 Kotlin의 결합은 비동기 프로그래밍을 한층 쉽게 만들어줍니다.

5. 테스트 및 코드 품질 개선

코틀린으로 스프링 프로젝트를 전환하면 테스트 코드 역시 코틀린으로 작성하게 됩니다. 코틀린은 테스트 작성 시에도 간결함과 가독성을 제공하며, 자바 진영의 풍부한 테스트 프레임워크와도 호환됩니다. 이 절에서는 JUnit 5 기반의 단위 테스트 작성, 코틀린에 특화된 모킹 프레임워크인 MockK의 활용, 스프링 컨텍스트를 사용하는 통합 테스트, 그리고 코틀린 DSL을 활용하여 읽기 쉬운 테스트 코드를 만드는 방법을 살펴봅니다.

JUnit 5와 MockK를 이용한 단위 테스트 – JUnit 5은 코틀린과 궁합이 잘 맞습니다. 우선 JUnit 5에서는 테스트 클래스의 생성자 주입이 가능하여, 코틀린의 val로 의존성을 받을 수 있고 이를 통해 @BeforeAll 메서드를 정적(companion object)으로 만들 필요 없이 활용할 수 있습니다. 그리고 테스트 함수의 이름을 백틱(`)으로 묶어 공백이나 한글 등도 자유롭게 사용할 수 있기 때문에, 테스트의 의도를 자연어에 가깝게 표현할 수 있습니다. 예를 들어 코틀린에서 테스트 함수를 @Test fun \유효한 유저면 성공을 반환한다`(){ ... }처럼 정의하면, JUnit에서 이 함수를 인식하고 실행하며, 보고서에도 해당 문장이 그대로 표시됩니다. 이는shouldReturnSuccessWhenUserIsValid`처럼 카멜케이스로 작성하는 것보다 직관적입니다.

MockK는 코틀린에서 유명한 모킹 라이브러리로, Kotlin으로 작성된만큼 코틀린 언어의 특성(final 클래스, 객체 등)을 잘 지원합니다. Mockito 등 자바 기반 라이브러리도 Kotlin에서 사용할 수 있지만, final 클래스 모킹을 위해 별도 설정이 필요하거나 Kotlin의 특성을 완벽히 지원하지 못하는 한계가 있습니다. 반면 MockK는 처음부터 Kotlin을 위해 만들어져 이러한 제약이 없고, 코루틴 함수 모킹까지도 간편하게 지원합니다. MockK를 사용하려면 testImplementation("io.mockk:mockk:버전")으로 의존성을 추가하고, 테스트 클래스에 @ExtendWith(MockKExtension::class)를 붙이거나 MockKAnnotations.init(this)를 통해 초기화합니다. 이제 @MockK 애노테이션으로 목 객체를 선언하거나, 직접 val mock = mockk<ClassType>()으로 객체를 생성해 사용할 수 있습니다.

다음은 UserService 클래스의 동작을 검증하기 위한 코틀린 단위 테스트 예시입니다 (MockK 사용):

@ExtendWith(MockKExtension::class)
class UserServiceTest {

    @MockK lateinit var userRepository: UserRepository  // 목 객체 선언

    private lateinit var userService: UserService

    @BeforeEach
    fun setUp() {
        // 목 객체 초기화 및 UserService 생성
        userService = UserService(userRepository)
    }

    @Test
    fun `존재하는 ID로 유저를 조회하면 User 객체를 반환한다`() {
        // given
        val sampleUser = User(id = 1L, name = "Alice", email = "[email protected]")
        every { userRepository.findById(1L) } returns Optional.of(sampleUser)
        // when
        val result = userService.findUser(1L)
        // then
        assertNotNull(result)
        assertEquals("Alice", result?.name)
        verify(exactly = 1) { userRepository.findById(1L) }
    }

    @Test
    fun `존재하지 않는 ID로 유저를 조회하면 null을 반환한다`() {
        every { userRepository.findById(any()) } returns Optional.empty()
        val result = userService.findUser(999L)
        assertNull(result)
        verify { userRepository.findById(999L) }
    }
}

위 테스트에서는 userRepository를 MockK로 목킹하고, every { ... } returns ... 구문을 통해 동작을 지정했습니다. MockK의 DSL은 given-when-then 패턴을 함수형으로 표현하며 (every {}가 stub 설정에 해당, verify {}가 호출 검증), Kotlin의 람다를 활용하여 가독성이 좋습니다. 테스트 함수 이름도 백틱으로 기술하여 자연어 형태로 시나리오를 나타냈습니다. 예를 들어 두 번째 테스트 함수 이름은 "존재하지 않는 ID로 유저를 조회하면 null을 반환한다"라고 적혀 있어, 테스트 의도를 한눈에 파악할 수 있습니다. 이러한 DSL스러운 표현은 Kotlin 테스트 코드의 큰 장점 중 하나입니다.

스프링 통합 테스트 – 코틀린으로 작성한 Spring Boot 애플리케이션은 통합 테스트도 코틀린으로 구현할 수 있습니다. @SpringBootTest 애노테이션을 사용하면 스프링 컨텍스트를 로딩하여 실제 빈들이 구성된 상태로 테스트를 실행할 수 있습니다. 예를 들어, 웹 레이어를 테스트하려면 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)TestRestTemplate 또는 WebTestClient를 이용할 수 있습니다. 코틀린에서는 JUnit 5의 생성자 주입 기능을 활용하여 @Autowired val restTemplate: TestRestTemplate처럼 테스트 클래스의 생성자에 주입받을 수 있습니다. 아래는 통합 테스트의 한 예입니다 (앞서 만든 User API를 테스트한다고 가정):

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class UserApiIntegrationTest @Autowired constructor(
    val mockMvc: MockMvc,
    val objectMapper: ObjectMapper
) {

    @Test
    fun `POST /users 호출 시 유저가 생성되고 201 Created를 반환한다`() {
        // given: 새로운 유저 요청 DTO
        val request = CreateUserRequest(name = "Bob", email = "[email protected]")
        // when: API 호출
        val result = mockMvc.perform(
            post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        ).andReturn()
        // then: 응답 상태 및 내용 검증
        assertEquals(201, result.response.status)
        val responseBody = result.response.contentAsString
        val userResponse = objectMapper.readValue(responseBody, UserResponse::class.java)
        assertEquals("Bob", userResponse.name)
    }
}

이 테스트에서는 @AutoConfigureMockMvc를 통해 MockMvc 빈을 주입받고 (val mockMvc: MockMvc), 실제 HTTP 호출 없이도 MVC 레이어를 테스트하고 있습니다. Kotlin에서는 테스트 클래스의 주 생성자에 @Autowired constructor(...)를 사용해 필요한 빈(MockMvc, ObjectMapper)을 주입받고, 프로퍼티를 val로 선언하여 불변으로 활용했습니다. 테스트 함수 이름 역시 백틱으로 명시하여 어떤 요청에 어떤 결과를 기대하는지 서술형으로 표현했습니다.

통합 테스트 실행 시 Kotlin의 간결함은 Assertions 사용에도 도움이 됩니다. 코틀린 표준 테스트 라이브러리(kotlin.test)나 AssertJ, 혹은 Kotest의 어서션 DSL 등을 활용하여 result shouldBe expected과 같은 가독성 높은 검증을 할 수 있습니다. 예를 들어 Kotest의Assertions를 사용하면 userResponse.name shouldBe "Bob" 같은 문장형 코드로 검증이 가능하고, 이는 가독성을 더욱 높여줍니다. (Kotest는 별도 테스트 프레임워크이지만 Assertions만 의존하여 JUnit과 함께 사용할 수도 있습니다.)

코드 품질과 DSL 활용 – Kotlin으로 테스트를 작성하면 불필요한 보일러플레이트가 감소하고, 함수형 스타일을 활용한 DSL(Domain Specific Language) 형식의 테스트 코드가 가능합니다. 앞서 보여준 백틱 테스트 이름이나 MockK의 every { } returns ... 구문이 그 예이며, 이러한 표현은 자연어에 가까운 테스트 시나리오를 만들 수 있어 협업 시 의사소통에도 도움이 됩니다. 또한 Kotlin DSL을 이용하면 테스트 데이터 빌더를 간결하게 만들 수 있는데, apply 스코프 함수나 확장 함수를 사용해서 User().apply { name="Alice"; email="[email protected]" }처럼 직관적인 테스트 데이터 생성 코드도 작성할 수 있습니다.

마지막으로, Kotlin 코드로 전환함으로써 프로젝트 전반의 코드 품질도 향상될 수 있습니다. Null-safety로 NPE 버그를 사전에 방지하고, 데이터 클래스와 불변타입 사용으로 사이드이펙트를 줄이며, 표준 라이브러리의 풍부한 함수들로 컬렉션 처리 등의 로직을 깔끔하게 구성할 수 있습니다. 테스트 측면에서도 위에서 다룬 것처럼 코틀린의 간결함은 더 많은 테스트를 더 읽기 쉽게 작성하도록 유도합니다. 기존 자바 기반 프로젝트를 코틀린으로 변환할 때 처음에는 문법 차이에 익숙해지는 과정이 필요하지만, 실습을 통해 하나씩 리팩토링해가다 보면 코틀린의 생산성과 안정성이 프로젝트에 큰 이점을 가져다 줄 것입니다.

728x90