왜 Kotlin Coroutines인가?

Java의 CompletableFuture를 쓰다가 Kotlin Coroutine을 처음 써본 날, 동료에게 이렇게 말했다. '이게 코드야? 사기 아니야?' 비동기 코드가 동기 코드처럼 읽히는 마법. 물론 현실은 마법만은 아니었다. CancellationException이 삼켜지는 함정에 빠져서 3일을 날린 적도 있다.

실제로 저희 팀에서는 기존 Java + WebFlux 프로젝트를 Kotlin + Coroutines로 전환한 후 코드 가독성이 크게 향상되었고, 신규 멤버의 온보딩 시간도 줄어들었습니다.

프로젝트 설정

// build.gradle.kts
plugins {
    kotlin("jvm") version "1.9.22"
    kotlin("plugin.spring") version "1.9.22"
    id("org.springframework.boot") version "3.2.2"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
}

suspend 함수로 Controller 작성

Spring WebFlux 환경에서 Controller의 핸들러 함수를 suspend로 선언하면 자동으로 코루틴 컨텍스트에서 실행됩니다.

@RestController
@RequestMapping("/api/orders")
class OrderController(
    private val orderService: OrderService,
    private val notificationService: NotificationService
) {

    @GetMapping("/{id}")
    suspend fun getOrder(@PathVariable id: Long): OrderResponse {
        return orderService.findById(id)
    }

    @PostMapping
    suspend fun createOrder(
        @RequestBody request: CreateOrderRequest
    ): ResponseEntity<OrderResponse> {
        val order = orderService.create(request)

        // 비동기로 알림 전송 (주문 생성과 독립적)
        coroutineScope {
            launch {
                notificationService.sendOrderConfirmation(order)
            }
        }

        return ResponseEntity
            .created(URI("/api/orders/${order.id}"))
            .body(order)
    }
}

Service 레이어의 코루틴 활용

병렬 호출로 응답 시간 단축

독립적인 여러 API를 호출해야 할 때, async를 사용하여 병렬로 처리하면 전체 응답 시간을 크게 줄일 수 있습니다.

@Service
class DashboardService(
    private val orderRepository: OrderRepository,
    private val userRepository: UserRepository,
    private val analyticsClient: AnalyticsClient
) {

    suspend fun getDashboard(userId: Long): DashboardResponse =
        coroutineScope {
            val ordersDeferred = async {
                orderRepository.findRecentByUserId(userId)
            }
            val profileDeferred = async {
                userRepository.findById(userId)
            }
            val statsDeferred = async {
                analyticsClient.getUserStats(userId)
            }

            DashboardResponse(
                orders = ordersDeferred.await(),
                profile = profileDeferred.await(),
                stats = statsDeferred.await()
            )
        }
}

Flow를 활용한 스트리밍

대량 데이터를 처리하거나 SSE(Server-Sent Events)를 구현할 때 Kotlin Flow가 유용합니다.

@Service
class EventStreamService(
    private val eventRepository: EventRepository
) {

    fun streamEvents(userId: Long): Flow<ServerSentEvent<EventDto>> = flow {
        while (currentCoroutineContext().isActive) {
            val events = eventRepository.findNewEvents(userId)
            events.forEach { event ->
                emit(ServerSentEvent.builder(EventDto.from(event)).build())
            }
            delay(1000) // 1초 간격 폴링
        }
    }
}

예외 처리 패턴

코루틴 환경에서의 예외 처리는 기존 Spring MVC와 유사하지만 몇 가지 주의점이 있습니다.

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val inventoryClient: InventoryClient
) {

    suspend fun create(request: CreateOrderRequest): OrderResponse {
        // supervisorScope: 하위 코루틴 실패가 부모에 전파되지 않음
        return supervisorScope {
            val order = orderRepository.save(request.toEntity())

            // 재고 차감 실패 시에도 주문은 생성됨
            val inventoryResult = runCatching {
                withTimeout(3000) {
                    inventoryClient.deductStock(
                        request.productId,
                        request.quantity
                    )
                }
            }

            if (inventoryResult.isFailure) {
                log.warn("재고 차감 실패, 보상 트랜잭션 필요: ${order.id}")
                order.markPendingInventory()
                orderRepository.save(order)
            }

            OrderResponse.from(order)
        }
    }
}

테스트 작성

@SpringBootTest
class OrderServiceTest {

    @Test
    fun `주문 생성 시 재고가 차감된다`() = runTest {
        // given
        val request = CreateOrderRequest(
            productId = 1L, quantity = 2
        )

        // when
        val result = orderService.create(request)

        // then
        assertThat(result.status).isEqualTo(OrderStatus.CONFIRMED)
        coVerify { inventoryClient.deductStock(1L, 2) }
    }

    @Test
    fun `재고 서비스 타임아웃 시 주문은 보류 상태`() = runTest {
        // given
        coEvery {
            inventoryClient.deductStock(any(), any())
        } coAnswers { delay(5000) } // 타임아웃 유발

        // when
        val result = orderService.create(createRequest())

        // then
        assertThat(result.status)
            .isEqualTo(OrderStatus.PENDING_INVENTORY)
    }
}

성능 비교

동일한 API를 Java + WebFlux와 Kotlin + Coroutines로 구현하여 비교한 결과, 처리량(throughput)은 거의 동일했습니다. 다만 코드량은 약 30% 줄어들었고, 스택 트레이스가 훨씬 읽기 쉬워져 디버깅 시간이 단축되었습니다.

마무리

Kotlin Coroutines는 비동기 프로그래밍의 복잡성을 대폭 줄여줍니다. 특히 여러 외부 서비스를 호출하는 마이크로서비스 아키텍처에서 그 효과가 극대화됩니다. 다만 기존 Java 프로젝트와의 호환성, 팀의 Kotlin 숙련도 등을 고려하여 도입 시점을 결정하는 것이 좋습니다.

← 목록으로 다음 글 →
Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글