들어가며

스타크래프트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. 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는 자연스러운 선택이 될 수 있다. 백프레셔 지원을 통해 데이터 생산자와 소비자 간의 속도 차이를 조절할 수 있으며, 상태 관리 기능을 통해 현재 데이터 처리 상태를 실시간으로 모니터링할 수 있다. 이는 데이터 손실을 방지하고 시스템의 안정성을 높이는 데 기여한다.

 

결론

Kotlin의 Coroutine과 Flow는 비동기 처리와 상태 관리를 효율적으로 구현할 수 있는 강력한 도구를 제공한다. 특히 서버사이드 애플리케이션에서는 높은 동시성과 실시간 처리가 요구되는 다양한 상황에서 Flow와 StateFlow의 장점을 극대화할 수 있다. 시간 증폭과 같은 복잡한 비동기 메커니즘을 손쉽게 구현하고, 안정적인 상태 관리를 통해 시스템의 성능과 신뢰성을 높일 수 있다. 따라서, 서버 개발자들은 Flow와 StateFlow를 적극적으로 활용하여 더욱 효율적이고 확장 가능한 서버 애플리케이션을 개발할 수 있을 것이다.

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

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 영역을 초기화하여 메모리를 해제하는 과정을 반복하여 메모리 파편화 문제를 방지한다.

👻 Java Restdocs코드

junit5로-계층-구조의-테스트-코드-작성하기 - 기계인간 John Grib글을 보고 난 후 계층형으로 테스트 코드를 작성해 보았는데, 테스트 결과가 한눈에 파악하기 좋게 트리구조로 출력되어서 다음과 같은 DCI패턴의 계층형 테스트를 선호하게 되었다.

@Nested
@DisplayName("POST")
class DescribePOST {

  @Nested
  @DisplayName("유효한 요청이 전달되면")
  class ContextWithValidRequest {
    setAuthenticationHolder(1L, Role.USER);
    final var requestBody = new BoardCreateRequest("Test", "Test");
    final var jsonBody = objectMapper.writeValueAsString(requestBody);

    final var request = RestDocumentationRequestBuilders
          .post(URL)
          .contentType(MediaType.APPLICATION_JSON)
          .content(jsonBody);

    @Test
    @DisplayName("201 응답")
    void ItResponse201() throws Exception {
      final var response = mockMvc.perform(request);

      response.andExpect(status().isCreated())
              .andDo(document("Create Board", requestFields(
                  fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                  fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
              )));
    }

  }

 

하지만 간단하게 주석으로 다음과 같은 스타일의 테스트 코드를 작성하는 것에 비해 @Nested어노테이션을 매번 붙여주어야 하고, 클래스명들도 정의해 주어야 해서 테스트 작성시간이 오래 걸린다는 생각이 들었다.

@Test
void test() {
  //given
  ...
  //when
  ...
  //then
  ...
}

물론 인텔리제이 Live template을 활용해 모든코드를 일일이 작성하지는 않았지만 테스트 코드 외 불필요한 코드들이 여전히 많은 수의 라인을 차지하고 있어 테스트 코드에 온전히 집중하기 힘들었다.

 

이러한 고민을 하던 찰나에 Kotest에서는 DCI패턴의 테스팅 스타일을 지원한다는 이야기를 듣고 찾아보게 되었다.

👍 왜 Kotest인가

  • Kotlin DSL 을 활용한 다양한 테스팅 스타일을 제공한다
  • Kotest는 Junit과 호환된다

테스팅 스타일
Kotest에는 여러 언어의 테스트프레임워크로부터 영감을 받은 테스팅 스타일을 제공한다.

Java - Junit

@Nested
@DisplayName("...")
class Describe... {

  @Nested
  @DisplayName("...")
  class ContextWith... {

    @Test
    @DisplayName("...")
    void It...("...") {
      ...
    }

  }

}

Kotlin - Kotest

describe("...") {
  context("...") {
    it("...") {
        ...
    }
  }
}

Kotlin DSL 을 활용하니 테스트 코드가 훨씬 눈에 잘 들어온다. 또 Java에서 DCI패턴을 활용할 때는 매번 클래스 명과 메서드 명을 정의해 주어야 해서 불편한 점이 있었는데 Kotest를 활용하니 이러한 점이 사라져서 좋았다.

그렇다면 이를 활용해서 기존 Junit 테스트 코드를 Kotest 테스트 코드로 리펙토링 해보자.

♻️ 리펙토링

테스트 하려는 클래스는 컨트롤러 계층의 BoardRestController클래스로 서비스 계층에 대해 다음과 같은 의존성을 갖고 있었다.

public class BoardRestController {

  private final BoardCommandService boardCommandService;
  private final BoardQueryService boardQueryService;

기존 테스트 코드는 @MockBean을 통해 두 서비스 클래스를 모킹 하고 있었는데, Koltin에서 역시 동일하게 사용가능하므로 다음과 같이 테스트를 작성해 주었다.

class BoardRestControllerTest(
  @MockBean
  private val commandService: BoardCommandService,
  @MockBean
  private val queryService: BoardQueryService,
  ...

이후 DCI 테스팅 스타일을 활용해 Restdocs코드까지 다음과 같이 작성해 주었다. @Nested와 불필요한 클래스 선언 코드들이 사라지니 조금 더 깔끔해진 느낌이다.

describe("POST : /api/v1/boards") {
  val url = "/api/v1/boards"
  context("유효한 요청이 전달 되면") {
    val authentication = Authentication(1L, Role.USER)
    AuthenticationContextHolder.setAuthentication(authentication)

    val requestBody = BoardCreateRequest("Test", "Test")
    val requestJson = mapper.writeValueAsString(requestBody)
    val request = request(HttpMethod.POST, url)
        .contentType(MediaType.APPLICATION_JSON)
        .content(requestJson)

    it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDo(document("Create Board", requestFields(
                   fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                   fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
          )))
    }
  }

하지만 restDocs와 관련된 코드들이 거슬렸다. andDo(docuemnt(… 같은 형태의 메서드 호출은 반복되고 fieldWithPath... 같은 형태의 체이닝 메서드는 매번 자동 포매팅을 할 때마다 이상하게 정렬되고 한눈에 잘 들어오지 않는다는 생각이 들었다.

✚ 더 개선하기

JAVA에서는 Standard Library나 외부 라이브러리를 사용하는 경우 새로운 함수를 추가하기 어렵다. 하지만 코틀에서는 Extension functions(확장 함수)을 활용하면 기존에 정의된 클래스에 함수를 손쉽게 추가할 수 있다.

 

우선 andDo() 메서드를 가지고 있는 클래스에 코틀린 확장함수로 andDocument() 함수를 다음과 같이 구현하여 반복되는. andDo(document(... 호출을 개선하였다.

fun ResultActions.andDocument(
  identifier: String,
  vararg snippets: Snippet
): ResultActions {
  return andDo(document(identifier, *snippets))
}
it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDocument("Create Board", requestFields(
                   fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                   fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
          )))
    }

다음으로 restdocs코드의 경우 infix function(중위함수)와 문자열 extension functions(확장함수)를 통해 개선할 수 있었다.

우선 description과 optional 과같은 descriptor의 체이닝 메서드들을 중위함수로 처리할 수 있도록 RestDocField를 구현하였다.

class RestDocField(
  val descriptor: FieldDescriptor
) {

  infix fun isOptional(value: Boolean): RestDocField {
    if (value) descriptor.optional()
    return this
  }

  infix fun isIgnored(value: Boolean): RestDocField {
    if (value) descriptor.ignored()
    return this
  }

  infix fun means(value: String): RestDocField {
    descriptor.description(value)
    return this
  }

  infix fun attributes(block: RestDocField.() -> Unit): RestDocField {
    block()
    return this
  }
}

그리고 문자열을 통해 descriptor를 정의할 수 있도록 문자열에 대한 확장함수를 구현하였다.

infix fun String.fieldType(
  docsFieldType: DocsFieldType
): RestDocField {
  return createField(this, docsFieldType.type)
}

private fun createField(
  value: String,
  type: JsonFieldType,
  optional: Boolean = true
): RestDocField {
  val descriptor = PayloadDocumentation
      .fieldWithPath(value)
      .type(type)
      .description("")

  if (optional) descriptor.optional()

  return RestDocField(descriptor)
}

fun requestBody(vararg fields: RestDocField): RequestFieldsSnippet =
  PayloadDocumentation.requestFields(fields.map { it.descriptor })

fun responseBody(vararg fields: RestDocField): ResponseFieldsSnippet =
  PayloadDocumentation.responseFields(fields.map { it.descriptor })

적용 후

describe("POST : /api/v1/boards") {
  val url = "/api/v1/boards"
  context("유효한 요청이 전달 되면") {
    val authentication = Authentication(1L, Role.USER)
    AuthenticationContextHolder.setAuthentication(authentication)

    val requestBody = BoardCreateRequest("Test", "Test")
    val requestJson = mapper.writeValueAsString(requestBody)
    val request = request(HttpMethod.POST, url)
        .contentType(MediaType.APPLICATION_JSON)
        .content(requestJson)

    it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDocument(
            "Create Board", (
                requestBody(
                  "title" fieldType STRING means "게시판 이름" isOptional false,
                  "description" fieldType STRING means "게시판 설명" isOptional true
                ))
          )
    }
  }
}

좀 더 파라미터가 많은 예시

describe("GET : /api/v1/boards/{id}") {
  val url = "/api/v1/boards/{id}"
  context("유효한 요청이 전달 되면") {
    val id = 1L
    val request = request(HttpMethod.GET, url, id)
    it("200 응답") {
      val response = getTestBoardGetDetailResponse()
      given(queryService.getDetail(anyLong())).willReturn(response)
      mockMvc
          .perform(request)
          .andExpect(status().isOk)
          .andDocument(
            "Lookup Board",
            pathParameters(
              "id" pathMeans "게시판 번호"
            ),
            responseBody(
              "id" fieldType NUMBER means "게시판 번호" isOptional false,
              "title" fieldType STRING means "게시판 제목" isOptional false,
              "description" fieldType STRING means "게시판 제목" isOptional true,
              "creatorInfo.id" fieldType NUMBER means "게시판 생성자 번호" isOptional false,
              "creatorInfo.name" fieldType STRING means "게시판 생성자 이름" isOptional false,
              "creatorInfo.imageUrl" fieldType STRING means "게시판 생성자 이미지 URL" isOptional false,
              "createdAt" fieldType STRING means "게시판 생성일" isOptional false,
            )
          )
    }
  }
}

Kotest와 코틀린의 확장함수, 중위함수를 통해 기존 테스트 코드를 훨씬 가독성 있게 변경하였다. 이러한 리팩터링은 필드와 Path 파라미터들이 많이 필요할수록 눈에 띄었다. 코틀린의 특성을 활용해 DSL을 작성하고 기존 코드를 리펙토링 해보면서 코틀린의 장점을 더 크게 느낄 수 있었다.

Reference

+ Recent posts