코루틴vs스레드: 동시성 처리 토너먼트

코루틴vs스레드: 동시성 처리 토너먼트 #


매치 소개 #

코루틴이 “경량 스레드"라고 불리는 이유는 무엇일까? 과연 정말로 가벼울까? Java/Kotlin 동시성 처리 역사상 가장 치열한 대결을 해보려고한다.
메모리 효율성, CPU 사용률, 확장성, 응답성 4개 라운드에 걸쳐 펼쳐지는 완전한 승부를 통해 진정한 동시성 처리의 챔피언을 가릴 예정이다.

선수 소개 #

스레드 선수

  • 정체성: OS 레벨의 무거운 실행 단위
  • 특기: 각자 독립적인 스택 공간 확보
  • 약점: 메모리 사용량이 작업 개수에 정비례

코루틴 선수

  • 정체성: JVM 레벨의 경량 실행 단위
  • 특기: 상태머신 기반의 스마트한 메모리 관리
  • 약점: 복잡한 내부 구조

라운드 1 : 스레드 스택 vs 코루틴 객체 메모리 사용량 대결 #

이번 라운드에서는 메모리 사용량 관점에서 두 선수의 진짜 실력을 검증해보자.
1000개의 동시 작업을 처리할 때, 전통적인 스레드와 코루틴이 시스템 메모리를 어떻게 사용하는지 VisualVM을 통해 적나라하게 비교 분석해보겠다

매치 조건 #

작업량: 1000개의 동시 대기 작업 측정 도구: VisualVM 메모리 프로파일러 측정 항목: 힙 메모리, 스택 메모리, 활성 스레드 수 대기 시간: 30초 (충분한 측정 시간 확보)

대결 조건 #

  • 작업량: 1000개의 동시 대기 작업
  • 측정 도구: VisualVM 메모리 프로파일러
  • 측정 항목: 힙 메모리, 스택 메모리, 스레드 수

스레드 선수의 전략 #

1000개의 OS스레드 생성방식

fun main() {
    repeat(1000) { i ->
        Thread {
            Thread.sleep(30000)// 30초 대기
            println("Thread $i done")
        }.start()
    }

    Thread.sleep(35000)// 측정을 위한 대기
}

f9bef220-6029-4cc8-886e-d0b1aaca2915

메모리 사용량

  • 활성 스레드: 1000개의 OS 레벨 스레드가 모두 생성됨
  • 힙 메모리: Thread 객체 생성으로 인한 상당한 힙 사용량 증가
  • 스택 메모리: 각 스레드마다 독립적인 스택 공간 할당
    • 기본 스택 크기: 1MB per thread
    • 총 예상 사용량: 1MB × 1000개 = 약 1GB
  • 시스템 부하: 높은 컨텍스트 스위칭 비용

코루틴 선수의 전략 #

1000개의 코루틴 생성 방식

fun main() = runBlocking {
    repeat(1000) { i ->
        launch {
            delay(30000)// 30초 대기
            println("Coroutine $i done")
        }
    }

    delay(35000)// 측정을 위한 대기
}

1d9f408b-6726-45a7-9513-6e3dc2cf0f8b

메모리 사용량

  • 워커 스레드: Dispatchers.Default가 CPU 코어 수 × 2 정도의 스레드 풀 사용 (약 8-16개)
  • 힙 메모리: 상태머신 기반의 경량 객체 사용으로 안정적인 메모리 사용량
  • 스택 메모리: 별도의 스택 메모리 불필요
    • 상태는 힙의 객체로 관리
    • 총 예상 사용량: 스레드 풀 크기 × 1MB = 약 8-16MB
  • 시스템 부하: 최소한의 컨텍스트 스위칭

메모리 사용 패턴 비교 #

항목 전통적 스레드 방식 코루틴 방식
스레드 수 작업 개수만큼 OS 스레드 생성 (예: 1000개 작업 → 1000개 스레드) 내부적으로 적은 수의 스레드(CPU 코어 × 2 등)에서 수천 개 코루틴 실행
스택 메모리 -Xss로 설정된 만큼 각 스레드마다 별도의 스택 메모리 확보→ 1000개 스레드 × 1MB = 약 1GB 코루틴은 스택 사용 안 함. 상태는 객체로 저장되며, 필요한 최소 메모리만 사용
힙 메모리 각 스레드에서 캡처하는 객체, ThreadLocal, Runnable 등으로 인해 객체가 힙에 쌓임 코루틴은 상태머신 객체가 힙에 적재되지만 매우 작고 가볍다
스레드 생성 비용 OS 수준의 스레드 생성 → 컨텍스트 스위칭 비용 큼 사용자 수준의 경량 스케줄링 → 컨텍스트 스위칭 없음
스케일링 수천 개 이상 처리 시 OutOfMemory 또는 시스템 부하 증가 수십만 개까지도 안전하게 동시 실행 가능 (e.g. 10만 개 launch)

🏆 라운드 1 결과 🏆 #

측정항목 스레드 선수 코루틴 선수 승자
활성 스레드 수 1,001개 15개 코루틴
스택 메모리 ~1GB ~16MB 코루틴
힙 메모리 증가 상당한 증가 안정적 코루틴
메모리 효율성 낮음 높음 코루틴

🏅라운드 1 승자 -> 코루틴 (압도적 승리)
코루틴이 메모리 사용량에서 60배 이상의 효율성을 보여주며 첫 번째 라운드를 가져갔다. 1000개 작업 처리에 겨우 16MB만 사용하는 놀라운 성능을 선보였다.


라운드 2 : CPU 사용량 대결 #

이번 라운드에서는 CPU 사용량 관점에서 어떤 선수가 CPU 리소스에 집중 투여 되는지 검증해보려고 한다.
1000개의 동시 작업을 처리할 때, 전통적인 스레드와 코루틴이 시스템 메모리를 어떻게 사용하는지 IntelliJ Profiler을 통해 비교 분석해보겠다

매치 조건 #

작업량: 1000개의 동시 CPU 집약적 작업 측정 도구: IntelliJ Profiler 측정 항목: CPU 사용률, 컨텍스트 스위칭 횟수, 스케줄링 오버헤드 측정 시간: 30초간 지속 모니터링

대결 조건 #

  • 작업량: 1000개의 소수 찾기 + 수학 계산 동시 작업
  • 측정 도구: IntelliJ 프로파일러
  • 측정 항목: CPU 사용량
  • 작업 복잡도: 각 작업당 1000회 반복 계산 (소수 찾기 + 복합 수학 연산)

스레드 선수의 전략 #

1000개의 OS스레드 CPU 집약적 작업

fun main() {
    repeat(1000) { i ->
        Thread {
            repeat(1000) { j ->
                val x = j.toDouble()
                val mathResult = Math.sqrt(Math.pow(x, 2.0) + Math.pow(x + 1, 2.0)) *
                        Math.log(x + 1) / Math.exp(x / 1000.0)

                // 소수 찾기
                var primeCount = 0
                for (k in (j * 100)..(j * 100 + 100)) {
                    if (isPrime(k)) primeCount++
                }

                // JIT컴파일러 최적화 방지
                if (mathResult + primeCount > Double.MAX_VALUE) {
                    println("Overflow detected")
                }
            }
            Thread.sleep(100) 
            println("Thread $i calculation done")
        }.start()
    }

    Thread.sleep(300000) // 30초
}

77af612e-7b2e-4efc-b6e5-dc2a0e306529

CPU 사용량 분석

  • 컨텍스트 스위칭: 1000개의 OS 스레드 간 빈번한 컨텍스트 스위칭으로 인한 오버헤드 발생
  • 스케줄링 부하: OS 레벨 스케줄러가 1000개 스레드를 관리하느라 과부하 상태
  • CPU 코어 활용: 물리적 CPU 코어 수를 초과하는 스레드들이 강제로 CPU를 점유하여 빠른 처리
  • 스레드 경합: 동시에 실행되려는 1000개 스레드 간의 CPU 자원 경쟁으로 리소스 대비 효율성 저하
  • 시스템 안정성: 높은 CPU 사용률로 인해 시스템 전체 반응성 저하

코틀린 선수의 전략 #

fun main() = runBlocking {
    repeat(1000) { i ->
        launch {
            repeat(1000) { j ->
                // 복합 수학 계산 (동일)
                val x = j.toDouble()
                val mathResult = Math.sqrt(Math.pow(x, 2.0) + Math.pow(x + 1, 2.0)) *
                        Math.log(x + 1) / Math.exp(x / 1000.0)

                // 소수 찾기 (동일)
                var primeCount = 0
                for (k in (j * 100)..(j * 100 + 100)) {
                    if (isPrime(k)) primeCount++
                }

                if (mathResult + primeCount > Double.MAX_VALUE) {
                    println("Overflow detected")
                }
            }
            delay(100) // 주기적 양보
            println("Coroutine $i calculation done")
        }
    }

    delay(30000) 
}

f6b0e9c8-ff23-4b76-9561-424dfe8b060b

CPU 사용량 분석

  • 최대 CPU 사용률: 34%로 안정적 유지
  • 컨텍스트 스위칭: 적은 수의 워커 스레드(~16개) 간의 최소한의 컨텍스트 스위칭
  • 스케줄링 부하: 코루틴 디스패처가 효율적으로 작업을 분배하여 스케줄링 오버헤드 최소화
  • CPU 코어 활용: 물리적 CPU 코어 수에 최적화된 스레드 풀로 효율적인 자원 활용
  • 협력적 멀티태스킹: delay() 호출을 통한 자발적 양보로 다른 코루틴에게 실행 기회 제공
  • 시스템 안정성: 낮은 CPU 사용률로 시스템 전체가 안정적이며 다른 프로세스에도 충분한 여유 제공

CPU 사용 패턴 비교 #

항목 전통적 스레드 방식 코루틴 방식
CPU 사용률 최대 96% (시스템 과부하 수준) 최대 34% (안정적 수준)
컨텍스트 스위칭 1000개 OS 스레드 간 빈번한 스위칭으로 높은 오버헤드 16개 내외 워커 스레드 간 최소한의 스위칭
스케줄링 방식 OS 레벨 프리엠티브 스케줄링 (강제적) 사용자 레벨 협력적 스케줄링 (자발적)
CPU 코어 활용 물리 코어 수 초과로 인한 비효율적 경쟁 CPU 코어 수에 최적화된 효율적 활용
리소스 사용 패턴 스레드별 독립적인 메모리 공간 사용 공유된 스레드 풀에서 메모리 공간 재사용
시스템 영향 다른 프로세스 성능에 악영향 시스템 리소스에 최소 영향

🏆 라운드 2 결과 🏆 #

🏅라운드 2 승자 -> 코루틴 (완벽한 승리)
코루틴이 CPU 사용률에서 65% 이상의 효율성을 보여주며 두 번째 라운드도 가져갔다. 동일한 작업량을 처리하면서도 시스템 안정성을 유지하는 놀라운 성능을 선보였다.

잠깐! 실행 시간의 역설 #

실험 결과에서 흥미로운 현상이 발견되었다:

  • 스레드: 0.1초만에 완료
  • 코루틴: 0.3초 소요

“어? 코루틴이 더 느린데 왜 승리자일까?”

실행 시간 차이의 핵심 원인 #

스레드 방식 (0.1초)

// 1000개 스레드가 **동시에** 모든 CPU 코어를 점유
Thread {
    // CPU 집약적 작업을 병렬로 수행
    repeat(1000) { /* 계산 작업 */ }
    Thread.sleep(100) // 논블로킹
}.start()
  • 진정한 병렬 처리: 1000개 스레드가 모든 CPU 코어에서 동시에 실행
  • CPU 코어 초과 사용: 물리 코어 수를 초과하는 스레드들이 강제로 CPU를 점유
  • 컨텍스트 스위칭 비용을 감수하고라도 최대한 빠르게 처리

코루틴 방식 (0.3초)

launch {
    repeat(1000) { /* 계산 작업 */ }
    delay(100) // 다른 코루틴에게 양보!
}
  • 제한된 병렬성: CPU 코어 수 × 2 정도의 스레드 풀에서만 실행 (~16개)
  • 협력적 스케줄링: delay() 호출 시 다른 코루틴에게 실행권 양보
  • 순차적 처리 측면: 1000개 코루틴이 16개 스레드에서 돌아가면서 실행

결론: 효율성을 위한 의도적인 설계 #

코루틴이 더 오래 걸리는 이유는 “지속 가능한 성능을 위한 의도적인 설계” 때문이다

  • 시스템 리소스 안정성 우선: CPU를 독점하지 않고 다른 프로세스와 협력
  • 메모리 효율성: 무제한 스레드 생성 대신 제한된 스레드 풀 사용
  • 장기적 성능: 단기 속도보다 지속 가능한 성능 추구

실제 프로덕션 환경에서는 이런 트레이드오프가 매우 중요하다. 0.2초 차이보다는 시스템이 안정적으로 동작하고 메모리를 효율적으로 사용하며, 다른 애플리케이션에 영향을 주지 않는 것이 훨씬 가치있다!