카테고리 없음

[Kotlin] when과 if-else의 바이트코드 비교: Jump Table 최적화 사례 분석

yujinius 2024. 12. 26. 18:53

 Kotlin의 when 문은 Java의 switch와 유사하지만, 다중 값 비교, 범위, 표현식 사용 가능 등 더 강력한 기능과 유연성을 제공한다. 또한, when 문은 if-else에 비해 더 간결하고 가독성이 뛰어나 많은 개발자들이 선호한다.

 

 그리고 나는 프로그래머스 기초 코딩 테스트 문제 중 대소문자 바꿔서 출력하기 를 풀다가 문득 when과 if-else의 성능 차이가 궁금해졌다. 분기 처리에는 when이 더 효율적이고 가독성도 좋다는 이야기를 들었기에 이를 확인해보고자 했다. 그러다가 아래의 글을 발견했다.

 

https://medium.com/@stephen.leigh/when-statements-in-kotlin-with-bytecode-9ae65ad0d50e

when이 bytecode로 어떻게 변경되는지에 대해서 이야기 하며 jump table이라는 구조가 나왔다. 그래서 when은 컴파일 시 Jump Table로 최적화될 가능성이 있어 성능적인 이점을 제공할 수 있다는 것을 알게 되었다.


이 글에서는 whenif-else의 바이트코드를 비교하며, Jump Table로 변경되는 경우와 그렇지 않은 경우를 분석하고자 한다.


Jump Table이란?

Jump Table은 컴파일러가 조건 분기를 효율적으로 처리하기 위해 생성하는 데이터 구조이다. 상수 값 또는 고정된 값 비교를 기반으로, 조건에 따라 직접적으로 해당 분기로 이동할 수 있게 한다. 이를 통해 순차적으로 조건을 평가하는 것보다 더 빠르게 실행될 수 있다.

Jump Table이 적용되려면 다음 조건을 충족해야 한다:

  1. 조건이 상수 값이어야 한다 (예: 정수, 열거형(enum), 문자열).
  2. 조건 간의 연산이 없어야 한다.
  3. 조건 범위가 일정하거나 제한적이어야 한다.

참고: 열거형(enum) 조건에서는 ordinal() 값을 매핑한 배열(Mapping Array)을 생성한 후, 이를 통해 Jump Table로 최적화된다.


바이트코드 확인 방법

  • IntelliJ IDEA에서 Tools > Kotlin > Show Kotlin Bytecode로 바이트코드를 확인할 수 있다.

실험: Jump Table로 변경되는 경우와 변경되지 않는 경우

1. 정수형 상수 조건 (Jump Table 적용)

fun main() {
    val testInput = 3

    // When 방식
    val startTimeWhen = System.nanoTime()
    val resultWithWhen = when (testInput) {
        1 -> "one"
        2 -> "two"
        3 -> "three"
        4 -> "four"
        else -> "unknown"
    }
    val endTimeWhen = System.nanoTime()

    // If-Else 방식
    val startTimeIf = System.nanoTime()
    val resultWithIf = if (testInput == 1) {
        "one"
    } else if (testInput == 2) {
        "two"
    } else if (testInput == 3) {
        "three"
    } else if (testInput == 4) {
        "four"
    } else {
        "unknown"
    }
    val endTimeIf = System.nanoTime()

    println("Result with when: $resultWithWhen")
    println("Execution time with when: ${endTimeWhen - startTimeWhen} ns")
    println("Result with if-else: $resultWithIf")
    println("Execution time with if-else: ${endTimeIf - startTimeIf} ns")
}

 

바이트코드 분석:

TABLESWITCH
  1: L3    // 입력값이 1일 때 "one" 반환
  2: L4    // 입력값이 2일 때 "two" 반환
  3: L5    // 입력값이 3일 때 "three" 반환
  4: L6    // 입력값이 4일 때 "four" 반환
  default: L7 // 그 외의 값일 때 "unknown" 반환

이 바이트코드는 when 문이 TABLESWITCH로 변환된 것을 보여준다. 입력 값(1, 2, 3, 4)에 따라 Jump Table을 사용해 직접 해당 분기로 이동한다.

정수형은 if-else에서도 위와 같이 TABLESWITCH로 변환된 것을 보여준다.

이 경우 when 문과 if-else 문 모두 동일한 TABLESWITCH 최적화를 적용받아 Jump Table을 생성한다. 그래서 조건이 적은 상태에서 결과적으로 아래와 같이 if-else의 속도가 더 빠르다.

 

2. 열거형(enum)을 사용하는 경우 (Jump Table 적용)

enum class TestEnum {
    A, B, C, D
}

fun main() {
    val testInput = TestEnum.C

    val startTimeWhen = System.nanoTime()
    val resultWithWhen = when (testInput) {
        TestEnum.A -> "A"
        TestEnum.B -> "B"
        TestEnum.C -> "C"
        TestEnum.D -> "D"
    }
    val endTimeWhen = System.nanoTime()

    val startTimeIf = System.nanoTime()
    val resultWithIf = if (testInput == TestEnum.A) {
        "A"
    } else if (testInput == TestEnum.B) {
        "B"
    } else if (testInput == TestEnum.C) {
        "C"
    } else {
        "D"
    }
    val endTimeIf = System.nanoTime()

    println("Result with when: $resultWithWhen")
    println("Execution time with when: ${endTimeWhen - startTimeWhen} ns")
    println("Result with if-else: $resultWithIf")
    println("Execution time with if-else: ${endTimeIf - startTimeIf} ns")
}

바이트코드 분석:

ALOAD 0                       // TestEnum.C 로드
GETSTATIC org/example/MainKt$WhenMappings.$EnumSwitchMapping$0 : [I
SWAP
INVOKEVIRTUAL org/example/TestEnum.ordinal ()I
IALOAD
TABLESWITCH
  1: L3  // "A"
  2: L4  // "B"
  3: L5  // "C"
  4: L6  // "D"
  default: L7

열거형에서 when은 Mapping Array를 생성하여 ordinal() 값을 매핑하고, 이 값을 기반으로 TABLESWITCH로 최적화한다. 이 매핑 배열은 열거형의 ordinal 값을 기반으로 각 값을 정수로 변환하여 배열 인덱스로 접근할 수 있게 한다. 이를 통해 분기 처리가 효율적으로 이루어진다.

  • GETSTATIC org/example.MainKt$WhenMappings.$EnumSwitchMapping$0:
    • 매핑 배열을 가져온다. 이 배열은 컴파일러가 생성하며, 각 TestEnumordinal 값을 특정 분기로 매핑한다.
  • INVOKEVIRTUAL org/example.TestEnum.ordinal():
    • 현재 열거형의 ordinal 값을 가져온다. 이 값은 열거형 정의 순서에 따라 0부터 시작하는 정수이다.
  • IALOAD:
    • 매핑 배열에서 ordinal 값에 해당하는 매핑 인덱스를 가져온다.
  • TABLESWITCH:
    • 매핑된 정수 값을 기반으로 Jump Table을 사용해 직접 해당 분기로 이동한다.

ALOAD 0                                // testInput을 로드 (열거형 값)
GETSTATIC org/example/TestEnum.A      // TestEnum.A 상수 값을 로드
IF_ACMPNE L12                         // testInput과 TestEnum.A가 같지 않으면 L12로 이동
L13
LINENUMBER 20 L13                     // if 조건이 참일 때 실행되는 코드 (LINENUMBER은 디버깅용 메타데이터)
LDC "A"                               // 조건이 참이므로 "A"를 스택에 적재
GOTO L14                              // 결과 저장 부분으로 이동

L12                                   // 첫 번째 조건 실패 시 여기로 이동
LINENUMBER 21 L12                     // 다음 조건 확인 시작
ALOAD 0                               // testInput을 다시 로드
GETSTATIC org/example/TestEnum.B      // TestEnum.B 상수 값을 로드
IF_ACMPNE L15                         // testInput과 TestEnum.B가 같지 않으면 L15로 이동
L16
LINENUMBER 22 L16                     // if 조건이 참일 때 실행되는 코드
LDC "B"                               // 조건이 참이므로 "B"를 스택에 적재
GOTO L14                              // 결과 저장 부분으로 이동

L15                                   // 두 번째 조건 실패 시 여기로 이동
LINENUMBER 23 L15                     // 다음 조건 확인 시작
ALOAD 0                               // testInput을 다시 로드
GETSTATIC org/example/TestEnum.C      // TestEnum.C 상수 값을 로드
IF_ACMPNE L17                         // testInput과 TestEnum.C가 같지 않으면 L17로 이동
L18
LINENUMBER 24 L18                     // if 조건이 참일 때 실행되는 코드
LDC "C"                               // 조건이 참이므로 "C"를 스택에 적재
GOTO L14                              // 결과 저장 부분으로 이동

L17                                   // 세 번째 조건 실패 시 여기로 이동
LINENUMBER 26 L17                     // 네 번째 조건 실행
LDC "D"                               // 조건이 참이므로 "D"를 스택에 적재

L14                                   // 모든 조건이 끝나면 결과 저장
LINENUMBER 19 L14                     // 최종 결과 저장 지점
ASTORE 8                              // 스택에서 값을 꺼내 resultWithIf 변수에 저장

이 if-else 구조는 열거형 값이 하나씩 비교되며 조건이 맞는 값으로 분기하는 단순한 순차 비교 방식이다.

왜 이번에는 if-else가 Jump Table로 변환되지 않았을까?

Jump Table로 변환되지 않은 이유

if-else는 열거형 상수를 직접 비교하지만, 이번 바이트코드에서 Jump Table 방식으로 최적화되지 않았다. 이는 열거형 상수가 단순 정수가 아니라 참조 타입이기 때문이다.

아래와 같이 여전히 if-else가 빠른 것을 볼 수 있다.

Jump Table 없이도 if-else가 빠른 이유

  1. 간단한 조건 수

if-else 방식에서는 조건의 수가 적기 때문에 조건을 하나씩 순차적으로 비교해도 성능 저하가 크지 않다. TestEnum에 포함된 상수가 4개뿐이므로, 각 조건을 비교하는 데 걸리는 비용이 낮다.

  1. 조건의 수가 매우 적은 경우의 Jump Table 초기화 오버헤드

Jump Table은 분기 조건을 효율적으로 처리하지만, JVM에서 이를 초기화하는 데 추가적인 비용이 발생한다. 조건의 수가 적거나 실행 시간이 매우 짧은 경우에는 Jump Table 초기화 비용이 더 큰 영향을 미칠 수 있다.

  1. 참조 타입 비교의 최적화

바이트코드에서 if-else 방식은 IF_ACMPNE와 같은 참조 타입 비교 명령을 사용한다. 이 명령은 JVM 수준에서 이미 최적화되어 있어, 조건 수가 적을 때 매우 효율적이다.

3. 문자열 비교 (Jump Table 적용 가능)

fun main() {
    val testInput = "three"

    val startTimeWhen = System.nanoTime()
    val resultWithWhen = when (testInput) {
        "one" -> 1
        "two" -> 2
        "three" -> 3
        "four" -> 4
        else -> -1
    }
    val endTimeWhen = System.nanoTime()

    val startTimeIf = System.nanoTime()
    val resultWithIf = if (testInput == "one") {
        1
    } else if (testInput == "two") {
        2
    } else if (testInput == "three") {
        3
    } else if (testInput == "four") {
        4
    } else {
        -1
    }
    val endTimeIf = System.nanoTime()

    println("Result with when: $resultWithWhen")
    println("Execution time with when: ${endTimeWhen - startTimeWhen} ns")
    println("Result with if-else: $resultWithIf")
    println("Execution time with if-else: ${endTimeIf - startTimeIf} ns")
}

바이트코드 분석:

ALOAD 0                       // testInput 로드
INVOKEVIRTUAL java/lang/String.hashCode ()I
LOOKUPSWITCH                  // 해시 코드로 분기
  110182: L3                 // "one"
  115276: L4                 // "two"
  3149094: L5                // "three"
  110339486: L6              // "four"
  default: L7                // else
L3: ALOAD 0                  // equals("one") 확인
LDC "one"
INVOKEVIRTUAL java/lang/String.equals (Ljava/lang/Object;)Z
IFNE L9

when 문은 문자열 비교를 처리하며, LOOKUPSWITCH로 최적화되었다. 이는 문자열의 hashCode를 기반으로 분기하지만, 동일한 해시 값이 있을 가능성을 방지하기 위해 추가적으로 equals 호출을 통해 문자열 값을 확인한다고 한다.

주요 동작

  1. hashCode() 호출: 문자열의 해시 코드를 계산한다.
  2. LOOKUPSWITCH 실행: 해시 값을 기반으로 분기한다.
  3. equals() 호출: 실제 문자열 값을 확인한다.
  4. 결과 반환: 조건이 맞는 값을 반환한다.

if-elseLOOKUPSWITCH를 사용해 해시 값 기반으로 분기하는 형식이 되었다.

이번에도 결과는 if-else가 빨랐다.

그런데 여기에서 TABLESWITCHLOOKUPSWITCH의 차이점이 무엇일까?

  1. TABLESWITCH
    • 연속된 정수 범위를 처리할 때 사용.
    • 낮은 값과 높은 값 사이의 모든 정수에 대해 Jump Table 생성.
    • 인덱스를 기반으로 빠르게 분기로 이동.
    • 장점: 조건 값이 연속적이면 효율적.
    • 단점: 조건 값이 드문드문 있으면 비효율적(빈 슬롯도 포함).
    • 예시*:
    • TABLESWITCH 1: L1 2: L2 3: L3 default: L4
  2. LOOKUPSWITCH
    • 불연속적인 값이나 드문드문 있는 값을 처리할 때 사용.
    • 값과 분기 위치를 매핑한 해시 테이블 형태.
    • 장점: 조건 값이 불연속적이거나 소수일 때 효율적.
    • 단점: 검색에 약간의 오버헤드가 발생.
    • 예시*:
    • LOOKUPSWITCH 10: L1 20: L2 30: L3 default: L4

사용 기준

  • 조건 값이 연속적TABLESWITCH 사용.
  • 조건 값이 불연속적LOOKUPSWITCH 사용.

표로 정리

구분 TABLESWITCH LOOKUPSWITCH
사용 조건 조건 값이 연속적일 때 사용 조건 값이 불연속적일 때 사용
메모리 사용량 더 많음 더 적음
실행 속도 일정 약간 느릴 수 있음
예시 1~10 10, 20, 30

 

문자열은 해시코드로 변환되더라도 그 값이 연속적이지 않으므로, LOOKUPSWITCH가 사용되는 것이었다.

 

[참고]

https://www.objectos.com.br/blog/java-switch-internals-tableswitch-lookupswitch-instructions.html?utm_source=chatgpt.com

https://headf1rst.github.io/TIL/post-1-1

4. 복잡한 조건 (Jump Table 미적용)

fun main() {
    val testInput = 15

    val startTimeWhen = System.nanoTime()
    val resultWithWhen = when {
        testInput % 2 == 0 -> "even"
        testInput < 10 -> "less than ten"
        testInput == 15 -> "fifteen"
        testInput > 20 -> "greater than twenty"
        else -> "unknown"
    }
    val endTimeWhen = System.nanoTime()

    val startTimeIf = System.nanoTime()
    val resultWithIf = if (testInput % 2 == 0) {
        "even"
    } else if (testInput < 10) {
        "less than ten"
    } else if (testInput == 15) {
        "fifteen"
    } else if (testInput > 20) {
        "greater than twenty"
    } else {
        "unknown"
    }
    val endTimeIf = System.nanoTime()

    println("Result with when: $resultWithWhen")
    println("Execution time with when: ${endTimeWhen - startTimeWhen} ns")
    println("Result with if-else: $resultWithIf")
    println("Execution time with if-else: ${endTimeIf - startTimeIf} ns")
}

바이트코드 분석:

조건에 산술 연산(%, <)이 포함되었기 때문에 when 문은 Jump Table로 최적화되지 않았다. 대신 조건을 순차적으로 평가하는 방식으로 처리되며, 이는 if-else와 동일한 동작을 한다.


결론 : Jump Table로 변경되는 경우와 그렇지 않은 경우

조건 유형 Jump Table 변경 여부 바이트코드 예시 사용 사례
정수형 상수 변경 가능 TABLESWITCH 메뉴 선택, 고정된 숫자 기반 분기 처리
열거형(enum) 변경 가능 TABLESWITCH 상태 관리, 열거형 상태 전환 로직 처리
문자열 비교 변경 가능 LOOKUPSWITCH 사용자 입력, 고정된 키 값 기반 로직
산술 연산 포함된 조건 미적용 순차 조건 평가 숫자 범위 조건(x > 10), 복잡한 조건 계산 로직
복잡한 논리 조건 미적용 순차 조건 평가 다중 조건 결합

 

이렇게 바이트 코드로 어떻게 변하나 확인해보았다. when에서만 Jump Table로 최적화 되는 줄 알았으나 if-else도 특정 조건(정수형 상수, 문자열)인 경우에만 Jump Table가 적용되는 경우가 있었다. 이외에는 복잡한 조건이나 열거형과 같은 경우에는 순차적으로 비교한다.


결론

앞서 when이 jump table을 만든다는 것을 보고 그럼 when이 복잡한 연산만 아니라면 분기 처리에서 if-else보다 무조건 빠른가? 라는 의문이 들었었다. 그러나 if-else도 상수나 문자열 비교에서 jump table을 만드는 경우가 있었으며, whenif-else의 성능 차이는 상황에 따라 다른 것이었다.

조건 유형 Jump Table 적용 여부 추천
정수형 상수 가능 when 또는 if-else
열거형(enum) when만 가능 when
문자열 비교 가능 when 또는 if-else
산술 연산 포함 조건 불가능 if-else
복잡한 논리 조건 불가능 if-else
  • 상수 조건(정수형, 문자열)에서는 when과 if-else 모두 Jump Table로 최적화 가능하다.
  • 열거형(enum)은 when에서만 Mapping Array와 Jump Table 최적화를 통해 효율적으로 처리된다.
  • 복잡한 산술 연산이나 논리 조건에서는 Jump Table이 생성되지 않으므로 when과 if-else의 성능 차이는 없다.
  • 가독성과 유지보수성 측면에서 when이 유리하므로 일반적으로 권장된다. 단, 성능 최적화가 중요한 경우 바이트코드 분석을 통해 적합한 방식을 선택해야 한다.

결론적으로, when은 가독성과 유지보수성이 뛰어나므로 권장되지만, 성능 최적화가 중요한 경우 바이트코드 분석을 통해 적합한 방식을 선택해야 할 것 같다. 상수 조건이 많고 고정적일 때는 whenif-else 모두 Jump Table을 활용할 수 있지만, 열거형에서는 when 만 Jump Table을 사용하므로 열거형은 when이 유리하다.