Kotlin(Java)을 사용하여 스레드풀의 성능을 비교해 보자
📌 서론
지난 포스트에서는 일반적인 스레드 환경의 I/O, CPU 스레드 성능을 비교해 봤다.
이번 포스트는 Thread 시리즈의 2번째 포스트로서 만약 스레드풀을 사용한다면 어떤 성능을 보여줄지 테스트해 보고 이전 결과와 비교해 볼 예정이다.
이 글을 확실하게 이해하기 위해 이전 포스트를 확인하는 것을 추천한다. 왜냐하면 이전 포스트에서 얻은 결과를 통해 이번 포스트에서 비교하기 때문이다.
이전 포스트 (일반 스레드 성능 비교)
1. 테스트 환경
환경 설명
언어 | Kotlin (Java21) |
프레임워크 | SpringBoot3.x.x |
라이브러리 | 코루틴(core, reactor) |
IDE | IntelliJ |
AI tool | ChatGPT 4o, Claude3.5 Sonnet |
마음가짐 | 호기심, 참을성, 노력, 반복 |
플러그인과 버전은 다음과 같다.
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.3.3"
id("io.spring.dependency-management") version "1.1.6"
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
build.gradle에 2개의 라이브러리를 추가했다. (코루틴)
// coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
깃허브 주소
2. 코드 작성 (멀티스레드: I/O, CPU 작업)
Tree구조
└── src
├── main
│ ├── kotlin
│ │ └── com
│ │ └── study
│ │ └── thread
│ │ ├── ThreadApplication.kt
│ │ └── study
│ │ ├── Common.kt
│ │ ├── RegularThread.kt (1탄 포스팅)
│ │ └── ThreadPool.kt
공통으로 사용하는 코드 (Common.kt)
- 상수, 함수를 정의해 둔 공통 사용 코드
package com.study.thread.study;
import kotlin.math.sin
// 테스트할 작업의 수
const val TEST_COUNT = 1000
const val THREAD_POOL_SIZE = 200
const val DELAY_MS = 10L // I/O 작업을 시뮬레이션하기 위한 지연 시간
// CPU 집중 작업을 시뮬레이션하는 함수 (모든 모델 공통)
fun simulateCPUIntensiveOperation() {
var result = 0.0
for (i in 1..1_000_000) {
result += sin(i.toDouble())
}
}
// 일반, 스레드 풀 작업 처리 결과를 출력하는 함수
fun printTestResult(result: TestResult) {
println("\n🧵 테스트 결과: ${result.testName}")
println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
println("📊 스레드 정보:")
println(" 🛫 시작 시 활성 스레드 수: ${result.initialThreadCount}")
println(" 🛬 종료 시 활성 스레드 수: ${result.finalThreadCount}")
println(" 📈 최대 동시 활성 스레드 수: ${result.maxActiveThreads}")
println(" 🔢 생성된 스레드 총 수: ${result.createdThreadsCount}")
println(" 🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: ${result.remainingThreadsCount}")
println("\n📊 작업 처리 정보:")
println(" ✅ 완료된 작업 수: ${result.completedTasks}")
println(" ⏱️ 평균 작업 시간: ${"%.2f".format(result.avgTaskTime)} ms")
println(" 🏎️ 최소 작업 시간: ${result.minTaskTime} ms")
println(" 🐢 최대 작업 시간: ${result.maxTaskTime} ms")
println("\n📊 성능 지표:")
println(" ⏳ 총 실행 시간: ${result.executionTime} ms")
println(" 🚀 1초당 처리된 작업 수: ${"%.2f".format(result.completedTasks.toDouble() / (result.executionTime / 1000.0))}")
if (result.additionalInfo.isNotEmpty()) {
println("\n📌 추가 정보:")
result.additionalInfo.forEach { (key, value) ->
println(" • $key: $value")
}
}
println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
}
// 일반, 스레드 풀 작업 처리 결과를 저장하는 데이터 클래스
data class TestResult(
val testName: String,
val initialThreadCount: Int,
val finalThreadCount: Int,
val maxActiveThreads: Int,
val createdThreadsCount: Int,
val remainingThreadsCount: Int,
val completedTasks: Int,
val executionTime: Long,
val avgTaskTime: Double,
val minTaskTime: Long,
val maxTaskTime: Long,
val additionalInfo: Map<String, String> = emptyMap()
)
// 활성화된 스레드 수를 계산하는 함수 (일반 , 스레드 풀)
fun countActiveThreads(createdThreads: Set<Long>): Int {
return Thread.getAllStackTraces().keys.count { it.id in createdThreads }
}
멀티 스레드 코드 작성 (ThreadPool.kt)
package com.study.thread.study
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.system.measureTimeMillis
fun main() {
val threadPoolIoResult = testIOIntensiveThreadPool()
printTestResult(threadPoolIoResult)
val threadPoolCpuResult = testCPUIntensiveThreadPool()
printTestResult(threadPoolCpuResult)
}
// 스레드 풀 I/O 집중 작업 테스트
fun testIOIntensiveThreadPool(): TestResult {
val initialThreadCount = Thread.activeCount()
val tasksCompleted = AtomicInteger(0)
val createdThreads = ConcurrentHashMap.newKeySet<Long>()
val maxActiveThreads = AtomicInteger(0)
val threadUsageCounts = ConcurrentHashMap<Long, AtomicInteger>()
val taskTimes = ConcurrentLinkedQueue<Long>()
val executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE)
val threadTime = measureTimeMillis {
val futures = List(TEST_COUNT) {
executor.submit {
val threadId = Thread.currentThread().id
createdThreads.add(threadId)
threadUsageCounts.computeIfAbsent(threadId) { AtomicInteger(0) }.incrementAndGet()
maxActiveThreads.updateAndGet { max -> maxOf(max, Thread.activeCount()) }
val taskStartTime = System.nanoTime()
simulateFileOperation("thread_file_$it.txt")
val taskEndTime = System.nanoTime()
taskTimes.add((taskEndTime - taskStartTime) / 1_000_000)
tasksCompleted.incrementAndGet()
}
}
futures.forEach { it.get() }
}
// 스레드 풀 종료 전 활성 스레드 수
val activeThreadsBeforeShutdown = countActiveThreads(createdThreads)
// 스레드 풀 종료
executor.shutdown()
executor.awaitTermination(1, TimeUnit.MINUTES)
// 스레드 풀 종료 후 활성 스레드 수
val activeThreadsAfterShutdown = countActiveThreads(createdThreads)
val finalThreadCount = Thread.activeCount()
val avgThreadUsage = threadUsageCounts.values.map { it.get() }.average()
val minThreadUsage = threadUsageCounts.values.map { it.get() }.minOrNull() ?: 0
val maxThreadUsage = threadUsageCounts.values.map { it.get() }.maxOrNull() ?: 0
return TestResult(
testName = "스레드 풀 I/O 집중 작업 테스트 (최대 $THREAD_POOL_SIZE 개)",
initialThreadCount = initialThreadCount,
finalThreadCount = finalThreadCount,
maxActiveThreads = maxActiveThreads.get(),
createdThreadsCount = createdThreads.size,
remainingThreadsCount = activeThreadsBeforeShutdown,
completedTasks = tasksCompleted.get(),
executionTime = threadTime,
avgTaskTime = taskTimes.average(),
minTaskTime = taskTimes.minOrNull() ?: 0,
maxTaskTime = taskTimes.maxOrNull() ?: 0,
additionalInfo = mapOf(
"스레드 풀 크기" to THREAD_POOL_SIZE.toString(),
"스레드 풀 종료 후 활성 스레드 수" to activeThreadsAfterShutdown.toString(),
"스레드당 평균 작업 수" to "%.2f".format(avgThreadUsage),
"스레드당 최소 작업 수" to minThreadUsage.toString(),
"스레드당 최대 작업 수" to maxThreadUsage.toString()
)
)
}
// 스레드 풀 CPU 집중 작업 테스트
fun testCPUIntensiveThreadPool(): TestResult {
val initialThreadCount = Thread.activeCount()
val tasksCompleted = AtomicInteger(0)
val createdThreads = ConcurrentHashMap.newKeySet<Long>()
val maxActiveThreads = AtomicInteger(0)
val threadUsageCounts = ConcurrentHashMap<Long, AtomicInteger>()
val taskTimes = ConcurrentLinkedQueue<Long>()
val executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE)
val threadTime = measureTimeMillis {
val futures = List(TEST_COUNT) {
executor.submit {
val threadId = Thread.currentThread().id
createdThreads.add(threadId)
threadUsageCounts.computeIfAbsent(threadId) { AtomicInteger(0) }.incrementAndGet()
maxActiveThreads.updateAndGet { max -> maxOf(max, Thread.activeCount()) }
val taskStartTime = System.nanoTime()
simulateCPUIntensiveOperation()
val taskEndTime = System.nanoTime()
taskTimes.add((taskEndTime - taskStartTime) / 1_000_000)
tasksCompleted.incrementAndGet()
}
}
futures.forEach { it.get() }
}
// 스레드 풀 종료 전 활성 스레드 수
val activeThreadsBeforeShutdown = countActiveThreads(createdThreads)
// 스레드 풀 종료
executor.shutdown()
executor.awaitTermination(1, TimeUnit.MINUTES)
// 스레드 풀 종료 후 활성 스레드 수
val activeThreadsAfterShutdown = countActiveThreads(createdThreads)
val finalThreadCount = Thread.activeCount()
val avgThreadUsage = threadUsageCounts.values.map { it.get() }.average()
val minThreadUsage = threadUsageCounts.values.map { it.get() }.minOrNull() ?: 0
val maxThreadUsage = threadUsageCounts.values.map { it.get() }.maxOrNull() ?: 0
return TestResult(
testName = "스레드 풀 CPU 집중 작업 테스트 (최대 $THREAD_POOL_SIZE 개)",
initialThreadCount = initialThreadCount,
finalThreadCount = finalThreadCount,
maxActiveThreads = maxActiveThreads.get(),
createdThreadsCount = createdThreads.size,
remainingThreadsCount = activeThreadsBeforeShutdown,
completedTasks = tasksCompleted.get(),
executionTime = threadTime,
avgTaskTime = taskTimes.average(),
minTaskTime = taskTimes.minOrNull() ?: 0,
maxTaskTime = taskTimes.maxOrNull() ?: 0,
additionalInfo = mapOf(
"스레드 풀 크기" to THREAD_POOL_SIZE.toString(),
"스레드 풀 종료 후 활성 스레드 수" to activeThreadsAfterShutdown.toString(),
"스레드당 평균 작업 수" to "%.2f".format(avgThreadUsage),
"스레드당 최소 작업 수" to minThreadUsage.toString(),
"스레드당 최대 작업 수" to maxThreadUsage.toString()
)
)
}
3. 스레드풀 이해하기
스레드풀의 구조를 알아보자
우선 스레드풀의 동작부터 이해하고 테스트를 진행하자 (스레드풀은 최대 200개로 가정)
- 스레드 종료
- 특정 시간(일반적으로 60초) 동안 유휴 상태인 추가 스레드는 완전히 종료된다. (스레드 풀의 관리 대상에서 제외)
- 종료된 스레드는 메모리에서 제거되고, 시스템 리소스가 해제된다.
- 코어 스레드 (10개로 가정)
- 코어 스레드 10개는 기본적으로 항상 유지된다 (설정에 따라 다를 수 있음).
- 코어 스레드 10개는 기본적으로 항상 유지된다 (설정에 따라 다를 수 있음).
- 새로운 요청 처리: 요청이 10개를 초과하면
- 먼저 유휴 상태의 코어 스레드를 사용한다.
- 모든 코어 스레드가 사용 중이면, 새로운 스레드를 생성한다. 이 새로 생성된 스레드는 스레드 풀에 추가되어 관리된다.
- 스레드 풀 동적 확장
- 요청이 증가함에 따라 필요한 만큼 새 스레드를 생성한다 (최대 200개까지).
- 각 새 스레드는 생성 시점부터 스레드 풀의 일부가 된다.
- 스레드 재사용 및 종료
- 스레드 풀의 스레드는 작업 완료 후 바로 종료되지 않고, 다음 작업을 위해 대기한다.
- 이 대기 상태가 특정 시간을 초과하면 스레드가 종료되고, 스레드 풀의 관리 대상에서 제외된다. (코어 스레드는 종료되지 않음)
- 코어 스레드 (Core Threads): 기본적으로 풀이 살아있는 한 계속 유지된다.
- 추가 스레드 (Non-core Threads): 특정 시간(일반적으로 60초) 동안 유휴 상태이면 종료된다.
- 동적 조절
- 스레드 풀은 요청량에 따라 스레드 수를 10개에서 200개 사이에서 동적으로 조절한다.
- 부하가 줄어들면 스레드 수도 점진적으로 감소한다.
예시 시나리오
- 시작 시: 10개의 코어 스레드가 존재한다.
- 부하 증가: 50개의 동시 요청이 들어오면, 40개의 추가 스레드가 생성된다 (총 50개).
- 부하 감소: 요청이 줄어들면, 추가된 40개의 스레드 중 일부가 유휴 상태가 된다.
- 시간 경과: 유휴 상태가 지속되면, 추가 스레드들이 하나씩 종료된다.
- 최종 상태: 충분한 시간이 지나면 다시 10개의 코어 스레드만 남게 된다.
- 새로운 부하: 다시 요청이 증가하면 필요에 따라 새 스레드를 생성한다.
스레드풀에 대해서 간단하게 이해했다면 테스트를 해보자!
4. 스레드풀 I/O, CPU 테스트 진행
테스트 설정은 이전 포스팅(일반 스레드)과 동일하게 진행한다.
- 다만 이번 테스트에서는 'THREAD_POOL_SIZE'를 추가적으로 사용한다. (스레드풀 크기 설정)
// 테스트할 작업의 수
const val TEST_COUNT = 1000
const val THREAD_POOL_SIZE = 200
const val DELAY_MS = 10L // I/O 작업을 시뮬레이션하기 위한 지연 시간
스레드풀을 사용해서 I/O 집중 작업 테스트를 진행해 보자
- 일반 스레드를 사용했던 코드와 다른 점은 newFixedThreadPool 메서드를 통해 executor를 생성해서 스레드풀을 활용한다는 점과 futures를 사용해서 작업을 진행한다는 점이다. (일반 스레드 코드는 최상단의 링크로 들어가서 확인해 볼 수 있다.)
val executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE)
val threadTime = measureTimeMillis {
val futures = List(TEST_COUNT) {
executor.submit {
val threadId = Thread.currentThread().id
createdThreads.add(threadId)
threadUsageCounts.computeIfAbsent(threadId) { AtomicInteger(0) }.incrementAndGet()
maxActiveThreads.updateAndGet { max -> maxOf(max, Thread.activeCount()) }
val taskStartTime = System.nanoTime()
# 파일 I/O를 시뮬레이션 한다.
simulateFileOperation("thread_file_$it.txt")
val taskEndTime = System.nanoTime()
taskTimes.add((taskEndTime - taskStartTime) / 1_000_000)
tasksCompleted.incrementAndGet()
}
}
futures.forEach { it.get() }
}
이번 코드의 핵심인 executor를 살펴보자
- Executors.newFixedThreadPool(THREAD_POOL_SIZE)는 고정 크기의 스레드 풀을 생성하는 메서드다.
- 여기서 THREAD_POOL_SIZE는 스레드 풀의 크기를 지정하는 상수로, 이 크기만큼의 스레드를 미리 생성하여 재사용한다.
- '고정 스레드 풀'은 '스레드 생성 및 종료'의 오버헤드를 줄이고, 자원을 효율적으로 사용하게 해 준다.
- '스레드 풀'이 처리할 수 있는 작업보다 더 많은 작업이 제출되면, 큐에 대기시켜 작업을 스레드가 처리할 수 있을 때까지 기다린다.
val executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE)
I/O 작업 함수를 살펴보자
- 위의 작업 코드를 자세히 살펴보면 매번 simulateFileOperation()을 호출한다.
simulateFileOperation("thread_file_$it.txt")
- 이 함수는 파일 작업을 시뮬레이션하는 함수다. 설정한(10L)만큼 스레드를 sleep 시킨다. (실제 파일을 읽어봐도 된다.)
// 파일 작업을 시뮬레이션하는 함수 (일반 함수)
fun simulateFileOperation(fileName: String) {
Thread.sleep(DELAY_MS) // I/O 작업 시뮬레이션
}
I/O 테스트 결과는 다음과 같다.
🧵 테스트 결과: 스레드 풀 I/O 집중 작업 테스트 (최대 200 개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 2
📈 최대 동시 활성 스레드 수: 202
🔢 생성된 스레드 총 수: 200
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 200
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 11.33 ms
🏎️ 최소 작업 시간: 10 ms
🐢 최대 작업 시간: 17 ms
📊 성능 지표:
⏳ 총 실행 시간: 78 ms
🚀 1초당 처리된 작업 수: 12820.51
📌 추가 정보:
• 스레드 풀 크기: 200
• 스레드 풀 종료 후 활성 스레드 수: 0
• 스레드당 평균 작업 수: 5.00
• 스레드당 최소 작업 수: 4
• 스레드당 최대 작업 수: 6
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
다음으로 스레드풀을 사용해서 CPU 집중 작업 테스트를 진행해 보자
val executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE)
val threadTime = measureTimeMillis {
val futures = List(TEST_COUNT) {
executor.submit {
val threadId = Thread.currentThread().threadId()
createdThreads.add(threadId)
threadUsageCounts.computeIfAbsent(threadId) { AtomicInteger(0) }.incrementAndGet()
maxActiveThreads.updateAndGet { max -> maxOf(max, Thread.activeCount()) }
val taskStartTime = System.nanoTime()
simulateCPUIntensiveOperation()
val taskEndTime = System.nanoTime()
taskTimes.add((taskEndTime - taskStartTime) / 1_000_000)
tasksCompleted.incrementAndGet()
}
}
futures.forEach { it.get() }
}
CPU 함수를 살펴보자
- sin 함수를 통해 cpu작업을 수행한다.
// CPU 집중 작업을 시뮬레이션하는 함수 (모든 모델 공통)
fun simulateCPUIntensiveOperation() {
var result = 0.0
for (i in 1..1_000_000) {
result += sin(i.toDouble())
}
}
CPU 테스트 결과는 다음과 같다.
🧵 테스트 결과: 스레드 풀 CPU 집중 작업 테스트 (최대 200 개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 2
📈 최대 동시 활성 스레드 수: 202
🔢 생성된 스레드 총 수: 200
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 200
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 55.36 ms
🏎️ 최소 작업 시간: 5 ms
🐢 최대 작업 시간: 431 ms
📊 성능 지표:
⏳ 총 실행 시간: 678 ms
🚀 1초당 처리된 작업 수: 1474.93
📌 추가 정보:
• 스레드 풀 크기: 200
• 스레드 풀 종료 후 활성 스레드 수: 0
• 스레드당 평균 작업 수: 5.00
• 스레드당 최소 작업 수: 2
• 스레드당 최대 작업 수: 14
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5. 스레드풀 I/O, CPU 테스트 결과 분석
스레드 정보 (동일한 개수)
- 두 테스트 모두 200개의 스레드를 생성했고, 최대 동시 활성 스레드 수는 202개로 동일하다. (멀티 스레드 제한 개수)
- 종료 시 활성 스레드 수는 I/O 작업과 CPU 작업 모두 2개로 동일하다.
- 테스트 후 남아있는 스레드 수도 두 경우 모두 200개로 동일하다.
작업 처리 정보 (I/O > CPU)
- 두 테스트 모두 1000개의 작업을 완료했다.
- I/O 작업의 평균 작업 시간(11.33ms)이 CPU 작업(55.36ms) 보다 여전히 훨씬 짧다.
- I/O 작업의 작업 시간 편차(10ms - 17ms)가 CPU 작업(5ms - 431ms) 보다 훨씬 작다.
성능 지표 (I/O > CPU)
- I/O 작업의 총 실행 시간(78ms)이 CPU 작업(678ms)보다 약 8.7배 짧다.
- I/O 작업의 초당 처리 작업 수(12820.51)가 CPU 작업(1474.93)보다 약 8.7배 높다.
분석
1. 동시성 처리
- I/O 작업이 여전히 더 효율적으로 동시성을 활용하고 있다. 이는 I/O 대기 시간 동안 다른 작업을 처리할 수 있기 때문이다.
- CPU 작업에서는 모든 스레드가 활성화되었지만, 실제 동시 처리는 CPU 코어 수에 제한된다.
2. 작업 시간 안정성
- I/O 작업은 매우 일정한 작업 시간을 보이는 반면, CPU 작업은 큰 변동을 보인다. 이는 CPU 경쟁과 컨텍스트 스위칭의 영향으로 보인다.
3. 전체 성능
- I/O 작업이 훨씬 빠른 총 실행 시간과 높은 처리량을 보인다. 이는 I/O 대기 시간을 효과적으로 활용했기 때문이다
4. 스레드 풀 관리
- 두 경우 모두 테스트 후 200개의 스레드가 남아있지만, I/O 작업에서 스레드 활용이 더 균형적이다.
결론
- 이 결과는 일반 스레드 실험과 동일하게 'I/O 집중 작업'과 'CPU 집중 작업'의 특성 차이를 명확히 보여준다. I/O 작업은 대기 시간을 효과적으로 활용하여 높은 동시성과 처리량을 달성하는 반면, CPU 작업은 실제 CPU 코어 수에 '처리량'이 제한되어 상대적으로 낮은 처리량을 보인다.
- I/O 작업의 경우, CPU 대기 시간을 다른 작업 처리에 활용할 수 있어 전체적인 처리량이 높다. 반면 CPU 작업은 각 작업이 지속적으로 CPU를 사용하므로, 동시에 처리할 수 있는 작업 수가 제한된다.
- 이는 '작업의 특성'에 따라 다른 동시성 처리 전략이 필요함을 다시 한번 확인해 준다. I/O 집중 작업에는 많은 수의 스레드를 활용하는 전략이 효과적일 수 있지만, CPU 집중 작업에는 CPU 코어 수에 맞춘 최적화된 스레드 풀 관리가 필요할 수 있다.
지금부터가 핵심이다!
시리즈 1에서 테스트한 일반 스레드와 이번에 테스트한 스레드풀의 결과를 비교해 보도록 하자.
6. 일반 스레드 vs 스레드풀 (I/O)
일반 스레드
🧵 테스트 결과: 일반 스레드 I/O 집중 작업 테스트
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 2
📈 최대 동시 활성 스레드 수: 443
🔢 생성된 스레드 총 수: 1000
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 0
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 11.44 ms
🏎️ 최소 작업 시간: 10 ms
🐢 최대 작업 시간: 23 ms
📊 성능 지표:
⏳ 총 실행 시간: 63 ms
🚀 1초당 처리된 작업 수: 15873.02
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
스레드풀 (200개)
🧵 테스트 결과: 스레드 풀 I/O 집중 작업 테스트 (최대 200 개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 2
📈 최대 동시 활성 스레드 수: 202
🔢 생성된 스레드 총 수: 200
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 200
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 11.33 ms
🏎️ 최소 작업 시간: 10 ms
🐢 최대 작업 시간: 17 ms
📊 성능 지표:
⏳ 총 실행 시간: 78 ms
🚀 1초당 처리된 작업 수: 12820.51
📌 추가 정보:
• 스레드 풀 크기: 200
• 스레드 풀 종료 후 활성 스레드 수: 0
• 스레드당 평균 작업 수: 5.00
• 스레드당 최소 작업 수: 4
• 스레드당 최대 작업 수: 6
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 스레드 생성 및 관리
- 일반 스레드: 1000개의 스레드를 생성하고 작업 후 모두 종료됨.
- 스레드 풀: 200개의 스레드만 생성하고 재사용함. 테스트 후에도 200개 유지.
2. 최대 동시 활성 스레드 수
- 일반 스레드: 443개
- 스레드 풀: 202개
- 스레드 풀이 스레드 수를 제한하여 시스템 리소스를 더 효율적으로 관리함.
3. 작업 처리 시간
- 평균 작업 시간은 두 경우 모두 비슷 (11.44ms vs 11.33ms)
- 최대 작업 시간은 일반 스레드에서 더 김 (23ms vs 17ms)
- 스레드 풀이 작업 시간을 더 일관되게 유지함.
4. 전체 성능
- 총 실행 시간: 일반 스레드가 더 빠름 (63ms vs 78ms)
- 초당 처리된 작업 수: 일반 스레드가 더 높음 (15873.02 vs 12820.51)
- 더 많은 스레드를 사용한 일반 방식이 약간 더 높은 처리량을 보임.
5. 리소스 관리
- 일반 스레드: 많은 스레드 생성/소멸로 오버헤드 발생 가능
- 스레드 풀: 제한된 수의 스레드로 리소스 관리 효율적
6. 작업 분배
- 스레드 풀에서는 작업이 스레드 간에 비교적 균등하게 분배됨 (4-6개/스레드)
결론
- 성능면에서는 일반 스레드 방식이 약간 우세하나, 차이가 크지 않음.
- 스레드 풀 방식이 리소스 관리와 작업 분배 면에서 더 효율적이고 안정적임.
- 실제 애플리케이션에서는 스레드 풀 방식이 더 바람직할 수 있음 (리소스 관리, 확장성, 안정성 측면).
- I/O 작업의 특성상 두 방식 모두 높은 동시성을 달성할 수 있음.
7. 일반 스레드 vs 스레드풀 (CPU)
일반 스레드
🧵 테스트 결과: 일반 스레드 CPU 집중 작업 테스트
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 2
📈 최대 동시 활성 스레드 수: 41
🔢 생성된 스레드 총 수: 1000
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 0
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 7.72 ms
🏎️ 최소 작업 시간: 5 ms
🐢 최대 작업 시간: 54 ms
📊 성능 지표:
⏳ 총 실행 시간: 694 ms
🚀 1초당 처리된 작업 수: 1440.92
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
스레드풀 (200개)
🧵 테스트 결과: 스레드 풀 CPU 집중 작업 테스트 (최대 200 개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 2
📈 최대 동시 활성 스레드 수: 202
🔢 생성된 스레드 총 수: 200
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 200
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 55.36 ms
🏎️ 최소 작업 시간: 5 ms
🐢 최대 작업 시간: 431 ms
📊 성능 지표:
⏳ 총 실행 시간: 678 ms
🚀 1초당 처리된 작업 수: 1474.93
📌 추가 정보:
• 스레드 풀 크기: 200
• 스레드 풀 종료 후 활성 스레드 수: 0
• 스레드당 평균 작업 수: 5.00
• 스레드당 최소 작업 수: 2
• 스레드당 최대 작업 수: 14
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 스레드 생성 및 관리
- 일반 스레드: 1000개의 스레드를 생성하고 작업 후 모두 종료됨.
- 스레드 풀: 200개의 스레드만 생성하고 재사용함. 테스트 후에도 200개 유지.
2. 최대 동시 활성 스레드 수
- 일반 스레드: 41개
- 스레드 풀: 202개
- 일반 스레드 방식에서 시스템이 효율적으로 관리할 수 있는 스레드 수를 보여줌.
3. 작업 처리 시간
- 평균 작업 시간: 일반 스레드가 더 짧음 (7.72ms vs 55.36ms)
- 작업 시간 범위: 일반 스레드 (5ms - 54ms), 스레드 풀 (5ms - 431ms)
- 일반 스레드 방식이 더 일관된 작업 시간을 보여줌.
4. 전체 성능
- 총 실행 시간: 비슷함 (일반 694ms vs 풀 678ms)
- 초당 처리된 작업 수: 거의 동일 (1440.92 vs 1474.93)
- 전체적인 성능은 두 방식이 매우 비슷함.
5. 리소스 관리
- 일반 스레드: 많은 스레드 생성/소멸로 오버헤드 발생 가능
- 스레드 풀: 제한된 수의 스레드로 리소스 관리 효율적
6. 작업 분배
- 스레드 풀에서 작업 분배가 불균형함 (2-14개/스레드)
- CPU 작업의 특성상 일부 스레드에 작업이 집중될 수 있음.
결론
- 성능면에서는 두 방식이 거의 동일한 결과를 보여줌.
- 일반 스레드 방식이 개별 작업 처리 시간에서 더 일관성 있는 결과를 보여줌.
- 스레드 풀 방식이 시스템 리소스 관리 면에서 더 효율적임.
- CPU 집중 작업의 특성상, 물리적 CPU 코어 수에 가까운 수의 스레드만 실제로 동시에 실행됨 (일반 스레드에서 41개 관찰).
- 스레드 풀에서 작업 분배가 불균형한 것은 CPU 작업의 특성과 스케줄링의 영향으로 보임.
음.. I/O, CPU 두 가지 비교 결과를 만들고 나니 나는 스레드풀의 개수를 늘리면 I/O작업만큼은 성능이 오를 것이라고 확신했다.
그래서 증명을 하고 싶어서 바로 스레드풀의 개수를 늘려서 테스트를 진행했다.
8. 스레드풀 개수를 늘려서 테스트 진행
이전과 테스트한 코드는 동일하다.
- 다만 스레드 풀 개수를 200 -> 500으로 늘렸다.
const val TEST_COUNT = 1000
const val THREAD_POOL_SIZE = 500
const val DELAY_MS = 10L // I/O 작업을 시뮬레이션하기 위한 지연 시간
I/O 테스트 결과는 다음과 같다.
🧵 테스트 결과: 스레드 풀 I/O 집중 작업 테스트 (최대 500 개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 51
📈 최대 동시 활성 스레드 수: 502
🔢 생성된 스레드 총 수: 500
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 500
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 11.43 ms
🏎️ 최소 작업 시간: 10 ms
🐢 최대 작업 시간: 22 ms
📊 성능 지표:
⏳ 총 실행 시간: 54 ms
🚀 1초당 처리된 작업 수: 18518.52
📌 추가 정보:
• 스레드 풀 크기: 500
• 스레드 풀 종료 후 활성 스레드 수: 64
• 스레드당 평균 작업 수: 2.00
• 스레드당 최소 작업 수: 1
• 스레드당 최대 작업 수: 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
wow! 예상했던 대로 스레드풀 최대 개수를 늘리니 I/O 작업에서 '78ms -> 54ms'로 성능향상이 있었다. I/O는 이전 테스트에서 400개 정도의 스레드가 동시에 사용되는 것을 보고 당연히 스레드풀을 그만큼 늘리면 성능이 좋아질 것이라고 생각했다.
다만 내가 좀 놀란 건 스레드풀을 늘려서 설정했을 뿐인데 이전의 '일반 스레드, 스레드풀 200개'이 성능을 모두 넘어섰다는 것이다.
'즉, 'CPU, I/O' 둘 중 어떤 작업인지 잘 분석해서 스레드 설정만 바꿔줘도 성능 향상이 있을 수도 있다는 것이다.'
I/O 테스트를 마쳤으니 이제 CPU 테스트 결과도 확인해 보자
나는 스레드풀을 늘린다고 해서 CPU 작업에서는 성능이 향상되지 않을 것이라고 생각했다. 왜냐하면 이전에 진행한 일반 스레드 테스트 결과를 보면 최대 스레드를 41개만 사용하는 것을 봤기 때문에 내가 스레드풀을 통해 200개를 사용하든 500개를 사용하든 성능 차이는 없을 것이라고 생각했다.
먼저 이전 I/O 작업을 했던 스레드풀을 500개로 늘린 상황의 CPU 작업 테스트 결과를 보자.
🧵 테스트 결과: 스레드 풀 CPU 집중 작업 테스트 (최대 500 개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 89
📈 최대 동시 활성 스레드 수: 502
🔢 생성된 스레드 총 수: 500
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 500
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 26.34 ms
🏎️ 최소 작업 시간: 5 ms
🐢 최대 작업 시간: 274 ms
📊 성능 지표:
⏳ 총 실행 시간: 709 ms
🚀 1초당 처리된 작업 수: 1410.44
📌 추가 정보:
• 스레드 풀 크기: 500
• 스레드 풀 종료 후 활성 스레드 수: 119
• 스레드당 평균 작업 수: 2.00
• 스레드당 최소 작업 수: 1
• 스레드당 최대 작업 수: 11
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
678ms -> 709ms 예상했던 대로 성능차이가 없는 것을 알 수 있다. 오히려 성능이 안 좋아졌다..
그럼 이번에는 스레드풀을 50까지 줄여서 테스트해 보자.
const val TEST_COUNT = 1000
const val THREAD_POOL_SIZE = 50
const val DELAY_MS = 10L // I/O 작업을 시뮬레이션하기 위한 지연 시간
- 테스트 결과는 다음과 같다. (스레드풀 50)
🧵 테스트 결과: 스레드 풀 CPU 집중 작업 테스트 (최대 50 개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 스레드 정보:
🛫 시작 시 활성 스레드 수: 2
🛬 종료 시 활성 스레드 수: 15
📈 최대 동시 활성 스레드 수: 52
🔢 생성된 스레드 총 수: 50
🔚 (일반, 스레드 풀) 테스트 후 남아있는 스레드 수: 50
📊 작업 처리 정보:
✅ 완료된 작업 수: 1000
⏱️ 평균 작업 시간: 31.53 ms
🏎️ 최소 작업 시간: 5 ms
🐢 최대 작업 시간: 233 ms
📊 성능 지표:
⏳ 총 실행 시간: 707 ms
🚀 1초당 처리된 작업 수: 1414.43
📌 추가 정보:
• 스레드 풀 크기: 50
• 스레드 풀 종료 후 활성 스레드 수: 17
• 스레드당 평균 작업 수: 20.00
• 스레드당 최소 작업 수: 15
• 스레드당 최대 작업 수: 27
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
스레드풀 200(678ms) -> 스레드풀 500(709ms) -> 스레드풀 50(707ms)
역시 별 차이 없다.
DevSisters의 테크 블로그글을 읽어보면 '일반적으로 CPU 작업을 위한 스레드 풀은 CPU 코어의 개수에 비례해서 스레드 개수를 고정으로 운용하는데, 만약 더 많은 스레드를 생성하면 Context Switching의 횟수가 증가하여 전체적인 성능 하락이 발생합니다.'라고 말한다. 이 말은 CPU작업에서 스레드를 많이 사용한다고 성능이 좋아지는 것이 아니라는 것이다.
아래 글을 읽어보면 스레드에 대한 이해를 할 때 큰 도움이 된다.
테스트 수를 1000 -> 10000으로 늘린 후 스레드풀을 늘려가며 성능 측정을 해봤더니 다음과 같은 결과를 얻었다.
테스트 수 | 스레드풀 개수 | 결과 (ms) |
10000 | 1000 | 6430 |
10000 | 2000 | 6511 |
10000 | 4000 | 6618 |
10000 | 6000 | 6700 |
10000 | 8000 | 6894 |
10000 | 10000 | 6990 |
스레드풀 개수가 늘어날수록 결과(ms)는 더 오래 걸리는 것을 알 수 있다. 이게 바로 위에서 말한 Context Switching의 횟수가 증가하여 전체적인 성능 하락이 발생하는 경우인 것 같다.
3탄에서는 일반 스레드 vs 스레드풀 성능 테스트를 해보도록 하자.
'스레드(Thread)' 카테고리의 다른 글
[Thread] 코루틴(Coroutine)의 동시성 제어 (0) | 2024.09.22 |
---|---|
[Thread] 코루틴(Coroutine)의 예외처리 (0) | 2024.09.22 |
[Thread] 4. Kotlin의 코루틴(Coroutine)이란? (1) | 2024.09.19 |
[Thread] 3. 일반 스레드 vs 스레드풀 (I/O, CPU 성능 비교) (2) | 2024.09.16 |
[Thread] 1. 일반 스레드의 I/O, CPU 성능 비교 (2) | 2024.09.15 |