Backend/Java, Kotlin

[Kotlin] 서버에서 코루틴을 언제 활용하면 좋을까?

mopil 2023. 11. 18. 18:04
반응형

코루틴은 동시성 프로그래밍을 실현시키고자 할 때 주로 고려되는 요소이다.

 

자바에서는 스레드 풀을 이용해서 이를 구현하거나, RxJava 라이브러리를 활용하면 된다.

코틀린에서는 언어에서 지원하는 훨씬 쓰기 편한 코루틴을 활용할 수 있다.

 

보통 코틀린은 안드로이드에서 주로 사용되는 언어라 API call이나 뷰 렌더링을 코루틴으로 비동기적으로 수행하는데, 서버에서는 어떤 방식으로 활용하면 좋을지 알아보자

 

MySQL, Spring MVC를 사용하는 환경이라고 가정한다.

 

# 수행 시간이 오래걸리는 조회 API 성능 개선하기

다음과 같은 유저 상세조회 API를 생각해보자.

1. 유저가 쓴 게시물 목록 디비 조회 (5초)

2. 유저가 쓴 댓글 목록 디비 조회 (5초)

3. 유저의 특정 기간동안 거래내역 목록 디비 조회 (5초)

4. 1,2,3 정보를 종합하여 결과 리턴

 

이 API는 동기적으로 수행했을 때 총 15초라는 응답 시간을 가진다.

 

이런경우 코루틴이나 멀티스레딩을 활용하여 성능을 개선할 수 있다.

1. (비동기) 유저가 쓴 게시물 목록 디비 조회 (5초)

2. (비동기) 유저가 쓴 댓글 목록 디비 조회 (5초)

3. (비동기) 유저의 특정 기간동안 거래내역 목록 디비 조회 (5초)

4. 1,2,3 정보를 종합하여 결과 리턴 -> 총 수행 시간 5초

 

좀 더 하위 레벨의 SQL로 내려가서도 활용할 수 있다.

 

# 수행 시간이 오래걸리는 조회 쿼리 성능 개선하기

1년치 거래내역 정보를 디비에서 가져오는 쿼리가 있다고 생각해보고, 건수가 많이 20초가 소요된다고 해보자.

 

이를 해결하기 위해 1년치를 한번에 가져오지 않고 달별로 12번 나눠서 가져오도록 페이징 처리를 했다.

15초로 성능이 향상되었지만, 아직 느리다.

 

이런 경우 코루틴을 활용하여 월별로 가져오는 부분을 비동기적으로 수행하도록 수정할 수 있다.

 

# SQL을 코루틴에서 수행할 때 주의 할 점

1. JDBC는 블로킹 I/O

일반적으로 사용하는 JDBC는 I/O 작업이 완료될 때 까지 스레드를 블록시킨다. (블로킹I/O)

그래서 suspend 함수를 활용할 수 없다.

코루틴 컨텍스트에서 이를 단순히 할당하면, 동기적으로 수행된다. 

 

runBlocking {
    repository.findAll() // JPA는 JDBC를 사용하므로, 이 경우 결국 블록되어 코루틴의 특성 활용 불가능
}

 

만약 디비 I/O도 비동기적으로 하고 싶다면 R2DBC를 고려해봐야한다.

 

이런경우 코루틴 컨텍스트의 별도의 스레드 풀을 할당하여 멀티스레딩으로 동작하게끔 하면 동시성을 구현할 수 있다.

 

runBlocking(createGetTransactionThreadPool()) { // 별도의 스레드풀 할당
    repository.findAll() // 코루틴은 아니지만, 멀티스레딩을 좀 더 쉽게 사용 가능
}

 

즉, 이런 경우 자바의 ExecutorService와 동일하게 동작한다. 코루틴의 일시 중단 함수(suspend 키워드가 붙은 함수)를 활용하지 못 하고 결국 고전적인 스레드 풀 방식을 사용한 건 아쉽지만, 그래도 코드가 깔끔해진다.

 

 

2. write 쿼리는 문제를 유발할 수 있다.

write 연산 (UPDATE, INSERT, DELETE)을 코루틴(멀티스레드)로 수행하면 문제가 발생할 수 있다.

 

우선, 데드락이 발생여부와 원자성이 깨질 수 있으니 주의해야한다.

 

추가로 트랜잭션 관리를 할 수 없다. 스프링에서 제공하는 선언적 트랜잭션 (@Transactional)이 상태를 스레드 로컬에 저장하여 즉 스레드 바인딩되게 트랜잭션을 관리하기 때문에 멀티스레드는 트랜잭션 관리를 할 수 없다.

 

예시로 멀티 스레드를 활용하여 1000만건의 데이터를 100만건씩, 10스레드가 동시에 insert 한다고 가정했을 때

트랜잭션으로 묶어도 한 스레드에서 예외가 발생하면 다른 스레드에서 작업한 것들은 롤백되지 않는다.

 

이런 경우 @Transactional 어노테이션을 활용할 순 없고, 개발자가 명시적으로 커밋 롤백을 지정해줘야하고 복잡하다.

 

# 수행 시간이 오래걸리는 외부 호출 API 비동기 처리

비즈니스 로직상 외부 API (MSA환경에서 다른 서비스 호출)을 하는 경우 역시 비동기 처리를 통해 성능을 향상시킬 수 있다.

 

# (심화) 응답시간이 굉장히 적고, 대용량 트래픽을 처리해야하는 API 설계

가령, MSA 환경에서 트래픽을 가장 먼저 받고 service discovery 해주는 게이트웨이 서버를 만든다고 해보자.

이런 경우 블로킹 디비는 사용하지 않고, 굉장이 많은 트래픽을 낮은 지연시간에 처리해야하는 요구사항이 생긴다.

 

즉, MVC 톰캣의 스레드 풀 방식 (request-per-thread)은 성능 향상에 제한될 수 있다.

따라서 WebFlux 도입을 고려해야한다.

 

WebFlux에서는 코루틴을 적극 지원하므로 Reactor의 Mono, Flux말고 suspend함수를 활용하면 더 간략한 코딩이 가능하다.

// Reactor Mono
@GetMapping("/routes")
fun routes(): Mono<String> {
    // logics...
}

// Coroutine suspend
@GetMapping("/routes")
suspend fun routes(): String {
    // logics...
}

 

 

이 경우 위에서 소개한 블로킹 I/O 디비 경우 보단 suspend를 사용하여 보다 코루틴을 잘 활용한 케이스라고 할 수 있다.

반응형