Backend

[코틀린 마스터하기] 4편: 코틀린 고급 - 코루틴으로 비동기 마스터

관리자

6일 전

12600
#백엔드#Kotlin#Coroutines#Async#Flow#WebFlux

[코틀린 마스터하기] 4편: 코틀린 고급 - 코루틴으로 비동기 마스터

📚 이 글은 "코틀린 마스터하기" 시리즈의 마지막 편입니다.

🎯 한 줄 요약

코틀린의 킬러 기능! 코루틴으로 비동기 프로그래밍을 동기처럼 쉽게!

코루틴 비동기 프로그래밍

🤔 이런 경험 있으신가요?

  • "Callback Hell에서 벗어나고 싶어요"
  • "CompletableFuture 너무 복잡해요"
  • "비동기 코드 디버깅이 지옥같아요"
  • "동시에 여러 API 호출하는데 코드가 난장판이에요"

코루틴이 이 모든 걸 해결해줍니다! 🚀

💡 코루틴? 그냥 가벼운 스레드입니다!

"스레드 1개에 코루틴 100,000개 실행 가능!"

자바의 스레드는 생성 비용이 큽니다 (1MB+ 메모리).
하지만 코루틴은? 단 몇 KB!

코루틴 vs 스레드

🚀 1. 코루틴 시작하기 - 너무 쉬워!

기본 설정 (build.gradle.kts)

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0") // Spring WebFlux용
}

첫 번째 코루틴

자바의 CompletableFuture 방식:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    try {
        Thread.sleep(1000);
        return "Hello";
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
});

future.thenAccept(result -> System.out.println(result));

코틀린 코루틴 방식:

fun main() = runBlocking {
    val result = async {
        delay(1000)  // Thread.sleep 대신!
        "Hello"
    }
    println(result.await())
}

10배는 간단하죠? 😎

💎 2. async/await - 병렬 처리의 정석!

동시에 여러 API 호출하기

class UserService {
    suspend fun getUserDetails(userId: String): UserDetails {
        // 3개 API를 동시에 호출!
        val profileDeferred = async { fetchProfile(userId) }
        val postsDeferred = async { fetchPosts(userId) }
        val friendsDeferred = async { fetchFriends(userId) }
        
        // 모두 기다리기
        return UserDetails(
            profile = profileDeferred.await(),
            posts = postsDeferred.await(),
            friends = friendsDeferred.await()
        )
    }
    
    // 각 API 호출은 suspend 함수
    private suspend fun fetchProfile(userId: String): Profile {
        delay(1000) // 네트워크 지연 시뮬레이션
        return Profile(name = "홍길동", age = 25)
    }
    
    private suspend fun fetchPosts(userId: String): List<Post> {
        delay(1500)
        return listOf(Post("첫 게시글"), Post("두 번째 게시글"))
    }
    
    private suspend fun fetchFriends(userId: String): List<Friend> {
        delay(800)
        return listOf(Friend("김철수"), Friend("이영희"))
    }
}

순차 실행이면 3.3초, 병렬이면 1.5초!

에러 처리도 깔끔하게

suspend fun fetchDataWithErrorHandling(): Result<Data> {
    return try {
        supervisorScope {
            val data1 = async { api1() }
            val data2 = async { api2() }
            val data3 = async { api3() }
            
            // 하나가 실패해도 나머지는 계속!
            val result1 = data1.awaitOrNull()
            val result2 = data2.awaitOrNull()
            val result3 = data3.awaitOrNull()
            
            Result.success(
                Data(result1, result2, result3)
            )
        }
    } catch (e: Exception) {
        Result.failure(e)
    }
}

// 확장 함수로 편리하게
suspend fun <T> Deferred<T>.awaitOrNull(): T? = try {
    await()
} catch (e: Exception) {
    null
}

병렬 처리 다이어그램

🌊 3. Flow - 리액티브 스트림의 진화!

RxJava vs Flow

RxJava 방식:

Observable.interval(1, TimeUnit.SECONDS)
    .take(5)
    .map(i -> "Item " + i)
    .subscribe(System.out::println);

Flow 방식:

fun countdownFlow() = flow {
    for (i in 5 downTo 1) {
        emit("카운트다운: $i")
        delay(1000)
    }
    emit("🚀 발사!")
}

// 사용
fun main() = runBlocking {
    countdownFlow().collect { value ->
        println(value)
    }
}

실시간 데이터 스트림

class StockPriceService {
    fun getStockPrices(symbol: String): Flow<StockPrice> = flow {
        while (currentCoroutineContext().isActive) {
            val price = fetchLatestPrice(symbol)
            emit(price)
            delay(1000) // 1초마다 갱신
        }
    }.flowOn(Dispatchers.IO)
    
    // 변환과 필터링
    fun getHighPrices(symbol: String): Flow<StockPrice> =
        getStockPrices(symbol)
            .filter { it.price > 100000 }
            .map { it.copy(alert = "고가 알림!") }
}

StateFlow와 SharedFlow - UI 상태 관리

class ViewModel {
    // 상태 관리 (항상 최신 값 유지)
    private val _uiState = MutableStateFlow(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
    
    // 이벤트 전달 (일회성)
    private val _events = MutableSharedFlow<Event>()
    val events: SharedFlow<Event> = _events.asSharedFlow()
    
    fun loadData() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val data = repository.getData()
                _uiState.value = UiState.Success(data)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message)
                _events.emit(Event.ShowToast("오류 발생!"))
            }
        }
    }
}

📡 4. Channel - 코루틴 간 통신!

Producer-Consumer 패턴

fun CoroutineScope.produceNumbers() = produce<Int> {
    var x = 1
    while (true) {
        send(x++) // 무한히 숫자 생성
        delay(100)
    }
}

fun CoroutineScope.square(numbers: ReceiveChannel<Int>) = produce<Int> {
    for (x in numbers) {
        send(x * x) // 제곱 계산
    }
}

fun main() = runBlocking {
    val numbers = produceNumbers()
    val squares = square(numbers)
    
    // 5개만 출력
    repeat(5) {
        println(squares.receive())
    }
    
    coroutineContext.cancelChildren() // 자원 정리
}

Fan-out / Fan-in 패턴

// Fan-out: 하나의 생산자, 여러 소비자
suspend fun fanOut() {
    val producer = produce {
        repeat(10) {
            send(it)
        }
    }
    
    // 5개의 워커가 동시에 처리
    repeat(5) { workerId ->
        launch {
            for (msg in producer) {
                println("Worker $workerId 처리: $msg")
            }
        }
    }
}

// Fan-in: 여러 생산자, 하나의 소비자
suspend fun fanIn() {
    val channel = Channel<String>()
    
    // 여러 소스에서 데이터 수집
    launch { 
        repeat(5) { channel.send("Source1: $it") }
    }
    launch { 
        repeat(5) { channel.send("Source2: $it") }
    }
    
    // 하나의 소비자가 모두 처리
    repeat(10) {
        println(channel.receive())
    }
}

Channel 통신 패턴

🚀 5. Spring WebFlux와 코루틴 조합!

Reactive REST API

@RestController
@RequestMapping("/api/reactive")
class ReactiveController(
    private val service: ReactiveService
) {
    // Flow 반환 - 스트리밍 응답
    @GetMapping("/stream")
    fun streamData(): Flow<DataEvent> = flow {
        repeat(10) {
            emit(DataEvent("Event $it"))
            delay(1000)
        }
    }
    
    // suspend 함수 - 단일 응답
    @GetMapping("/user/{id}")
    suspend fun getUser(@PathVariable id: String): User {
        return service.findUser(id)
    }
    
    // 병렬 처리
    @GetMapping("/dashboard")
    suspend fun getDashboard(): Dashboard {
        return coroutineScope {
            val stats = async { service.getStats() }
            val activities = async { service.getActivities() }
            val notifications = async { service.getNotifications() }
            
            Dashboard(
                stats = stats.await(),
                activities = activities.await(),
                notifications = notifications.await()
            )
        }
    }
}

WebClient와 코루틴

@Service
class ApiClient {
    private val webClient = WebClient.builder()
        .baseUrl("https://api.example.com")
        .build()
    
    suspend fun fetchData(id: String): ApiResponse {
        return webClient
            .get()
            .uri("/data/{id}", id)
            .retrieve()
            .awaitBody() // suspend 함수로 변환!
    }
    
    // 여러 API 동시 호출
    suspend fun aggregateData(ids: List<String>): List<ApiResponse> {
        return ids.map { id ->
            async {
                try {
                    fetchData(id)
                } catch (e: Exception) {
                    ApiResponse.empty() // 실패 시 기본값
                }
            }
        }.awaitAll() // 모두 완료 대기
    }
}

⚡ 6. 실전 최적화 팁!

Dispatcher 선택 가이드

class OptimizedService {
    // CPU 집약적 작업
    suspend fun processData(data: List<Item>) = withContext(Dispatchers.Default) {
        data.map { complexCalculation(it) }
    }
    
    // I/O 작업
    suspend fun readFile(path: String) = withContext(Dispatchers.IO) {
        File(path).readText()
    }
    
    // UI 업데이트 (Android)
    suspend fun updateUI(text: String) = withContext(Dispatchers.Main) {
        textView.text = text
    }
}

성능 최적화 패턴

// 1. Flow 캐싱
val expensiveFlow = flow {
    // 비싼 연산
    emit(calculateExpensive())
}.shareIn(
    scope = GlobalScope,
    started = SharingStarted.Lazily,
    replay = 1
)

// 2. 타임아웃 설정
suspend fun fetchWithTimeout(): String? {
    return withTimeoutOrNull(5000) { // 5초 타임아웃
        slowApiCall()
    }
}

// 3. 재시도 로직
suspend fun <T> retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 100,
    block: suspend () -> T
): T {
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            delay(initialDelay * (attempt + 1))
        }
    }
    return block() // 마지막 시도
}

🎯 7. 테스트 작성하기

class CoroutineTest {
    @Test
    fun `코루틴 테스트`() = runTest {
        // runTest는 delay를 스킵!
        val service = UserService()
        
        val result = service.fetchUser("123")
        
        assertEquals("홍길동", result.name)
    }
    
    @Test
    fun `Flow 테스트`() = runTest {
        val flow = flowOf(1, 2, 3)
            .map { it * 2 }
            .filter { it > 2 }
        
        val results = flow.toList()
        
        assertEquals(listOf(4, 6), results)
    }
}

📊 실제 성능 비교

작업 스레드 (Java) 코루틴 (Kotlin)
10,000개 동시 실행 10GB 메모리 50MB 메모리
컨텍스트 스위칭 느림 (μs) 빠름 (ns)
생성 비용 높음 거의 없음
취소/타임아웃 복잡 간단

💭 마무리

"코루틴 없이 어떻게 코딩했나 싶어요"

이제 여러분도 이런 말을 하게 될 겁니다!

코루틴은 단순히 비동기를 쉽게 만드는 것이 아니라,
코드를 읽기 쉽고, 유지보수하기 쉽게 만들어줍니다.

자바의 CompletableFuture, RxJava를 쓰셨다면,
코루틴의 간결함에 놀라실 거예요!

🎓 시리즈를 마치며

4편에 걸쳐 코틀린의 A to Z를 다뤘습니다:

  1. 기초: 자바와의 차이점, 기본 문법
  2. 생산성: 데이터 클래스, 확장 함수, 컬렉션 API
  3. 실전: Spring Boot 통합, REST API 구축
  4. 고급: 코루틴으로 비동기 마스터

이제 여러분은 진정한 코틀린 개발자입니다! 🎉


📚 전체 시리즈 다시보기


시리즈를 끝까지 읽어주셔서 감사합니다! 🙏

코틀린 개발 중 막히는 부분이 있다면 언제든 댓글로 질문해주세요! 💬

Happy Kotlin Coding! 🚀

댓글 0

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!