본문 바로가기

KOTLIN/코루틴의 정석

코틀린 코루틴의 정석, 첫 걸음

👩‍💻 오늘의 할 일

코루틴을 공부하면서 정말 많은 도움을 받았던 블로그의 저자분께서 책을 내신다 해서 한 달 전부터 예약 걸고 기다렸는데 드디어 오늘 도착했습니다. 제가 살다 살다 전공 서적을 이렇게 기다릴 줄은 몰랐네요🤣 그냥 공부하기보단 저자분처럼 직접 글로 쓰고, 그림을 그리며 이해하는 게 가장 좋은 학습 방법이라고 생각해서 오늘부터 꾸준히 글을 남겨볼까 합니다. 해당 책과 블로그는 여기를 참조해 주세요!

 

코틀린 코루틴의 정석 책 출간

코틀린 코루틴의 정석 책 출간 소식 안녕하세요. '조세영의 Kotlin World' 기술 블로그를 운영 중인 조세영입니다. 이번에 제가 저술한 『코틀린 코루틴의 정석』, 책이 출간되었습니다. 이 책은 많

kotlinworld.com

 

이제 첫걸음마 단계를 넘어서 어디서 어떤 코루틴 스코프를 사용하고 왜 그 스코프를 사용해야 하는지 정확히 알고 사용하는 단계가 되었습니다. 하지만 여전히 코루틴이 기존 스레드 방식과 비교했을 때 어떤 장점이 있고 어떤 이유에서 스레드 방식을 대처할 수 있는지 정확히 설명할 수 없었습니다. 근데 역시는 역시나... 저자분께서 책의 1장부터 기존 스레드 방식의 프로세스와 코루틴이 어떤 방식으로 스레드를 대체하는지 아주 아주 친절히 설명해 주셨더라고요! 정말 코루틴 공부할 수 있는 한글 자료가 없어서 어려움을 많이 겪었는데 정말 대한민국의 코루틴 개척자가 아니실까 싶네요.

👩‍🏫 Chapter 1

첫 챕터에서는Thread와 코루틴이 무엇인지에 대해 알아볼 겁니다. 안드로이드의 Main Thread에 대해서 한번 다뤄본 이 있는데 같이 보면 좋을 것 같아서 남겨봅니다!

 

Android Main Thread

[안드로이드 공식문서] 프로세스 및 스레드 개요 | App quality | Android Developers 프로세스 및 스레드 개요 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 애플리

chanho-study.tistory.com

1.1 JVM Process & Thread

가장 먼저 Main Thread에 대해 알아보겠습니다. 일반적으로 Kotlin 또는 Java 어플리케이션의 실행 진입점은 Main 함수를 통해 이루어지며 JVM은 프로세스를 시작하고 Main Thread를 생성해 Main 함수의 내부 코드들을 실행합니다. 그 후, Main 함수 내부의 코드가 모두 실행되면 어플리케이션은 종료됩니다.

Hello World 예제는 처음 코딩을 시작할 때도 생각나고 언제 봐도 반갑네요. 이제 저는 단순히 Hello World가 출력되는 게 아니라 Hello World를 출력하기 위한 Main Thread의 동작을 이해한답니다 하하하하🤣 이 코드는 main 함수에서 "Hello World"를 출력하면 더 이상 실행할 코드가 없기 때문에 프로세스가 종료됩니다. 

 

이처럼 Main Thread는 프로세스의 시작과 끝을 함께 하는 매우 중대한 임무를 수행하는데, 만약 Main Thread가 예외로 인해 강제 종료되면 프로세스도 강제 종료됩니다. 

fun main(){
    println("메인 함수 시~작!")
    throw Exception("I am Exception")
    println("메인 스레드 종료")
}
/**
 * 실행 결과
 * 메인 함수 시~작!
 * Exception in thread "main" java.lang.Exception: I am Exception
 * 	at chap1.Code1_1Kt.main(Code1-1.kt:5)
 * 	at chap1.Code1_1Kt.main(Code1-1.kt)
 * */

이 코드를 실행하면 "메인 함수 시~작!"은 출력되지만 Main Thread에서 예외가 발생해 "메인 스레드 종료"는 출력되지 않습니다. 이렇듯, JVM의 프로세스는 Main Thread를 단일 Thread로 해서 실행되며 Main Thread가 종료되면 종료되는 특징이 있습니다.

 

단, 주의할 점은 Main Thread가 항상 Process의 끝을 함께하는 것은 아닙니다. JVM의 프로세스는 사용자 스레드가 모두 종료될 때 종료됩니다. Multi Thread 환경에서는 사용자 스레드가 여러 개일 경우 Main Thread에서 예외가 발생하더라도 프로세스가 강제 종료되지는 않습니다. 

1.2 Single Thread의 한계와 Multi Thread Programming

스레드 하나만 사용해 실행되는 어플리케이션은 Single Thread Application이라고 합니다. 대표적인 예시로 Android가 있습니다. Single Thread의 한계점엔 어떤 것들이 있을까요?

1.2.1 Single Thread Application의 한계

 구조적인 문제점으론 스레드는 하나의 작업을 수행할 때 다른 작업을 동시에 수행할 수 없습니다. 메인 스레드에서 실행하는 작업이 오래 걸리면 해당 작업이 처리되는 동안 다른 작업을 수행할 수 없습니다.

 

예시로, 안드로이드 어플리케이션은 Main Thread(UI Thread)에서 네트워크나 DB 트랜잭션 등 오래 걸리는 작업을 수행하면 화면이 멈춰버리고 사용자에게 터치 같은 이벤트를 발생시킬 수 없고 ANR(Application Not Responing) 에러가 발생합니다.

1.2.2 멀티 스레드 프로그래밍을 통한 단일 스레드의 한계 극복

단일 스레드의 한계를 멀티 스레드 프로그래밍으로 해결할 수 있습니다. 여러 개의 스레드로 작업을 실행하면 각각의 스레드가 한 번에 하나의 작업을 처리할 수 있어 여러 작업을 동시에 처리할 수 있습니다. 안드로이드의 경우엔 시간이 오래 걸리는 작업을 백그라운드 스레드에서 처리하도록 만들어 해결할 수 있습니다.

멀티 스레드를 사용하면 위 그림처럼 독립적으로 분할된 작업을 각각  다른 스레드로 할당해 처리할 수 있습니다. 이런 방식으로 스레드가 동시에 작업을 처리하면 싱글 스레드 방식보다 처리 속도가 빨라지고 이를 병철 처리(Parellel Processing)이라고 합니다.

🚨 주의
모든 작업을 작은 단위로 나눠 병렬로 실행할 수 있는 것은 아닙니다.
작은 작업 간에 의존성이 있다면 작은 작업은 순차적으로 실행돼야 합니다.
예를 들어, DB2에서 조회를 위해 DB1을 조회한 결과가 필요하다면 두 작업은 순차적으로 실행돼야 합니다.

1.3. Thread, Thread Pool을 사용한 Multi Thread Programming

1.3.1 Thread Class

Thread Class를 상속하는 Class를 만들어 작업 스레드를 만들 수 있습니다. 예제로 2초 정도의 시간이 걸리는 작업 스레드를 만들어 보겠습니다. Thread Class의 run 메소드를 오버라이드 하면 작업 스레드에서 할 작업을 정의할 수 있습니다.

package chap1

fun main(){
	// 현재 동작하는 Thread의 Name을 가져옴
    println("[${Thread.currentThread().name}] 메인 쓰레드 시작")
    // 새로운 스레드에 작업 요청
    ExampleThread1().start()
    // 1초 Delay
    Thread.sleep(1000)
    println("[${Thread.currentThread().name}] 메인 쓰레드 종료")
}

// 새로운 작업 Thread 생성
class ExampleThread1: Thread(){
    // 작업 스레드로 할 일을 정의
    override fun run() {
        super.run()
        println("[${Thread.currentThread().name}] 새로운 쓰레드 시작")
        sleep(2000)
        println("[${Thread.currentThread().name}] 새로운 쓰레드 종료")
    }
}
/**
 * 실행 결과
 * [main] 메인 쓰레드 시작
 * [Thread-0] 새로운 쓰레드 시작
 * [main] 메인 쓰레드 종료
 * [Thread-0] 새로운 쓰레드 종료
 * */

📌 Demon Thread
JVM은 스레드를 사용자 스레드와 데몬 스레드로 구분합니다. 
주 스레드(main thread)의 작업을 지원하거나 보조적인 작업을 처리하는 데 사용됩니다.
일반적으로 주 스레드에 종속 되어 있기 때문에 주 스레드가 종료되면 데몬 스레드도 함께 종료됩니다. 데몬 스레드를 생성하려면 다음과 같이 isDemon = true 속성을 지정해 주면 됩니다.
ExampleThread().apply {
    isDaemon = true
}.start()​

1.3.2. Thread Block

코틀린에선 다음과 같이 스레드를 쉽게 만들 수 있도록 thread 함수를 위한 람다식을 제공합니다.

import kotlin.concurrent.thread

fun main(){
    println("[${Thread.currentThread().name}] 메인 스레드 시작")
    thread(isDaemon = false) {
        println("[${Thread.currentThread().name}] 새로운 스레드 시작")
        Thread.sleep(2000L)// 2초 대기
        println("[${Thread.currentThread().name}] 새로운 스레드 종료")
    }

    Thread.sleep(1000)
    println("[${Thread.currentThread().name}] 새로운 스레드 종료")
}

1.3.3 Thread의 한계점

  • Tread Class를 통해 새로운 스레드를 인스턴스화 하는 작업은 실행할 때마다 매번 새로운 스레드를 생성합니다. 이로 인해 몇 가지 문제가 발생합니다.
    • 애초에 스레드는 생성 비용이 비싸기 때문에 매번 새로운 스레드를 생성하는 것은 성능적으로 좋지 않습니다.
    • 스레드간의 작업 전환(Context Switch) 시에도 비용이 발생합니다.
    • 스레드 생성과 관리에 대한 책임이 모두 개발자에게 있기 때문에 실수로 인해 예상치 못한 오류가 발생하거나 Memory Leak이 발생할 수 있습니다.

1.4. Executor Framework

위에 언급된 Thread의 한계점을 극복하기 위해 등장한 Framework입니다. Executor는 스레드 풀이란 개념을 사용합니다. 우선 Executor는 개발자가 스레드를 직접 관리 한다는 단점을 극복하고 이미 생성된 스레드의 재사용성을 높이기 위해 등장했습니다.  스타크래프트의 스포닝 풀이 생각나는 건 왜지...?

 

Executor의 동작 원리는 작업 처리를 위한 스레드풀을 미리 생성해 놓고 작업 요청시 쉬고 있는 스레드에 작업을 분배합니다. 이때, 각 스레드가 작업을 끝내도 스레드를 종료하지 않고 다음 작업이 들어올 시 재사용합니다.

1.4.1 Executor Framework 사용하기

1. 먼저 스레드풀을 생성하고 관리하는 객체를 생성합니다. newFixedThreadPool의 매개변수는 생성할 스레드의 개수가 됩니다.

// 스레드를 생성하고 관리하는 객체
val executorService: ExecutorService = Executors.newFixedThreadPool(2)

 

2. submit 함수를 사용해 원하는 작업을 제출합니다. 아래 코드는 각 작업 중 실행 중인 스레드의 이름과 작업에 소모된 시간을 출력합니다.

3. 작업이 완료된 후에는 shutdown 메소드를 사용해  executorService를 종료시켜 줍니다.

4. 이때, 두 개의 스레드는 서로 다른 스레드에서 병렬로 작업을 하기 때문에 실행할 때마다 출력 순서, 사용 순서는 다를 수 있습니다.

// Executor 사용해보기!
// Executor : 스레드 풀을 생성할 수 있는 프레임워크
fun main() {
    val startTime = System.currentTimeMillis()
    // ExecutorService 생성
    // 스레드를 생성하고 관리하는 객체
    val executorService: ExecutorService = Executors.newFixedThreadPool(2)

    // 작업1 제출
    executorService.submit {
        println("[${Thread.currentThread().name}][${getElapsedTime(startTime)}]작업1 시작")
        Thread.sleep(1000L) // 1초간 대기
        println("[${Thread.currentThread().name}][${getElapsedTime(startTime)}]작업1 완료")
    }
    // 작업2 제출
    executorService.submit {
        println("[${Thread.currentThread().name}][${getElapsedTime(startTime)}]작업2 시작")
        Thread.sleep(1000L) // 1초간 대기
        println("[${Thread.currentThread().name}][${getElapsedTime(startTime)}]작업2 완료")
    }
    // ExecutorService 종료
    executorService.shutdown()
  }  
    /*
    실행 결과 
    [pool-1-thread-1][지난 시간: 8ms]작업1 시작
    [pool-1-thread-2][지난 시간: 8ms]작업2 시작
    [pool-1-thread-1][지난 시간: 1012ms]작업1 완료
    [pool-1-thread-2][지난 시간: 1012ms]작업2 완료
    
	항상 작업 순서가 달라진다
	왜 ? 두 개의 스레드가 병렬로 실행되고 있기 때문!
	*/

	fun getElapsedTime(startTime: Long): String =
    "지난 시간: ${System.currentTimeMillis() - startTime}ms"

1.4.2 Executor Framework의 한계점

Executor Framework은 개발자가 스레드를 직접 관리하지 않고 스레드의 재사용성을 높여줄 수 있었지만 Thread를 사용한다는 점에서 근본적으로 Bloking으로 인한 문제가 발생합니다.

 

스레드가 공유 자원에 접근하거나 동기화 블록에 동시에 접근하는 경우 하나의 스레드만 접근이 허용되기 때문에 발생할 수 있고 Mutex, Semaphore로 인해 공유 자원에 접근할 수 있는 스레드가 제한됩니다.

✔ Mutex : 공유 자원에 대한 접근을 한 번에 하나의 스레드만 허용하도록 제한하는 동기화 메커니즘
✔Semaphore : 현재 공유자원에 접근할 수 있는 스레드, 프로세스의 수를 나타내는 값을 두어 상호배제를 달성하는 기법

 

Executor Framework가 Blocking이 일어나는 예시를 한번 보겠습니다. ExecutorService 객체에 제출한 작업에서 결과를 전달받을 때는 언제 올지 모르는 Future 객체를 사용합니다. Future 객체는 미래에 언제 올지 모르는 값을 기다리는 함수인 get을 갖고 있고, get 함수를 호출하면 get 함수를 호출한 스레드가 결괏값이 반환될 때까지 블로킹됩니다.

import java.util.concurrent.*

fun main() {
    // 2개의 스레드풀 생성
    val executorService: ExecutorService = Executors.newFixedThreadPool(2)
    // 스레드가 결과값을 반환할 때 까지 bloking
    val future: Future<String> = executorService.submit<String> {
        Thread.sleep(2000)
        return@submit "작업 1완료"
    }

    // 메인 스레드가 블로킹 됨
    val result = future.get()
    println(result)
    executorService.shutdown()
}

1.5 코루틴은 스레드 블로킹 문제를 어떻게 극복하는가?

🎉 작업 단위 코루틴 : 스레드에서 작업 실행 도중 일시 중단할 수 있는 작업 단위

 코루틴은 작업이 일시 중단되면 스레드의 사용 권한을 양보하고 양보된 스레드는 다른 작업을 실행하는 데 사용할 수 있으며 일시 중단된 코루틴은 재개 시점에 다시 스레드에 할당돼 실행됩니다

 

코루틴 생성 -> 코루틴 스케줄러에 전달 -> 스레드나 스레드 풀에 해당 코루틴을 분배해 작업 수행 이 세 과정을 거치게 됩니다. 코루틴이 경량 스레드라고 불리는 이유와 정확히 코루틴이 어떻게 동작하는지 대해 그림을 통한 예시로 살펴보겠습니다.

  • 기존 멀티 스레드 환경에선 작업 3을 수행하기 위해선 작업 1이 종료돼야 수행할 수 있습니다. 그렇다면 코루틴을 사용하면 어떻게 될까요?

핵심은 스레드-0에서 코루틴 2의 결과가 필요한 시점에 스레드 사용 권한을 반납합니다.

이때, 일반 스레드 방식처럼 Thread-0이 Block 되는 것이 아니라 Thread-1에선 코루틴 2를 실행하면서 동시에 Thread-0 에선 코루틴 3을 수행한다는 점입니다.

이후 코루틴 2의 실행이 완료된 시점에 Thread-0은 코루틴 3을 이미 완료해 쉬고 있고 Thread-1 도 코루틴 2 작업이 완료돼 사용 가능 하므로 Thread-0 또는 Thread-1 스레드가 코루틴 1을 할당받아 남은 작업을 하게 됩니다.

📖 정리

코루틴은 스레드의 작업 단위입니다. 코루틴이 스레드를 사용하지 않을 때 스레드 사용 권한을 양보하는 방식으로 스레드의 사용을 최적화하고 블로킹을 방지합니다.

 

또 다른 코루틴의 장점은 스레드에 비해 생성 비용이 적게 들고 전환 비용(Context Switch)이 적게 들어 자유롭게 뗐다 붙였다 할 수 있습니다. 이것이! 바로 코루틴이 경량 스레드라고 불리는 이유입니다. 

 

이외에도 구조화된 동시성을 통해 안정적인 비동기 작업을 하며, 예외 처리를 효과적으로 처리할 수 있습니다.

📕 후기

후... 정신없이 첫 번째 장이 끝났네요. 정말 책을 이렇게 잼있게 읽은게 얼마만인지... 정말 기대했던것 만큼 너무나 쉽게 설명이 되있어서 보는 내내 너무 잼있었습니다. 기존 스레드가 어떤 문제가 있는지, 코루틴은 스레드의 블로킹은 어떤 방식으로 극복하였는지 그림으로 너무 쉽게 설명이 되어있어 이해가 쏙쏙 되었습니다. 다음장 부터 본격적인 코루틴에 대한 내용이 시작될 탠데 기대가 됩니다.

 

🎉 Reference

저자님 블로그

 

조세영의 Kotlin World

'조세영의 Kotlin World'는 Kotlin를 전문적으로 다루는 개인 기술 블로그입니다. Kotlin 세계에 대한 양질의 자료를 제공하며 Kotlin, Android, Spring, CI, CD 분야에 대해 다룹니다.

kotlinworld.com

서적 내 예제 Code

 

GitHub - seyoungcho2/coroutinesbook: 『코틀린 코루틴의 정석』, 에이콘 출판사(2024) 저장소 입니다.

『코틀린 코루틴의 정석』, 에이콘 출판사(2024) 저장소 입니다. Contribute to seyoungcho2/coroutinesbook development by creating an account on GitHub.

github.com

 

'KOTLIN > 코루틴의 정석' 카테고리의 다른 글

코틀린 코루틴의 정석, 세 걸음  (0) 2024.03.22
코틀린 코루틴의 정석, 두 걸음  (0) 2024.03.07