Developing Myself Everyday

안드로이드 개발을 하다 보면 어쩔 수 없이 동시성에 관련된 문제들을 마주치기 마련이다. 안드로이드 개발에서는 코틀린의 코루틴을 통해 UI 스레드가 중단되는 문제를 효율적으로 처리할 수 있다. 이제부터 코루틴에 대한 기본 개념과 사용 방법을 알아보고자 한다.

 

 

자바 동시성


코틀린이 존재하기 전 자바에서는 동시성을 어떻게 처리했을까? 바로 자바 동시성 기본 요소를 사용하는 것이다. 자바 동시성 기본 요소를 사용하면 스레드 안전성을 달성할 수 있다. 하지만 엄청난 단점이 존재한다. 대부분의 동시성 연산이 Blocking 연산임으로 스레드를 블럭하고 실행을 재개할 때 Context Switch(문맥 교환)를 해야 하므로 프로그램 성능에 부정적인 영향을 미치게 된다. 그래서 동시성 스레드를 많이 사용하는 것은 비실용적이다. 

 

그래도 자바 동시성을 사용하는 법을 알아보겠다. 

 

자바 동시성의 핵심은 스레드를 잘 사용하는 것이다. 스레드는 크게 2가지로 나눌 수 있다.

 

1. 메인 스레드

액티비티를 포함해 모든 컴포넌트가 실행되는 오직 한 개만 존재하는 스레드이다. 

  • 프로그램에 실행될 때 자동으로 생성되는 기본 스레드이다.
  • 화면의 UI를 그리는 처리를 담당한다.
  • 메인 애플리케이션 로직 실행 등과 같은 중요한 작업을 담당한다.
  • 메인 스레드는 플로킹되면 안된다.

 

2. 백그라운드 스레드

메인 스레드 외에 생성되는 스레드로, 보조 작업을 수행하는 데 사용된다. 백그라운드 스레드는 메인 스레드와 병렬로 실행되어 여러 작업을 동시에 처리할 수 있다. 예를 들어, 파일 다운로드, 데이터베이스 작업, 네트워크 요청 등은 주로 백그라운드 스레드에서 처리된다. 백그라운드 스레드는 애플리케이션의 반응성을 유지하면서 오래 걸리는 작업을 분리하여 실행함으로 사용자 경험을 향상시킨다.

 

 

우리가 Kotlin에서 생성하는 스레드들은 기본적으로 백그라운드 스레드로 생성된다. Kotlin은 Java와 마찬가지로 JVM(Java Virtual Machine) 위에서 실행되며, JVM은 스레드를 백그라운드로 실행하는 것을 기본 동작으로 가지고 있다.

 

 


 

 

스레드 시작하기

범용 스레드를 시작하려면, 스레드에서 실행하려는 Runnable(실행 가능) 객체에 대응하는 람다와 스레드 프로퍼티들을 지정해서 thread() 함수를 사용하면 된다. 다음은 스레드에서 사용되는 프로퍼티들이다.

 

  • start: 스레드를 바로 시작할지 (Default: true)
  • isDaemon: 스레드를 데몬모드로 시작할지 (Default: true), 데몬 모드는 JVM의 종료를 방해하지 않고 메인 스레드가 종료될 때 자동으로 함께 종료된다.
  • contextClassLoader: 스레드 코드가 클래스와 자원을 적재할 때 상용할 클래스 로더 (Default:null)
  • name: 커스텀 스레드 이름 (Default: null)
  • priority: Thread.MIN_PRIORITY(=1) 부터 Thread.MAX_PRIORITY(=10) 사이의 갑승로 정해지는 우선순위 (Default:1)
  • block: () -> Unit 타입의 함숫값으로 새 스레드가 생성되면 실행할 코드

 

그럼 이제 실제로 스레드를 한 번 만들어 보겠다.

 

 

150밀리초마다 메시지를 출력하는 스레드

import kotlin.concurrent.thread

fun main() {
    println("스레드 시작하기...")
    
    thread(name = "준형", isDaemon = true) { 
        for (i in 1..5) {
            println("${Thread.currentThread().name}: $i")
            Thread.sleep(150)
        }
    }
    
    Thread.sleep(500)
    println("스레드 종료하기...")
}

 

출력

스레드 시작하기...
준형: 1
준형: 2
준형: 3
준형: 4
스레드 종료하기...

 

새 스레드가 데몬 모드로 시작했으므로, 메인 스레드가 500밀리초 동안 슬립한 다음 실행을 끝낼 때 이 스레드도 끝나기 때문에 메시지가 4개가 출력된다.

 

 


 

 

아까 스레드는 백그라운드 스레드에 생성된다고 했다. 안드로이드는 백그라운드 스레드에서는 UI에 접근할 수 없다. 이 때 사용할 수 있는 방법이 있다. 바로 runOnUiThread를 사용하는 것이다.

 

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        fun main() {
            println("스레드 시작하기...")

            thread(name = "준형", isDaemon = true) {
                for (i in 1..5) {
                    runOnUiThread {
                        text.text = i.toString()
                    }
                    Thread.sleep(150)
                }
            }

            Thread.sleep(500)
            println("스레드 종료하기...")
        }
    }
}

 

runOnUiThread을 사용하면 백그라운드 스레드에서도 UI에 접근할 수 있다.

 

 

Handler & Looper

백그라운드 스레드에서 UI에 접근하는 효과를 낼 수 있는 방법도 있다. 바로 Handler와 Looper를 사용하는 것이다. 이렇게 되면 스레드간 Runnable, Message 객체를 주고 받을 수 있게 되어, 이를 통해 백그라운드 스레드에서도 메인 스레드의 Handler를 통해 메인 스레드에서 UI 작업을 수행할 수 있게 한다.

 

import android.os.Handler
import android.os.Looper
import android.os.Message

// 백그라운드 스레드에서 작업을 수행하는 클래스
class BackgroundThread : Thread() {
    private lateinit var handler: Handler

    override fun run() {
        // Looper를 준비하고 메시지 루프를 시작
        Looper.prepare()

        // Handler를 생성하고 메시지를 처리할 콜백을 정의
        handler = object : Handler(Looper.myLooper()!!) {
            override fun handleMessage(msg: Message) {
                // 백그라운드 작업을 수행한 후 결과를 메인 스레드로 전달
                val result = performBackgroundTask()
                val message = obtainMessage()
                message.obj = result
                mainHandler.sendMessage(message)
            }
        }

        // 메시지 루프 실행
        Looper.loop()
    }

    // 백그라운드 작업을 수행하는 메서드
    private fun performBackgroundTask(): String {
        // 백그라운드 작업 수행
        Thread.sleep(2000)
        return "Background Task Completed"
    }
}

// 메인 스레드에서 작업을 처리하는 클래스
class MainActivity {
    private lateinit var backgroundThread: BackgroundThread

    private val mainHandler = object : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            val result = msg.obj as String
            // 메인 스레드에서 결과를 처리
            processResult(result)
        }
    }

    fun startBackgroundTask() {
        // 백그라운드 스레드 시작
        backgroundThread = BackgroundThread()
        backgroundThread.start()

        // 백그라운드 스레드의 Handler에 메시지를 전달하여 작업 요청
        backgroundThread.handler.sendEmptyMessage(0)
    }

    private fun processResult(result: String) {
        // 작업 결과를 처리하는 로직
        println("Result: $result")
    }
}

 

위의 예시에서 BackgroundThread 클래스는 백그라운드 스레드를 나타내며, Handler를 사용하여 메시지를 받고 처리한다. Looper.prepare()를 호출하여 Looper를 준비하고, Looper.loop()를 호출하여 메시지 루프를 실행한다. 이후 Handler를 생성하고 메시지를 처리할 콜백을 정의한다. 백그라운드 작업이 완료되면 Handler를 사용하여 메인 스레드로 결과를 전달한다.

MainActivity 클래스에서는 BackgroundThread를 생성하고 시작한다. 그리고 backgroundThread.handler.sendEmptyMessage(0)를 호출하여 백그라운드 스레드의 Handler에 메시지를 전달하여 작업을 요청한다. 메인 스레드에서는 mainHandler를 사용하여 메시지를 받고, 결과를 처리하는 processResult() 메서드를 호출한다.

 

이 예시를 통해 백그라운드 스레드와 메인 스레드 간의 통신을 Handler와 Looper를 사용하여 구현하는 방법을 알 수 있다.

 

 

 

Thread Pool


그럼 이렇게 계속해서 스레드를 늘리는건 좋은 것은 좋은 것 인가? 이렇게 스레드를 무차별적으로 늘리면 하드웨어의 무리가 가기 마련이다. 그래서 사용할 수 있는 것이 바로 Thread Pool 이다. 

 

Thread Pool은 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 큐에 들어오는 작업들을 하나씩 스레드가 맡아 처리하는 것을 말한다. 코틀린에서는 Thread Pool을 사용하기 위해 ExecutorService 인터페이스를 활용한다. ExecutorService는 스레드 풀을 관리하고 작업을 스케줄링하기 위한 메서드를 제공한다. 스레드 풀의 크기, 작업 큐의 종류 등을 설정할 수 있어 특정 상황에 맞게 조정할 수 있다.

 

 

스레드 풀을 사용하는 예시

import java.util.concurrent.Executors

fun main() {
    // 스레드 풀 생성
    val executorService = Executors.newFixedThreadPool(4)

    // 작업 제출
    for (i in 1..10) {
        executorService.submit {
            println("Task $i is being processed by ${Thread.currentThread().name}")
            Thread.sleep(1000)
            println("Task $i is completed by ${Thread.currentThread().name}")
        }
    }

    // 작업 완료 후 스레드 풀 종료
    executorService.shutdown()
}

 

 

사용할 수 있는 Thread Pool의 종류는 다음과 같다.

  1. FixedThreadPool: 고정된 크기의 스레드 풀을 생성합니다. 지정된 수의 스레드로 작업을 처리합니다. 추가적인 작업은 작업 큐에서 대기하게 됩니다.
  2. CachedThreadPool: 필요에 따라 스레드를 동적으로 생성 및 제거하는 스레드 풀입니다. 작업 요청이 증가하면 새로운 스레드를 생성하고, 스레드가 유휴 상태로 장시간 대기하면 스레드를 제거합니다. 크기를 동적으로 조정하여 작업 부하에 최적화됩니다.
  3. SingleThreadExecutor: 하나의 스레드만을 사용하는 스레드 풀입니다. 작업 큐에 있는 작업을 순차적으로 처리합니다. 주로 순차적인 작업이 필요한 경우에 사용됩니다.
  4. ScheduledThreadPool: 일정한 시간 간격으로 작업을 실행하는 스레드 풀입니다. 예약된 작업을 처리할 수 있으며, 일정한 주기로 작업을 반복 실행할 수도 있습니다.

 

 

 

동기화와 락


동기화는 코드가 한 스레드에서만 실행되게 하기 위한 요소다. 자바에서는 2가지의 방법으로 동기화를 제어할 수 있다. 

 

lock

락을 이용하면 사용하려는어떤 객체를 지정하는 특별한 동기화 블록을 사용해 코드를 감싸 동기화를 조절할 수 있다.

 

fun main() {
    var count = 0
    val lock = Any()

    for (i in 1..5) {
        thread(isDaemon = false) {
            synchronized(lock) {
                count += i
                println(count)
            }
        }
    }
}

 

동기화로 인한 결과는 다음과 같다.

 

1
6
10
13
15

 

synchronized() 함수는 람다의 반환값을 반환한다. 그래서 호출되는 시점의 중간 값을 읽어올 수도 있다.

 

 

@Synchronized 애너테이션

코틀린에서는 @Synchronized 애너테이션을 사용해 똑같은 결과를 얻을 수 있다.

 

class Count {
    private var value = 0
    @Synchronized
    fun addAndPrint(add: Int) {
        this.value += add
        println(value)
    }
}

fun main() {
    val count = Count()

    for (i in 1..5) {
        thread(isDaemon = false) {
            count.addAndPrint(i)
        }
    }
}

 

동기화로 인한 결과는 다음과 같다.

 

1
6
10
13
15

'Android > Kotlin' 카테고리의 다른 글

[Kotlin Coroutine] (3) - 코루틴 스코프과 컨텍스트(Dispatcher)  (0) 2023.06.02
[Kotlin Coroutine] (2) - 코루틴의 기본 개념  (0) 2023.06.01
Sealed Class  (0) 2023.05.26
Enum Class  (0) 2023.05.26
Abstract Class & Interface  (0) 2023.05.25
profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!