배경

Java 21에서 소개된 가상 스레드(Virtual Thread)는 수많은 동시 작업을 처리할 수 있는 경량화된 스레드로, 고성능 동시성 애플리케이션을 개발하기 위한 중요한 전환점이 되었습니다.
가상 스레드는 JDK 자체 스케줄러를 통해 플랫폼 스레드에 마운트되었다가 필요에 따라 해제되면서 효율적인 리소스 관리를 지원합니다.

그러나 Java 21에서는 Virtual Thread Pinning(핀) 문제가 성능 개선에 영향을 미치고 있습니다. 특정 상황에서 가상 스레드가 플랫폼 스레드를 고정되어 가상 스레드의 주된 이점인 플랫폼 스레드 마운트 언마운트를 제한하는 케이스가 종종 있습니다.

 

왜 Pinning이 발생하나?

가상 스레드는 기본적으로 논블로킹 작업을 수행할 때 플랫폼 스레드에서 해제되어야 하지만, 특정 동기화 및 블로킹 작업 시 플랫폼 스레드에 고정되는 문제가 있습니다.

 

주요 발생 원인은 다음과 같습니다

1. synchronized 메서드와 Pinning

synchronized는 JVM 모니터(Monitor)를 사용하여 스레드 간 상호 배제를 보장합니다.

모니터란?

Java에서 모든 객체는 고유한 모니터(Monitor)를 가지고 있습니다.
모니터는 Java에서 스레드 동기화를 구현하는 핵심 메커니즘으로, 동기화 블록이나 동기화 메서드를 사용할 때 자동으로 생성됩니다. 특정 객체를 기반으로 스레드 간 상호 배제(Mutual Exclusion)상태 동기화(Condition Synchronization)를 제공하여 공유 자원의 안전한 접근을 보장합니다.
  • 동기화 블록: synchronized 키워드로 정의되는 동기화 블록은 객체의 모니터를 획득(acquire)하고 해제(release)하여 특정 코드 블록이나 메서드에 단일 스레드만 접근할 수 있도록 보장합니다.
  • wait/notify 메서드: 스레드가 모니터를 사용해 다른 스레드와 상태를 동기화할 수 있도록 합니다. (wait()는 잠금 해제 후 대기, notify()는 대기 중인 스레드 깨우기)

JVM은 모니터를 플랫폼 스레드 기준으로 관리합니다. 가상 스레드가 synchronized 메서드에 진입하면, 모니터의 소유권은 가상 스레드가 아니라 가상 스레드의 캐리어 플랫폼 스레드에 할당됩니다. 이 상태에서 가상 스레드가 I/O 등의 블로킹 작업을 수행하면 플랫폼 스레드는 해제되지 않고 고정(Pinned)됩니다.

synchronized void fetchData() {
    byte[] data = new byte[1024];
    socket.getInputStream().read(data); // 블로킹 작업
}

위 코드에서 read 메서드가 데이터를 대기하며 블로킹되면, 가상 스레드는 플랫폼 스레드에 고정되어 다른 가상 스레드를 처리하지 못하는 상태가 됩니다.

 

라이브러리 Pinning 사례

 

Hibernate/JPA

Spring Data JPA 3.3.0 버전에서 PartTreeJpaQuery.QueryPreparer#createQuery() 메서드의 synchronized 블록으로 인해 virtual thread pinning이 발생한다는 이슈가 보고되었습니다. 이 문제를 해결하기 위해 synchronized 블록을 ReentrantLock으로 교체하는 것이 제안되었습니다.

https://github.com/spring-projects/spring-data-jpa/issues/3505

 

[Virtual Threads] Possible Thread Pinning in `PartTreeJpaQuery.QueryPreparer#createQuery()` · Issue #3505 · spring-projects/sp

Version: Spring Data JPA 3.3.0. The method uses a synchronized block, causing thread pinning: spring-data-jpa/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpa...

github.com

 

HikariCP 

반면 HikariCP는 syncronized 블럭 사용으로 인한 pinning 문제를 해결하지 않기로 결정했습니다. 

Virtual Thread "pinning" 문제는 특정 조건에서 발생하는데, 이는 Virtual Thread가 synchronized 블록 내부에서 IO 작업 또는 블로킹 작업을 수행할 때 발생합니다. 하지만 HikariCP는 이러한 블로킹 작업을 synchronized 내부에서 수행하지 않습니다.

ReentrantLock을 사용하도록 변경하는 것은 Virtual Threads의 호환성을 위한 시도로 제안되었으나, HikariCP의 기존 synchronized 사용 방식에서 실질적인 성능 개선이나 문제 해결 효과가 거의 없을 가능성이 크며, ReentrantLock으로의 변경은 불필요한 오버헤드(추가 객체 생성 및 GC)를 초래할 수 있다고 Brett Wooldridge(HikariCP의 소유자)가 언급했습니다.

https://github.com/brettwooldridge/HikariCP/pull/2055

 

Add support for Virtual Threads by bdeneuter · Pull Request #2055 · brettwooldridge/HikariCP

It seems that synchronized will still pinn carrier threads in JDK 21 for the moment. This is the draft JEP for JDK21: https://openjdk.org/jeps/8303683 So I'm reoping the PR for using ReentrantL...

github.com

 

 

2. Object.wait()와 Pinning

Object.wait()는 동기화된 객체에서 대기 상태로 전환될 때 사용하는 메서드입니다.

작동 방식

  1. Object.wait()는 모니터를 소유한 상태에서 호출해야 합니다.
  2. 호출된 스레드는 대기 상태로 전환되며, 모니터를 해제.
  3. 다른 스레드가 Object.notify() 또는 Object.notifyAll()을 호출하면 대기 상태에서 깨어남.

Pinning이 발생하는 이유

  • wait 호출 중에도 가상 스레드는 플랫폼 스레드와 연결되어 있습니다.
  • 깨어난 후 다시 모니터를 재획득해야 하는데, 이 과정에서도 플랫폼 스레드가 고정됩니다.

 

Java 24 의 해결 방안 - JEP491

https://openjdk.org/jeps/491

Java 24의 JEP 491은 Virtual Thread Pinning 문제를 해결하기 위해 JVM 수준의 동기화 메커니즘을 대폭 개선했습니다. 이 개선은 가상 스레드가 synchronized 메서드, 블록, 또는 Object.wait() 호출 중에도 플랫폼 스레드에서 분리(Mount 해제)될 수 있도록 지원합니다. 이를 통해 가상 스레드가 블로킹 작업을 수행하는 동안 플랫폼 스레드가 유휴 상태로 고정되지 않게 되어 확장성이 크게 향상됩니다.

 

1. Pinning 문제의 핵심 원인

기존 JVM의 동작 방식

  • synchronized 키워드와 모니터(Monitor)
    • synchronized는 JVM 내부적으로 객체의 모니터를 활용하여 상호 배제를 보장합니다.
    • JVM은 특정 스레드(현재는 플랫폼 스레드)가 모니터를 소유하고 있음을 추적합니다.
    • 가상 스레드가 synchronized 메서드에 진입하면, JVM은 해당 가상 스레드의 캐리어 플랫폼 스레드를 모니터 소유자로 설정합니다.
    • 이 상태에서 가상 스레드가 블로킹 작업에 들어가더라도 플랫폼 스레드는 모니터와 연결된 상태로 고정됩니다.
  • Object.wait()
    • Object.wait()는 모니터를 소유한 상태에서 호출해야 합니다.
    • 호출된 스레드는 대기 상태로 전환되며 모니터를 일시적으로 해제합니다.
    • 그러나 JVM은 여전히 플랫폼 스레드를 대기 상태로 유지하므로, 플랫폼 스레드가 다른 작업에 재사용되지 못합니다.

 

2. JEP 491의 해결 방안

Java 24는 Pinning 문제를 해결하기 위해 다음과 같은 변경을 도입했습니다.

1) 모니터 소유권을 가상 스레드 기준으로 변경

  • 기존 JVM은 모니터 소유권을 플랫폼 스레드 기준으로 관리했지만, Java 24에서는 이를 가상 스레드 기준으로 관리합니다.
  • 가상 스레드가 synchronized 메서드에 진입하면
    • JVM은 해당 가상 스레드를 모니터 소유자로 설정합니다.
    • 플랫폼 스레드와는 독립적으로 모니터 소유권을 유지할 수 있습니다.
  • 이를 통해 가상 스레드가 블로킹 상태에 들어가더라도 플랫폼 스레드가 고정되지 않고 다른 가상 스레드에 재사용될 수 있습니다.

2) Object.wait()의 동작 개선

  • Object.wait() 호출 시
    • 가상 스레드는 플랫폼 스레드에서 Unmount됩니다.
    • 대기 상태가 끝나면 JVM 스케줄러는 가상 스레드를 새로운 플랫폼 스레드에 Mount하여 작업을 재개합니다.
  • 이를 통해 wait 대기 중에도 플랫폼 스레드가 유휴 상태로 고정되지 않습니다.

들어가며

스타크래프트2의 프로토스 종족에는 '시간 증폭'이라는 독특한 메커니즘이 있다. 연결체(Nexus)에서 프로브를 생산할 때 시간 증폭을 사용하면 일정 시간 동안 생산 속도가 빨라지는 기능이다. 이런 상태 관리와 비동기 처리가 필요한 시스템을 Kotlin의 Coroutine Flow를 활용해 구현해보자.

https://github.com/waterfogSW/starcraft-time-amplification

 

시스템 요구사항

  1. 연결체는 프로브를 생산할 수 있다
  2. 생산 큐는 최대 5개까지 가능
  3. 시간 증폭은 10초 동안 지속되며, 적용 시 생산 속도가 3배로 증가
  4. 생산 진행 상태를 실시간으로 관찰 가능
  5. 생산 중인 항목을 취소할 수 있음

 

연결체 프로브 생산 프로세스

 

1. 전통적인 방식 (Observer 패턴)

직관에 따라 Nexus(연결체) 를 구현한다면 Variable과 Observer 패턴을 사용하는 방식을 떠올릴 수 있다.

class Nexus {
    private var productionState: ProductionState = ProductionState.Idle
    private var probeCount: Int = 0
    private val observers = mutableListOf<ProductionObserver>()
    
    // 메모리 누수 위험이 있는 observer 등록/해제
    fun addObserver(observer: ProductionObserver) {
        observers.add(observer)
    }
    
    fun removeObserver(observer: ProductionObserver) {
        observers.remove(observer)
    }
    
    // 스레드 안전성을 위해 모든 메서드에 동기화 필요
    @Synchronized
    fun updateState(newState: ProductionState) {
        productionState = newState
        observers.forEach { it.onStateChanged(newState) }
    }
    
    // 여러 상태를 변경할 때 데드락 위험
    @Synchronized
    fun completeProduction() {
        productionState = ProductionState.Complete
        probeCount++
        observers.forEach { 
            it.onStateChanged(productionState)
            it.onProbeCountChanged(probeCount)
        }
    }
}

// Activity/Fragment에서 메모리 누수 발생 가능
class NexusActivity : Activity(), ProductionObserver {
    private val nexus = Nexus()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        nexus.addObserver(this) // 등록은 했지만...
    }
    
    // onDestroy에서 removeObserver를 호출하지 않으면 메모리 누수
}

전통적인 Observer 패턴 기반의 상태 관리 방식은 근본적인 문제점을 가지고 있다. 개발자가 직접 Observer를 등록하고 해제하는 과정에서 메모리 누수가 발생하기 쉽다. Activity나 Fragment의 생명주기와 Observer의 생명주기를 수동으로 동기화해야 하는데, 이는 실수하기 쉬운 작업이다.

멀티스레드 환경에서의 안전성도 보장하기 어렵다. 상태 변경 메소드마다 @Synchronized 어노테이션을 붙여야 하며, 이는 성능 저하를 일으킨다. 여러 상태를 동시에 변경할 때는 데드락이 발생할 위험도 있다. 결과적으로 복잡한 보일러플레이트 코드가 생기고 유지보수가 어려워진다.

 

2. LiveData

그럼 LiveData는 어떤가?, LiveData는 이러한 문제를 상당 부분 해결한다. 생명주기를 자동으로 관리하여 메모리 누수를 방지하고, 메인 스레드 안전성을 보장한다. Observer 등록과 해제를 수동으로 관리할 필요가 없어졌다.

class Nexus {
    // 안드로이드 플랫폼 종속적
    private val _productionState = MutableLiveData<ProductionState>()
    val productionState: LiveData<ProductionState> = _productionState
    
    private val _probeCount = MutableLiveData<Int>()
    val probeCount: LiveData<Int> = _probeCount
    
    // 메인 스레드에서만 값 변경 가능
    fun updateState(newState: ProductionState) {
        _productionState.value = newState
    }
    
    // 빠른 연속 업데이트 처리 불가능
    fun startFastUpdates() {
        viewModelScope.launch {
            repeat(1000) { // 빠른 업데이트 발생
                _productionState.value = ProductionState.Producing(it / 1000f)
                delay(16) // 16ms마다 업데이트
                // 백프레셔 처리 메커니즘 부재로 프레임 드롭 발생
            }
        }
    }
}

그러나 LiveData는 안드로이드 플랫폼에 종속되어 있어 서버사이드에서는 사용할 수 없다. 역시 순수 Kotlin 모듈에서는 사용이 불가능하며, 이로 인해 멀티플랫폼 개발과 테스트가 제한된다.

더 큰 문제는 연속적인 데이터 업데이트 처리에 취약하다는 점이다. 센서 데이터와 같이 빠르게 들어오는 데이터 스트림을 처리할 때 데이터 손실이 발생하거나 UI 성능이 저하된다. 이는 LiveData가 백프레셔 처리 메커니즘을 가지고 있지 않기 때문이다.

 

3. Coroutine Flow

Flow는 이전 방식들의 문제를 해결하고 더 강력한 기능을 제공한다.

class Nexus {
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
    
    // 플랫폼 독립적이며 상태 관리가 용이한 StateFlow
    private val _productionState = MutableStateFlow<ProductionState>(ProductionState.Idle)
    val productionState = _productionState.asStateFlow()
    
    private val _probeCount = MutableStateFlow(0)
    val probeCount = _probeCount.asStateFlow()
    
    // 백프레셔가 적용된 생산 프로세스
    fun startProduction() = flow {
        var progress = 0f
        while (progress < 1f) {
            progress += 0.1f
            emit(progress) // 백프레셔 자동 적용
            delay(16)
        }
    }.buffer(Channel.BUFFERED) // 생산자-소비자 분리
     .catch { e -> 
        _productionState.value = ProductionState.Idle
        emit(0f)
    }
    
    // 복잡한 상태 조합도 쉽게 처리
    val combinedState = combine(
        productionState,
        probeCount
    ) { state, count ->
        CombinedState(state, count)
    }.stateIn(scope, SharingStarted.Lazily, CombinedState())
    
    // 스레드 안전한 상태 업데이트
    fun updateState(newState: ProductionState) {
        _productionState.value = newState // 자동으로 스레드 안전
    }
    
    fun shutdown() {
        scope.cancel() // 리소스 정리도 간단
    }
}

// UI에서의 사용 (Compose)
@Composable
fun NexusScreen(nexus: Nexus) {
    val state by nexus.productionState.collectAsState()
    
    LaunchedEffect(Unit) {
        nexus.startProduction()
            .collect { progress ->
                // 백프레셔로 인해 UI는 버벅임 없이 부드럽게 업데이트
            }
    }
}

// 순수 Kotlin 모듈에서도 사용 가능
class PureKotlinModule {
    private val nexus = Nexus()
    
    fun processData() {
        // Flow는 플랫폼 독립적이므로 사용 가능
        nexus.productionState
            .map { ... }
            .filter { ... }
            .collect { ... }
    }
}

이처럼 Flow와 StateFlow는 이전 방식들의 한계를 모두 해결하고, 코루틴과의 자연스러운 통합, 백프레셔 지원, 플랫폼 독립성 등 다양한 이점을 제공한다. 특히 상태 관리에 있어서 스레드 안전성과 메모리 관리가 자동으로 이루어지며, 복잡한 비동기 처리도 간단하게 구현할 수 있다.

 

Flow와 StateFlow

Flow의 기본 개념

Flow는 코틀린에서 비동기적으로 계산될 수 있는 데이터 스트림을 나타내는 타입이다. Flow는 다음과 같은 특징을 가진다:

// Flow의 기본 구조
val flow = flow {
    for (i in 1..3) {
        delay(100) // 비동기 작업 시뮬레이션
        emit(i)    // 값 방출
    }
}

// Flow 수집
scope.launch {
    flow.collect { value ->
        println(value)
    }
}

Flow의 주요 특징

  1. Cold Stream: Flow는 collect를 호출할 때만 데이터를 방출한다
  2. 순차적 실행: 기본적으로 순차적으로 처리된다
  3. 취소 가능: 코루틴의 취소 메커니즘을 지원한다
  4. 백프레셔 지원: 데이터 생산과 소비 속도를 조절할 수 있다

 

StateFlow 이해하기

StateFlow는 Flow의 특별한 형태로, 항상 값을 가지고 있는 상태 홀더다. 우리의 연결체 구현에서 중요한 역할을 한다

// StateFlow 기본 예제
private val _state = MutableStateFlow(초기값)
val state: StateFlow<T> = _state.asStateFlow()

// 값 업데이트
_state.value = 새로운값
// 또는
_state.update { currentValue -> 
    // 새로운 값을 계산하고 반환
}

StateFlow의 특징

  1. Hot Stream: 수집하는 코루틴이 없어도 활성 상태를 유지
  2. 상태 보유: 항상 현재 값을 가짐
  3. 중복 제거: 동일한 값은 방출하지 않음
  4. 다중 구독자 지원: 여러 수집기가 동시에 값을 관찰할 수 있음

 

생산 프로세스, 시간 증폭 프로세스 구현

앞서 본 내용들을 바탕으로 연결체의 생산 프로세스와 시간 증폭 프로세스를 구현해 보자.

생산 프로세스

fun startProduction() {
    if (_productionQueue.value.size >= 5) return

    scope.launch {
        // 큐에 새 항목 추가
        _productionQueue.update { it + ProbeQueueItem() }

        // 생산이 진행중이지 않은 경우에만 시작
        if (_productionState.value is ProductionState.Idle) {
            startProductionProcess()
        }
    }
}

private fun startProductionProcess() {
    productionJob = scope.launch {
        while (_productionQueue.value.isNotEmpty()) {
            var accumulatedProgress = 0f
            var lastUpdateTime = System.currentTimeMillis()

            // 진행률 업데이트 루프
            while (accumulatedProgress < 1f) {
                val currentTime = System.currentTimeMillis()
                val deltaTime = currentTime - lastUpdateTime
                lastUpdateTime = currentTime

                // 시간 증폭 상태 확인
                val isCurrentlyBoosted = _chronoBoostState.value.isActive
                val progressIncrement = calculateProgress(deltaTime, isCurrentlyBoosted)
                
                accumulatedProgress = (accumulatedProgress + progressIncrement)
                    .coerceAtMost(1f)

                // 상태 업데이트
                updateProductionState(ProductionState.Producing(accumulatedProgress))
                updateQueueProgress(accumulatedProgress)
                
                delay(16) // ~60fps
            }

            // 생산 완료 처리
            completeProduction()
        }
    }
}

 

시간 증폭 프로세스

동일한 메커니즘으로 시간 증폭도 구현할 수 있다.

fun applyChronoBoost() {
    chronoBoostJob = scope.launch {
        updateChronoBoostState(
            ChronoBoostState(
                isActive = true,
                remainingTimeMillis = CHRONOBOOST_DURATION
            )
        )
        
        val startTime = System.currentTimeMillis()
        while (true) {
            val remainingTime = CHRONOBOOST_DURATION - (System.currentTimeMillis() - startTime)
            if (remainingTime <= 0) break
            
            updateChronoBoostState(
                _chronoBoostState.value.copy(remainingTimeMillis = remainingTime)
            )
            delay(100)
        }
    }
}

시간 증폭은 별도의 코루틴에서 관리되며, 상태를 통해 생산 속도에 영향을 준다. 생산 프로세스는 지속적으로 시간 증폭 상태를 확인하며 생산 속도에 반영해 결정하게 된다.

 

서버사이드에서의 응용

앞서 살펴본 Flow와 StateFlow를 활용한 생산 및 시간 증폭 프로세스 구현은 서버사이드 애플리케이션에서도 효과적으로 응용할 수 있다. 서버 환경에서는 다수의 클라이언트 요청을 효율적으로 처리하고, 실시간 상태 관리를 통해 시스템의 성능과 안정성을 유지하는 것이 중요하다. Kotlin의 Coroutine과 Flow는 이러한 요구사항을 충족시키는 강력한 도구를 제공한다.

실시간 게임 서버에서는 플레이어의 상태 업데이트, 게임 내 이벤트 처리, 자원 관리 등이 빈번하게 발생한다. Flow와 StateFlow를 활용하면 각 플레이어의 상태 변화를 비동기적으로 처리하고, 서버 자원의 효율적인 분배를 통해 높은 동시성을 유지할 수 있다. 예를 들어, 플레이어의 행동에 따른 자원 생산 속도를 조절하거나, 특정 이벤트 발생 시 일시적으로 생산 속도를 증가시키는 시간 증폭 메커니즘을 구현할 수 있다.

또한 실시간 데이터 스트리밍 이 필요한 금융 거래 시스템, IoT 데이터 수집, 실시간 분석 등 고속의 데이터 스트림을 처리해야 하는 서버 애플리케이션에서 Flow는 자연스러운 선택이 될 수 있다. 백프레셔 지원을 통해 데이터 생산자와 소비자 간의 속도 차이를 조절할 수 있으며, 상태 관리 기능을 통해 현재 데이터 처리 상태를 실시간으로 모니터링할 수 있다. 이는 데이터 손실을 방지하고 시스템의 안정성을 높이는 데 기여한다.

다음 코드의 실행 결과를 한번 예측해보자.

fun main() {
    val a: Int? = 128
    val b: Int? = 128
    println(a === b)

    val c: Int? = 1
    val d: Int? = 1
    println(c === d)
}

이 코드의 실행 결과는 false, true이다. 이러한 결과가 나오는 원인은 Kotlin(그리고 그 기반이 되는 Java)의 정수 객체 캐싱 메커니즘에 있다. 본 글에서는 이 현상의 원인과 그 배경에 있는 객체 캐싱 메커니즘에 대해 상세히 분석한다.

 

1. Java의 Autoboxing과 객체 캐싱

Java에서는 기본 타입(primitive type)과 그에 대응하는 래퍼 클래스(wrapper class)가 존재한다. 예를 들어, int에 대응하는 래퍼 클래스는 Integer이다. Java 5부터 도입된 Autoboxing 기능은 기본 타입과 래퍼 클래스 간의 자동 변환을 지원한다.

Integer a = 100; // Autoboxing: int -> Integer
int b = a; // Unboxing: Integer -> int

Java는 성능 최적화를 위해 특정 범위의 정수값에 대해 객체 캐싱을 수행한다. 기본적으로 -128부터 127까지의 정수값에 대해서는 미리 객체를 생성하여 캐시에 저장한다.

다음 Java 코드를 통해 이 동작을 확인할 수 있다:

public class IntegerCacheTest {
    public static void main(String[] args) {
        Integer a = 127;
        Integer b = 127;
        System.out.println(a == b); // true

        Integer c = 128;
        Integer d = 128;
        System.out.println(c == d); // false

        int e = 128;
        int f = 128;
        System.out.println(e == f); // true
    }
}

이 코드에서 a == b는 true를 반환하지만, c == d는 false를 반환한다. 이는 127이 캐시 범위 내에 있어 같은 객체를 참조하지만, 128은 캐시 범위를 벗어나 새로운 객체가 생성되기 때문이다. 반면 e == f는 기본 타입 int의 비교이므로 true를 반환한다.

 

2. Kotlin에서의 정수 비교

Kotlin은 Java의 이러한 특성을 기반으로 하면서도, 몇 가지 추가적인 특징을 제공한다. Kotlin에서 === 연산자는 참조 동등성을 비교한다. 즉, 두 객체가 메모리상에서 같은 객체인지를 확인한다.

앞서 제시한 Kotlin 코드의 결과를 분석하면 다음과 같다:

  1. 1은 -128에서 127 사이의 값이므로 캐시된 객체를 사용한다. 따라서 c와 d는 같은 객체를 참조하게 되어 true가 반환된다.
  2. 128은 캐시 범위를 벗어나는 값이므로 새로운 객체가 생성된다. 따라서 a와 b는 서로 다른 객체를 참조하게 되어 false가 반환된다.

 

3. Java의 래퍼 클래스와 캐싱

Java에서 이러한 캐싱 메커니즘은 여러 래퍼 클래스에 적용된다:

  • Boolean: true와 false
  • Byte: 모든 값 (-128 to 127)
  • Short: -128 to 127
  • Integer: -128 to 127 (기본값, 변경 가능)
  • Long: -128 to 127
  • Character: 0 to 127

다음 Java 코드를 통해 다양한 래퍼 클래스의 캐싱 동작을 확인할 수 있다:

public class WrapperCacheTest {
    public static void main(String[] args) {
        Boolean bool1 = true;
        Boolean bool2 = true;
        System.out.println("Boolean: " + (bool1 == bool2)); // true

        Byte byte1 = 127;
        Byte byte2 = 127;
        System.out.println("Byte: " + (byte1 == byte2)); // true

        Short short1 = 127;
        Short short2 = 127;
        System.out.println("Short: " + (short1 == short2)); // true

        Integer int1 = 127;
        Integer int2 = 127;
        System.out.println("Integer: " + (int1 == int2)); // true

        Long long1 = 127L;
        Long long2 = 127L;
        System.out.println("Long: " + (long1 == long2)); // true

        Character char1 = 127;
        Character char2 = 127;
        System.out.println("Character: " + (char1 == char2)); // true
    }
}

 

4. -XX:AutoBoxCacheMax 옵션

Java (그리고 Kotlin)에서는 -XX:AutoBoxCacheMax JVM 옵션을 통해 Integer 캐시의 최대값을 조정할 수 있다. 기본값은 127이지만, 이를 변경하여 더 큰 범위의 정수에 대해서도 캐싱을 적용할 수 있다.

java -XX:AutoBoxCacheMax=1000 YourProgram

이 옵션을 사용하면 지정된 값까지의 Integer 객체가 캐시되어, 해당 범위 내의 정수 비교 시 == 연산자 (Java) 또는 === 연산자 (Kotlin)가 true를 반환하게 된다.

다음은 이 옵션을 적용한 Java 프로그램의 예시이다:

public class AutoBoxCacheMaxTest {
    public static void main(String[] args) {
        Integer a = 1000;
        Integer b = 1000;
        System.out.println(a == b); // true (if -XX:AutoBoxCacheMax=1000 is set)
    }
}

 

5. Kotlin의 특징

Kotlin은 Java의 이러한 특성을 기반으로 하면서도, 몇 가지 추가적인 특징을 제공한다:

  1. == 연산자: Kotlin에서 ==는 구조적 동등성을 비교한다. 이는 내부적으로 .equals() 메소드를 호출하는 것과 동일하다.
  2. === 연산자: 참조 동등성을 비교한다. Java의 ==와 유사한 역할을 한다.

다음 Kotlin 코드를 통해 이러한 특징을 확인할 수 있다:

fun main() {
    val a: Int? = 128
    val b: Int? = 128
    println(a == b)  // true (구조적 동등성)
    println(a === b) // false (참조 동등성)

    val c: Int? = 127
    val d: Int? = 127
    println(c == d)  // true
    println(c === d) // true (캐시된 객체)
}

 

6. 주의사항

  1. 이 캐싱 메커니즘은 성능 최적화를 위한 기능이므로, 코드의 정확성을 이 동작에 의존해서는 안 된다.
  2. Java에서 값 비교를 위해서는 .equals() 메소드를 사용하는 것이 안전하다. Kotlin에서는 == 연산자를 사용하면 된다.
  3. 이 캐싱 메커니즘은 Integer/Int 외의 다른 숫자 타입(Byte, Short, Long 등)에도 적용되지만, 범위가 다를 수 있다.
  4. Float와 Double은 캐싱되지 않는다.

 

7. 결론

Java와 Kotlin에서의 정수 비교는 표면적으로는 단순해 보이지만, 내부적으로 복잡한 메커니즘이 작동하고 있다. 객체 캐싱은 성능 최적화를 위한 중요한 기능이지만, 개발자는 이에 의존하기보다는 항상 명확하고 안전한 비교 방법을 사용해야 한다.

이러한 내부 동작을 이해함으로써, 더 효율적이고 버그 없는 코드를 작성할 수 있다. 또한, 이는 Java와 Kotlin의 내부 동작 방식에 대한 깊이 있는 이해를 제공하여, 더 나은 프로그래밍 실력 향상에 기여할 수 있다.

마지막으로, 이러한 세부사항을 알고 있는 것은 중요하지만, 일반적인 애플리케이션 개발에서는 == (Kotlin) 또는 .equals() (Java)를 사용하여 값을 비교하는 것이 가장 안전하고 명확한 방법임을 인지해야 한다.

우리는 종종 Primitive type이 도메인 객체를 모델링 하기에는 충분한 정보를 제공하지 못하기에 VO(Value Object)를 정의합니다.

이때 primitive 타입을 wrapping해 VO를 정의하곤 하는데, 추가적인 힙 할당으로 인한 런타임 오버헤드가 발생합니다. Primitive타입은 런타임에 최적화 되어있지만, data class로의 wrapping으로 인해 primitive의 성능 최적화를 의미없게 만듭니다. 이러한 문제를 해결하기 위해, Kotlin은 inline value class고 불리는 특별한 종류의 클래스를 제공합니다.

Kotlin의 value 클래스는 JDK 15부터 도입된 record 클래스의 특성을 가져와서, 불변성(immutability)과 데이터 홀딩(data holding)에 최적화되어 있습니다. value 클래스는 주로 다음과 같이 간결한 구문으로 VO 클래스를 정의하는데 사용됩니다.

@JvmInline 
value class Password(private val s: String)

val securePassword = Password("Don't try this in production")

위와 같이 inline value class를 통해 VO를 정의하게 되면 객체 초기화시에 검증 로직을 수행할 수도 있고, 래핑된 primitive type이 런타임에서는 기저에 있는 타입으로 컴파일되어 추가적인 힙 할당으로 인한 런타임 오버헤드가 발생하지 않기도 합니다.

하지만 항상 기저 타입으로 컴파일되지는 않습니다

코틀린 공식문서를 확인하면 다음과 같이 기저타입이 사용되지 않는 경우들에 대해 설명하고 있습니다.

interface I  

@JvmInline  
value class Foo(val i: Int) : I  

fun asInline(f: Foo) {}  
fun <T> asGeneric(x: T) {}  
fun asInterface(i: I) {}  
fun asNullable(i: Foo?) {}  

fun <T> id(x: T): T = x  

fun main() {  
    val f = Foo(42)  

    asInline(f)    // unboxed: used as Foo itself  
    asGeneric(f)   // boxed: used as generic type T  
    asInterface(f) // boxed: used as type I  
    asNullable(f)  // boxed: used as Foo?, which is different from Foo  

    // below, 'f' first is boxed (while being passed to 'id') and then unboxed (when returned from 'id')    // In the end, 'c' contains unboxed representation (just '42'), as 'f'    val c = id(f)  
}

1. asInline(f):

  • asInline 함수는 인라인 벨류 클래스 타입의 매개변수를 받도록 선언됩니다.
  • 컴파일러는 f를 직접 사용하여 기저 타입인 Int 값에 접근합니다.
  • 따라서 boxing/unboxing 없이 값을 효율적으로 처리할 수 있습니다.

2. asGeneric(f):

  • asGeneric 함수는 제네릭 타입 T를 매개변수로 받습니다.
  • 컴파일러는 Foo 인스턴스를 T 타입으로 변환해야 하기 때문에 boxing이 발생합니다.

3. asInterface(i):

  • asInterface 함수는 I 인터페이스 타입의 매개변수를 받습니다.
  • FooI 인터페이스를 구현하지만, 컴파일러는 여전히 Foo 인스턴스를 I 타입으로 변환해야 하기 때문에 boxing이 발생합니다.

4. asNullable(f):

  • asNullable 함수는 널 가능한 Foo 타입의 매개변수를 받습니다.
  • Foo는 기본 타입이 아닌 참조 타입이기 때문에 널 가능합니다.
  • asNullable 함수는 f가 null인지 확인하고, null이 아닌 경우 boxing을 수행합니다.
  • 결과적으로 asNullable 함수는 널 가능한 Int 값을 널 가능한 Integer 객체로 감싼 형태로 받게 됩니다.

5. id(f):

  • id 함수는 제네릭 타입 T를 매개변수로 받고, 그 타입의 값을 그대로 반환하는 항등 함수입니다.
  • fid 함수에 전달하면 컴파일러는 Foo 인스턴스를 T 타입으로 변환해야 하기 때문에 boxing이 발생합니다.
  • id 함수는 반환 값으로 T 타입을 요구하기 때문에, 반환하기 전에 boxing된 값을 unboxing합니다.
  • 결과적으로 id(f)Int 값을 반환합니다.

 

JPA Entity에서의 Value class 사용

저의 경우, 4번 nullable한 value class를 사용할때 기저타입이 사용되지 않는 현상을 경험했었습니다.

@Entity  
@Table(name = "`user`")  
class UserJpaEntity(  
    ...
    height: Height? = null,
    ...
) {  

    ...

    @Column(nullable = true, updatable = true, columnDefinition = "integer")  
    var height: Height? = height  
        private set

    ...  

}

@JvmInline  
value class Height(val value: Int) {  

    init {  
        require(value in 1..300) {  
            "키는 1cm 이상 300cm 이하여야 합니다."  
        }  
    }  

}

User 엔티티는 Height 라는 속성을 value class로 설정해주었는데, 이때 Height는 nullable하게 다뤄야 하는 속성이었기에 ?로 nullable한 타입임을 명시해 두었습니다.

이때 IDE에서 다음과 같은 오류를 보여주었습니다.

그대로 실행하게 되면 애플리케이션 실행시 Hibernate에서 다음과 같은 에러를 발생시키며 애플리케이션이 종료되었습니다

Caused by: org.hibernate.type.descriptor.java.spi.JdbcTypeRecommendationException: Could not determine recommended JdbcType for Java type 'com.studentcenter.weave.domain.user.vo.Height'

이러한 문제의 원인을 찾기위해 바이트 코드로 변환한 후 자바 코드로 디컴파일을 해보았는데, 결과는 다음과 같이 기저 타입이 아닌 Height Type을 사용하고 있었습니다.

@Column(  
   nullable = true,  
   updatable = true,  
   columnDefinition = "integer"  
)  
@Nullable  
private Height height;

이후 kotlin코드를 nullable하지 않게 해두고 실행해 보았는데, 기저타입으로 컴파일 된것을 확인할 수 있었습니다.

@Column(  
   nullable = true,  
   updatable = true,  
   columnDefinition = "integer"  
)  
private int height;

이러한 이슈로 인해 value class가 항상 기저타입으로 컴파일되어 런타임에 사용되지는 않음을 확인할 수 있었습니다.

결론

value class의 사용은 도메인 객체 모델링에 도움을 주고 애플리케이션 로직을 직관적으로 파악할 수 있게 도움을 준다는 장점이 있지만, 외부 인프라 레이어에서 사용하기에는 애매한 부분이 있다고 생각이 들었습니다.

deserializing시 리플렉션을 통해 초기화 되어 value class의 init 블럭에 있는 validation이 수행되지 않는다던가, nullable한 value class사용으로 인해 jpa entity에서 원치않는 타입이 사용되는 문제등 외부 인프라 연동시 발생하는 문제점들이 존재했습니다.

이러한 문제를 해결하기 위해 application layer에서는 value class를 적극적으로 사용하되, 외부 인프라 레이어에서는 unboxing해 기저 타입을 사용하도록 컨벤션을 두었습니다.

제네릭(Generic)은 데이터 타입을 파라미터로 만들어, 여러 데이터 타입에 대해 작동하는 메소드나 클래스를 만들 수 있게 하는 기능이다.

대표적인 컬렉션타입인 List는 타입파라미터 T를 갖는다. List라는 타입이 있을때 String이라는 타입 인자를 사용하여 List<String>이라 정의하면 String객체를 담기위한 리스트라고 말할 수 있다.

Java와 다른점은 Kotlin에서는 반드시 타입 인자를 명시하거나 컴파일러가 추론할 수 있도록 해주어야 한다는 점이다.

Java

List list = new ArrayList();
list.add("hello");
Integer integer = (Integer) list.get(0); // Throws ClassCastException at runtime

Java에서는 raw type을 허용하기 때문에 타입인자를 명시하지 않고 사용이 가능하지만, 런타임시점에 예외가 발생할 수 있다.

Kotlin

val list = ArrayList() // Compile error in Kotlin, as type inference goes for ArrayList<Nothing>
list.add("hello")
val integer: Int = list[0] // Wouldn't even compile

동일한 케이스에서 Kotlin의 경우 타입 인자를 명시하지 않으면 컴파일 시점에 에러가 발생한다.

왜 Java에서는 Raw타입을 허용할까?

Java에서 제네릭이 도입된 것은 2004년에 출시된 Java 5부터이다. 그 이전에는 제네릭이 존재하지 않았기 때문에, 특정 컬렉션에는 모든 종류의 객체를 담을 수 있었고, 런타임에 ClassCastException을 일으키는 상황이 만들어 졌기에 제네릭이 도입되었다.

하지만 제네릭이 도입된 이후에도 Java는 호환성을 유지하기 위해 raw 타입을 사용할 수 있게 남겨두었다.

 

변성(Variance) : 공변과 불공변

제네릭하면 빠질수 없는 개념이 변성이다. 변성은 List<String>과 List<Any>와 같이 기저타입이 같고 타입 인자가 다른 타입이 어떤 관계가 있는지 설명하는 개념이다.

불공변(Invariance)

다음의 코드를 살펴보자

open class Fruit
class Apple : Fruit()
class Orange : Fruit()

fun addFruit(fruitBasket: MutableList<Fruit>) {
    fruitBasket.add(Orange())
    fruitBasket.add(Apple())
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    addFruit(appleBasket) //Compile Error: Type mismatch, MutableList<Apple> cannot be assigned to MutableList<Fruit>
    addFruit(mutableListOf())
}

Apple은 Fruit을 상속하고 있지만 MutableList<Fruit> 타입의 파라미터를 요구하는 자리에는 MutableList<Apple>타입의 객체를 인자로 넘겨줄 수 없다. 이렇게 타입 파라미터가 상속관계에 있지만, 그 상속관계가 제네릭 타입에 영향을 주지 않는것을 불공변(Invariance)이라고 한다.

불공변(Invariance)

  • Apple은 Fruit의 하위타입이다 → True
  • List<Apple>은 List<Fruit>의 하위타입이다 → False

왜 불공변으로 만들어서 직관적으로 이해하기 어렵게 만들어 두었을까?

예를 들어 MutableList<Apple>을 MutableList<Fruit>로 사용할 수 있도록 허용한다고 가정해보자.

fun addFruit(fruitBasket: MutableList<Fruit>) {
    fruitBasket.add(Orange())  // Apple의 MutableList에 Orange를 추가하는 상황
}

val appleBasket: MutableList<Apple> = mutableListOf(Apple())
addFruit(appleBasket)  // mutableList<Fruit>는 Apple의 리스트를 허용하도록 허용

addFruit은 Basket에 어떤 종류의 Fruit라도 넣을 수 있으므로, Orange가 appleBasket에 추가될 수 있다. 그렇지만 appleBasket은 명시적으로 Apple만을 저장하기 위해 구현을 해두었기때문에 안정성을 깨뜨릴 수 있다.

따라서 Kotlin은 제네릭을 불공변으로 기본 설정한다. 이를 통해 부적절한 타입 변환으로 인한 런타임 에러를 방지하고, 안정성을 강화한다.

물론 개발자의 의도에 맞게 공변성(covariant), 반공변성(contravariant)을 지정할 수도 있게 개발자에게 선택의 폭을 제공한다.

 

공변(Covariant)과 반공변(Contravariant)

앞서 언급했듯 Kotlin에서는 out 키워드를 사용하여 공변(covariance)을, in 키워드를 사용하여 반공변(contravariant)을 지정할 수 있다.

공변(Covariance)

Kotlin에서 out 키워드를 통해 타입 인자를 공변(covariant)으로 지정할 수 있다.

즉 S가 T의 하위타입일때, Class<S>가 Class<T>의 하위타입임을 나타낸다.

open class Fruit {
    open fun name() : String {
        return "Fruit"
    }
}

class Apple : Fruit() {
    override fun name() : String {
        return "Apple"
    }
}

class Orange : Fruit() {
    override fun name() : String {
        return "Orange"
    }
}

fun printFruit(fruitBasket: MutableList<out Fruit>) {
    for (fruit in fruitBasket) {
        println(fruit.name())
    }
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())

    printFruit(appleBasket)  // Allowed, prints "Apple"
    printFruit(orangeBasket)  // Allowed, prints "Orange"
}

여기서 printFruit 함수는 MutableList<out Fruit>을 인자로 받아 그 안에 있는 과일의 이름을 출력한다. out 키워드가 있기 때문에 MutableList<Apple>과 MutableList<Orange>을 MutableList<out Fruit>에 할당할 수 있다.

즉, 함수 내부에서 fruitBasket: MutableList<out Fruit>은 Fruit을 생산하는 역할만 수행하게 되며, 이를 통해 Apple과 Orange 모두 출력할 수 있게 된다.

눈치빠른 분들은 눈치 채셨을수도 있겠지만, MutableList<out Fruit>대신 List<Fruit>를 사용해도 위의 코드는 정상동작한다.

fun printFruit(fruitBasket: List<Fruit>) {
    for (fruit in fruitBasket) {
        println(fruit.name())
    }
}

실제 List 인터페이스 코드를 살펴보면 out키워드가 붙어있는것을 확인할 수 있다. Kotlin에서 List는 불변컬렉션으로 생산자로서의 역할을 수행하기 때문에 위와 같이 사용할 수 있는것이다.

public interface List<out E> : Collection<E> {
    ...


조금더 나아가서, 여기 fruitBasket에 Apple을 추가하는 함수를 작성한다 가정하자.

fun addBasketApple(fruitBasket: MutableList<out Fruit>) {
    fruitBasket.add(Apple()) //Type mismatch
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())
    addBasketApple(appleBasket)
}

main함수 호출시에는 공변으로 인해 문제가 없지만, 메서드 내에서 인자를 소비하는 과정에서 Type mismatch 컴파일 에러가 발생한다.

쓰기시에는 fruitBasket이 MutableList<Apple>인지, MutableList<Orange>인지 알 수 없기때문에 코틀린에서는 Type mismatch 컴파일 에러를 발생시키기 때문이다.

그러면 MutableList<Apple>의 상위타입이 인자로 들어와야함을 명시해줄순 없는걸까?

반공변(Contravariant)

Kotlin에서 in 키워드를 통해 타입 인자를 반공변(cotravariant)으로 지정할 수 있다.

즉 S가 T의 상위타입일때, Class<S>가 Class<T>의 상위타입임을 나타낸다.

fun addBasketApple(fruitBasket: MutableList<in Apple>) {
    fruitBasket.add(Apple())
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())
    addBasketApple(appleBasket)
    addBasketApple(orangeBasket) // Error
}

위와 같이 fruitBasket: MutableList<in Apple>에서 in 키워드를 통해 MutableList<Apple>의 상위타입 객체만 전달되도록 명시할 수 있다.

메서드 밖에서는 MutableList<Apple>의 상위타입이 아닌 MutableList<Orange>타입의 객체를 전달하면 컴파일 오류를 발생시킨다.

때문에 메서드 내에서는 MutableList<Orange>일 가능성을 배제하고 안전하게 인자를 소비할 수 있다.

이처럼 생산자(Producer)시나리오에서는 out 키워드를 사용한 공변성을 이용하며, 소비자(Consumer)시나리오에서는 in 키워드를 사용한 반공변성을 이용한다. - Producer In Consumer Out

이를 Java에서는 'PECS(Producer Extends, Consumer Super)' 원칙이라고도 부른다.

 

PECS(Producer Extends, Consumer Super)

PECS는 "Producer Extends, Consumer Super"의 약자로 Joshua Bloch이 Effective Java에서 제네릭에서 공변성(covariance)와 반공변성(contravariance)을 사용할 방향을 제안한 원칙이다.

https://stackoverflow.com/questions/2723397/what-is-pecs-producer-extends-consumer-super

PE (Producer Extends)

  • "extends" 키워드는 공변성(covariance)을 의미한다.
  • 데이터를 "생산"하는 경우에 사용된다.

CS (Consumer Super)

  • "super" 키워드는 반공변성 (contravariant)을 의미한다.
  • 데이터를 "소비"하는 경우에 사용된다.

Suvivor 영역이 2개여서 얻는 이점이 뭐가있을까?

https://docs.oracle.com/en/java/javase/17/gctuning/hotspot-virtual-machine-garbage-collection-tuning-guide.pdf

 

HotSpot VM 의 Minor GC의 동작과정

1. Eden 영역에 새로 생성된 객체들을 할당한다.

2. Eden 영역이 꽉차면 Minor GC를 수행한다.
  - Eden 영역에서 할당된 객체 중 Minor GC에서 살아남은(참조되고있는) 객체들은 Survivor 영역중 하나로 이동한다.

  - Survivor영역에서 일정 Minor GC횟수만큼 살아남은 객체들은 Old Generation으로 이동한다. 
3. 이후 다시 Minor GC가 발생하면, Eden 영역뿐만 아니라 Survivor 영역에 존재하는 객체 중 아직도 참조되는 객체들을 다른 Survivor 영역으로 이동한다. 

 

Survivor 영역이 1개일 경우

Survivor영역이 1개일 경우 Old Generation으로 이동한 객체들에 의해 Survivor영역은 메모리가 파편화 된 상태로 남게 된다.

이러한 문제를 해결하기 위해서는

Minor GC가 발생할 경우 파편화된 공간 사이로 Eden영역에서 살아남은 객체들을 이동시키거나, 기존 Survivor공간의 객체들을 특정 공간으로 이동시켜 메모리 파편화를 막아야 한다.

 

Survivor 영역이 2개일 경우

Minor GC가 일어나면 다른 Survivor 영역으로 객체를 복사하고, 기존 Survivor 영역을 초기화하여 메모리를 해제하는 과정을 반복하여 메모리 파편화 문제를 방지한다.

+ Recent posts