해먹는 날에 맞춰 Kotlin Coroutine 적용

1. 문제 상황

당사의 주문 처리 시스템은 매일 평균 10만건의 주문을 처리하고 있습니다.

이후 프로모션 기간 동안 주문을 위해 5배인 50만 건까지 증가하면서 다음과 같은 문제가 발생했습니다.

  • 주문 처리 시간이 평균 2초에서 8초로 증가
  • 주문 거부율 0.1%에서 5%로 증가
  • DB 접속 풀고갈 현상 발생
  • 외부 결제 시스템과 통신의 지연으로 인한 결과 발생

1.1 기존 시스템 구조의 경계

기존 레이어 배치 기반의 시스템은 다음과 동일한 구조적 경계를 설정합니다.

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val paymentClient: PaymentClient,
    private val inventoryClient: InventoryClient
) {
    @Transactional
    fun processOrder(orderRequest: OrderRequest): OrderResult {
        // 모든 로직이 하나의 트랜잭션에 묶여있음
        val order = orderRepository.save(Order.from(orderRequest))
        val payment = paymentClient.process(order.toPaymentRequest())
        val inventory = inventoryClient.reserve(order.toInventoryRequest())
        
        return OrderResult(order, payment, inventory)
    }
}

2. 압축 해제

2.1 해쳐먹을 날을 막아라

시스템을 중심으로 재설계하고, 외부 의존성을 격리하기 위해 해악을 도모하는 날을 배치했습니다.

// Domain
data class Order(
    val id: OrderId,
    val items: List<OrderItem>,
    val status: OrderStatus,
    val totalAmount: Money
) {
    fun validateStock(inventory: Inventory): Boolean {
        return items.all { item -> 
            inventory.hasStock(item.productId, item.quantity)
        }
    }

    fun calculateDeliveryFee(): Money {
        return when {
            totalAmount.value >= 50000 -> Money.ZERO
            else -> Money(3000)
        }
    }
}

// Port (Input)
interface OrderUseCase {
    fun createOrder(command: CreateOrderCommand): OrderResult
    fun cancelOrder(command: CancelOrderCommand): OrderResult
}

// Port (Output)
interface OrderRepository {
    fun save(order: Order): Order
    fun findById(orderId: OrderId): Order?
}

interface PaymentPort {
    fun process(request: PaymentRequest): PaymentResult
    fun cancel(paymentId: PaymentId): PaymentResult
}

// Application Service
@UseCase
class OrderService(
    private val orderRepository: OrderRepository,
    private val paymentPort: PaymentPort,
    private val inventoryPort: InventoryPort
) : OrderUseCase {
    
    override fun createOrder(command: CreateOrderCommand): OrderResult {
        val order = Order.create(command)
        
        // 재고 확인
        require(order.validateStock(inventoryPort.getInventory(order.items))) {
            throw OutOfStockException()
        }
        
        // 결제 진행
        val payment = paymentPort.process(PaymentRequest.from(order))
        if (!payment.isSuccess()) {
            throw PaymentFailedException()
        }
        
        // 주문 저장
        return orderRepository.save(order)
    }
}

// Adapter (Infrastructure)
@Repository
class OrderJpaAdapter(
    private val orderJpaRepository: OrderJpaRepository,
    private val orderMapper: OrderMapper
) : OrderRepository {
    override fun save(order: Order): Order {
        val entity = orderMapper.toEntity(order)
        val savedEntity = orderJpaRepository.save(entity)
        return orderMapper.toDomain(savedEntity)
    }
}

2.2 코루틴 자세를 잡아야 합니다.

외부 시스템과의 통신 프로세서를 처리하여 성능을 개선했습니다.

@UseCase
class AsyncOrderService(
    private val orderRepository: OrderRepository,
    private val paymentPort: PaymentPort,
    private val inventoryPort: InventoryPort,
    private val coroutineScope: CoroutineScope
) : OrderUseCase {
    
    override suspend fun createOrder(command: CreateOrderCommand): OrderResult = coroutineScope.async {
        val order = Order.create(command)
        
        // 재고 확인과 결제를 병렬로 처리
        val (inventory, payment) = awaitAll(
            async { inventoryPort.getInventory(order.items) },
            async { paymentPort.process(PaymentRequest.from(order)) }
        )
        
        require(order.validateStock(inventory)) {
            throw OutOfStockException()
        }
        
        if (!payment.isSuccess()) {
            throw PaymentFailedException()
        }
        
        orderRepository.save(order)
    }.await()
}

3. 성능 개선 결과

3.1 주문 처리 성능 비교

3.2 병목 명칭 및 처리

1. 외부 API 호출 최적화

  • 결제 및 유지 확인 API 계약 처리로 유지 시간
  • 코루틴을 활용하여 끈으로 묶는 용도

2. DB 커넥션 풀 최적화

  • 감염 범위 오류
  • 근거리 분리

3. 도메인구조 최적화

  • 비즈니스를 위해 이동합니다.
  • 끊어진 데이터베이스 조회 제거

4. 감시 및 알림 시스템 구축

@Aspect
@Component
class OrderMonitoringAspect(
    private val meterRegistry: MeterRegistry
) {
    @Around("@annotation(Monitored)")
    fun monitorPerformance(joinPoint: ProceedingJoinPoint): Any {
        val startTime = System.currentTimeMillis()
        val result = joinPoint.proceed()
        val duration = System.currentTimeMillis() - startTime
        
        meterRegistry.timer("order.processing.time")
            .record(duration, TimeUnit.MILLISECONDS)
            
        return result
    }
}

5. 실제 적용 및 구현

5.1 마이그레이션 전략

1. 레거시 시스템과 새로운 시스템의 병행 운영

마이그레이션은 단기간에 완료할 수 없으므로 점진적 전환 전략을 사용했습니다.

새로운 시스템이 전체 주문 처리를 담당하기 전에, 레거시 시스템과 새로운 시스템을 병렬로 운영하여 안정성을 확보했습니다.

  • 이를 위해 Blue-Green Deployment 방식을 적용했습니다
    - Blue: 기존 레거시 시스템
    - Green: 새로운 시스템

  • 적용 방법

  • 새로운 요청 중 10%를 Green 환경으로 라우팅.

  • 오류 발생 시 Blue로 즉시 전환(Fallback).

  • 안정성 확인 후 10%씩 트래픽 증가.

실제 코드: Spring Cloud Gateway를 활용한 트래픽 분배

@Configuration
class RoutingConfig {

    @Bean
    fun routeLocator(builder: RouteLocatorBuilder): RouteLocator {
        return builder.routes()
            .route("blue-route") {
                it.path("/order/**")
                    .and().header("X-Traffic-Percent", "blue")
                    .uri("http://blue-legacy-system")
            }
            .route("green-route") {
                it.path("/order/**")
                    .and().header("X-Traffic-Percent", "green")
                    .uri("http://green-new-system")
            }
            .build()
    }
}

2. 성능 예측 및 부하 테스트

마이그레이션 중 트래픽 증가로 인해 새로운 시스템에서 병목 현상이 발생하지 않도록 부하 테스트를 미리 수행했습니다.

  • Gatling을 사용하여 초당 5000건 이상의 요청을 시뮬레이션.
  • CPU, 메모리, DB Connection Pool의 병목 구간 분석.

실제 적용 코드: Gatling 테스트 스크립트

import io.gatling.core.Predef._
import io.gatling.http.Predef._

class OrderServiceLoadTest extends Simulation {
  
  val httpProtocol = http.baseUrl("http://green-new-system")
  
  val scn = scenario("Order Load Test")
    .exec(
      http("Create Order")
        .post("/order")
        .body(StringBody("""{
            "userId": "12345",
            "items": [{"productId": "A1", "quantity": 2}]
        }""")).asJson
        .check(status.is(200))
    )
  
  setUp(
    scn.inject(constantUsersPerSec(500).during(60))
  ).protocols(httpProtocol)
}

3. Rollback 계획

새로운 시스템에서 심각한 오류가 발생할 경우를 대비하여 롤백 가능한 시스템을 구축했습니다.

  • 새로운 시스템에서 처리한 데이터는 별도의 Kafka Topic으로 전달.
  • Rollback 실행 시 Kafka Topic을 통해 데이터를 레거시 DB로 복구.

코드 예시: Kafka Consumer를 활용한 롤백 처리

@Component
class RollbackConsumer(
    private val legacyOrderRepository: LegacyOrderRepository
) {

    @KafkaListener(topics = ["order.rollback"], groupId = "rollback-group")
    fun handleRollback(record: ConsumerRecord<String, String>) {
        val order = ObjectMapper().readValue(record.value(), LegacyOrder::class.java)
        legacyOrderRepository.save(order)
    }
}

5.2 핵심 내용

1. 외부 의존성의 장점

외부 시스템(API)와의 통신이 병목이었던 기존 구조를 개선하기 위해 Circuit Breaker 패턴을 적용했습니다.

실제 코드: Resilience4j 적용

@Service
class PaymentService(
    private val paymentClient: PaymentClient
) {
    
    @CircuitBreaker(name = "paymentService", fallbackMethod = "fallbackPayment")
    suspend fun processPayment(request: PaymentRequest): PaymentResult {
        return paymentClient.process(request)
    }

    fun fallbackPayment(request: PaymentRequest, ex: Throwable): PaymentResult {
        return PaymentResult.failure("Fallback triggered: ${ex.message}")
    }
}

적용 결과

  • 외부 결제 API 지연으로 인한 실패율을 2%에서 0.1%로 감소.
  • Fallback 처리로 사용자 경험 개선.

2. 프로세서 처리 성능 최적화

Kotlin Coroutines을 활용하여 외부 API 호출과 DB 작업을 병렬로 처리하여 성능을 최적화했습니다.

실제코드: 병렬 처리 적용

suspend fun processOrder(command: CreateOrderCommand): OrderResult = coroutineScope {
    val order = Order.create(command)
    
    val (inventory, payment) = awaitAll(
        async { inventoryPort.getInventory(order.items) },
        async { paymentPort.process(PaymentRequest.from(order)) }
    )
    
    require(order.validateStock(inventory)) { throw OutOfStockException() }
    if (!payment.isSuccess()) { throw PaymentFailedException() }
    
    orderRepository.save(order)
}

적용 결과

  • 평균 응답 시간 8초 → 1.5초.
  • DB Connection Pool 사용률 95% → 45%.

3. 감시 및 알림

Prometheus와 Grafana를 활용하여 시스템 성능을 실시간으로 모니터링했습니다.

  • 주문 처리 시간(Timer)과 에러율(Meter)을 대시보드로 시각화.

실제 코드: Micrometer로 성능 데이터 수집

@Component
class OrderMetrics(
    private val meterRegistry: MeterRegistry
) {

    fun recordOrderProcessingTime(duration: Long) {
        meterRegistry.timer("order.processing.time")
            .record(duration, TimeUnit.MILLISECONDS)
    }

    fun incrementErrorCount() {
        meterRegistry.counter("order.errors.count").increment()
    }
}

4. 데이터베이스 최적화

  • DB Connection Pool 크기를 조정하고, Read-Replica를 활용하여 읽기 작업을 분산 처리.
  • 기존 N+1 문제를 해결하기 위해 JPA Fetch Join 최적화.

코드 예시: Fetch Join

@Repository
interface OrderRepository : JpaRepository<OrderEntity, Long> {
    @Query("SELECT o FROM OrderEntity o JOIN FETCH o.items WHERE o.id = :orderId")
    fun findOrderWithItems(orderId: Long): OrderEntity
}

6. 후속 계획

1. 이벤트 기반으로의 전환

주문 생성 및 처리를 이벤트 기반 아키텍처로 전환.

  • Kafka를 사용하여 주문 생성 이벤트를 발행.
  • 각각의 마이크로서비스가 이벤트를 구독하여 처리.

이벤트 예시

@KafkaListener(topics = ["order.created"])
fun handleOrderCreated(event: String) {
    val order = ObjectMapper().readValue(event, Order::class.java)
    processOrder(order)
}

2. 캐시 연산 검토

Redis를 활용한 캐싱으로 API 호출과 DB 부하를 줄이는 작업 예정.

  • 주문 목록 API 호출 시 캐시 적용.
  • TTL(Time To Live) 설정으로 데이터 일관성 유지.

3. 마이크로서비스 검토

모놀리식 시스템을 마이크로서비스로 전환하여 독립적 배포 및 확장성을 확보.

  • 주문, 결제, 재고 관리 서비스를 독립적으로 분리.

7. 결론

해악을 끼치는 일과 코틀린 코루틴의 교체로 시스템의 성능과 비교하면 크게 개선되었습니다.
특히 실리콘의 절연 및 안정성 처리를 통해 복잡한 비즈니스 요구사항을 처리할 수 있을 가능성이 더 높습니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글