레벨0 - 제네릭과 널, 그리고 3주간의 흐름

2026. 2. 17. 13:21·우아한테크코스/레벨0

 

세 번째 주를 시작하며

계획중 가장 머리 아픈 계획이었던 3주차 입니다. 제네릭이랑 널 안전성. 특히 여태까지 안드로이드 개발을 하면서 공변성, 반공변성 같은 개념은 적당이 이해만 하고 넘겼었어요. 사실 직관적인 개념은 아니라고 생각합니다.😅 안드로이드 개발하면서 List<String>이 List<Any>의 하위 타입이 아니라는 건 알았지만 왜 그런지는 제대로 이해하지는 않았어요. 그냥 "제네릭이 그렇게 작동한다"고만 받아들였던 것 같아요.

이번 주는 그 "왜"를 파고들었습니다. 2주차에서 인터페이스와 추상 클래스의 차이를 바이트코드로 확인했던 것처럼 제네릭도 디컴파일해보면 뭔가 보일거라고 생각해서 확인했어요.

나야, 제네릭

일단 간단한 코드부터 시작했습니다.

val strings: List<String> = listOf("a", "b")
val anys: List<Any> = strings  // 이게 왜 가능함?

이 코드가 컴파일되는 이유는 List가 out T로 선언되어 있기 때문이에요. 공식 문서를 읽어보니 이걸 "공변성(covariance)"이라고 부르는 개념입니다.

interface List<out E> : Collection<E> {
    // ...
}

out 키워드가 붙으면 List<String>을 List<Any>로 안전하게 업캐스팅할 수 있습니다. 왜냐하면 List는 읽기 전용이고 E 타입의 값을 반환만 하지 받지는 않기 때문이에요. 값을 꺼내기만 하면 String을 Any로 다루는 건 항상 안전한걸 보장하는 원리입니다.

반대로 MutableList는 어떨까요?

val strings: MutableList<String> = mutableListOf("a")
val anys: MutableList<Any> = strings  // 컴파일 에러남

에러가 납니다. 왜냐하면 MutableList는 add() 메서드로 값을 넣을 수 있어요. 만약 MutableList<String>을 MutableList<Any>로 업캐스팅할 수 있다면 이런 상황이 생겨요.

val strings: MutableList<String> = mutableListOf("a")
val anys: MutableList<Any> = strings  // 만약 이게 된다면
anys.add(123)  // Any이니까 Int도 넣을 수 있어야함
val str: String = strings[1]  // 런타임 에러남

strings는 String만 담을 수 있는데 Int가 들어가버리면 타입 안전성이 깨집니다. 그래서 MutableList는 out도 in도 없는 무공변(invariant)으로 선언돼요.

자바와 비교해보니 더 명확했어요. 자바는 이걸 와일드카드로 표현합니다.

List<? extends Object> anys = strings;  // 공변성

코틀린의 out이 자바의 ? extends와 같은 역할인데요, 자바는 사용하는 쪽에서 와일드카드를 써야 하는데 코틀린은 선언하는 쪽에서 out을 붙이면 끝입니다.(최고다 코틀린) 이걸 "선언 지점 변성(declaration-site variance)"이라고 부릅니다.

in 키워드는 반대로 작동합니다. 값을 받기만 하고 반환하지 않을 때 사용해요.

interface Comparable<in T> {
    fun compareTo(other: T): Int
}

Comparable<Number>를 받는 함수에 Comparable<Int>를 넘길 수 있어요. Int는 Number의 하위 타입이기 때문에 가능하고 이게 반공변성(contravariance)입니다.

솔직히 이 부분은 아직도 머리가 복잡해요. "값을 소비하면 in, 생산하면 out"이라는 규칙은 이해해도 실제 설계할 때 어떤 타입 파라미터에 변성을 줘야 할지 판단하는 건 또 다른 문제인 것 같아요.

reified, 스트롱 스트롱

제네릭 타입 소거는 자바의 고질적인 문제입니다. 런타임에 List<String>의 String 정보가 사라집니다.

fun <T> checkType(value: Any): Boolean {
    return value is T  // 컴파일 에러남
}

근데 코틀린은 inline 함수와 reified 키워드로 이걸 해결했어요.

inline fun <reified T> checkType(value: Any): Boolean {
    return value is T  // 가능!
}

어떻게 이게 가능한지 디컴파일해봤어요.

inline fun <reified T> printType() {
    println(T::class.simpleName)
}

fun main() {
    printType<String>()
}

이걸 디컴파일하면 inline 함수는 호출 지점에 코드가 복사되고 T는 실제 타입으로 치환됩니다.

public static final void main() {
    String var0 = "kotlin.String";
    System.out.println(var0);
}

printType<String>()이 통째로 인라인되면서 T::class.simpleName이 "kotlin.String"으로 바뀌었어요. 함수 호출이 아예 사라지고 코드가 그 자리에 복사/붙여넣기 된 형태입니다.

이게 reified의 원리입니다. 컴파일 타임에 타입 정보를 코드에 직접 박아넣는 건데요. 제네릭 타입 소거가 일어나기 전에 타입을 구체적인 값으로 바꿔치기하는 거예요.

안드로이드에서 viewModel<MyViewModel>()처럼 쓸 수 있는 이유입니다. reified 덕분에 런타임에 MyViewModel::class를 얻을 수 있어요.

inline fun <reified VM : ViewModel> viewModel(): VM {
    return ViewModelProvider(this).get(VM::class.java)
}

단 reified는 inline 함수에서만 쓸 수 있어요. 인라인되지 않으면 타입 정보를 코드에 박아넣을 수 없기 때문이에요.

코틀린은 Null을 싫어해

코틀린의 널 안전성은 컴파일 타임에만 존재하는 걸까요 아니면 런타임에도 뭔가 차이가 있을까요?

val name: String? = null
val length = name?.length

이 코드를 디컴파일해봤어요.

String name = null;
Integer length = name != null ? name.length() : null;

?.이 null 체크 삼항 연산자로 변환됐습니다. 그리고 String?의 ?는 자바 코드에서 사라졌어요. JVM 바이트코드에는 nullable 타입이라는 개념이 없기 때문입니다.

대신 @Nullable 어노테이션이 붙습니다.

@Nullable
String name = null;

이 어노테이션은 런타임에는 아무 역할도 하지 않아요. 컴파일 타임에 코틀린 컴파일러나 IntelliJ 같은 도구가 경고를 표시하는 역할 뿐이에요.

그럼 null 안전성은 어떻게 보장될까요? 답은 "컴파일러가 null 체크 코드를 자동으로 삽입한다"입니다.

fun printLength(name: String) {
    println(name.length)
}

printLength(null)  // 컴파일 에러

이 코드는 컴파일 자체가 불가능 합니다. 컴파일러가 "name은 non-null이어야 하는데 null을 넣으려고 하네?"라고 막아버립니다.

만약 자바에서 코틀린 함수를 호출하면 어떻게 될까요?

KotlinClass.printLength(null);  // 런타임 에러!

컴파일은 되지만 런타임에 NullPointerException이 아니라 IllegalArgumentException이 발생해요. 코틀린이 함수 시작 부분에 null 체크를 넣기 때문이에요.

public static final void printLength(@NotNull String name) {
    Intrinsics.checkNotNullParameter(name, "name");
    System.out.println(name.length());
}

Intrinsics.checkNotNullParameter()가 null이면 예외를 던집니다. 자바 코드에서 넘어온 null을 최대한 빨리 잡으려고 합니다.

결국 코틀린의 널 안전성은

  1. 컴파일 타임에 정적 분석으로 null 가능성을 추적
  2. 런타임에는 null 체크 코드를 자동 삽입해서 방어

이 두 가지로 구현됩니다!

DSL은 어떻게 쓰는거에요?

이번 주 계획에 있던 DSL 패턴도 확인해봤어요. 코틀린으로 HTML DSL 같은 걸 만들 수 있는 원리가 궁금했어요.

fun buildString(action: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.action()
    return sb.toString()
}

val result = buildString {
    append("Hello")
    append(" World")
}

여기서 action: StringBuilder.() -> Unit이 핵심이에요. 이걸 람다 리시버라고 하는데요, 일반 람다 () -> Unit과 뭐가 다를까요?

일반 람다는 외부 컨텍스트에 접근할 때 명시적으로 객체를 참조해야 해요.

fun buildStringNormal(action: (StringBuilder) -> Unit): String {
    val sb = StringBuilder()
    action(sb)  // sb를 파라미터로 전달
    return sb.toString()
}

val result = buildStringNormal { sb ->
    sb.append("Hello")  // sb.을 명시해야 함
}

하지만 람다 리시버는 this를 생략할 수 있어요.

val result = buildString {
    this.append("Hello")  // this는 StringBuilder
    append(" World")       // this 생략 가능
}

디컴파일해보면 이 차이가 명확합니다.

public static final String buildString(Function1<? super StringBuilder, Unit> action) {
    StringBuilder sb = new StringBuilder();
    action.invoke(sb);
    return sb.toString();
}

람다 리시버도 결국 파라미터로 객체를 받는 일반 함수로 변환돼요. 하지만 코틀린 컴파일러가 람다 안에서 this를 자동으로 리시버 객체로 바인딩해줍니다.

컴포즈의 Column도 이 원리입니다.

@Composable
fun Column(content: @Composable ColumnScope.() -> Unit) {
    // ...
}

Column {
    Text("Hello")        // ColumnScope의 확장 함수
    Spacer(height = 8.dp)
}

람다 안에서 this가 ColumnScope니까 ColumnScope의 확장 함수들을 직접 호출할 수 있습니다. Modifier.align() 같은 것도 마찬가지고 fun BoxScope.HelloText() 처럼 Box의 this 를 제공할 수 있어요.

이 패턴이 좋은 이유는 "특정 컨텍스트에서만 사용 가능한 API"를 만들 수 있기 때문이라고 생각합니다. Text()는 Column 안에서만 쓸 수 있게 설계할 수 있어요. 타입에 안전하게 만드는 좋은 방법중 하나입니다.

잘된 점

디컴파일이 습관이 됐어요. 이제는 궁금한 코드가 있으면 자연스럽게 Show Kotlin Bytecode를 누르고 보는 흐름이 익숙해요. "이게 왜 이렇게 작동하지?"라는 생각에는 "한번 디컴파일해보자"가 첫 번째 반응이 됐습니다.

이전 주차와의 연결 고리를 찾는 능력이 생겼어요. 2주차 회고에서 "1주차 개념을 바로 연결하지 못했다"고 아쉬워했는데 이번 주는 좀 나아진 것 같습니다. backing field가 인터페이스에도 적용되고 Expression이 null 체크에도 쓰이고 람다 리시버가 컴포즈 DSL의 기반이 된다는 걸 자연스럽게 연결할 수 있었어요.💪🏻

아쉬운 점

변성은 여전히 어려워요... 공변성, 반공변성, 무공변성 개념은 이해했는데 실제로 "이 타입 파라미터에 out을 붙여야 하나, in을 붙여야 하나?" 같은 판단은 여전히 헷갈리는 것 같아요. 좀더 실제 경험에서 상황을 겪어봐야 할것 같습니다.

그리고 reified로 우회할 수는 있지만 왜 자바가 처음부터 타입 소거를 선택했는지, 그게 어떤 트레이드오프였는지는 아직 명확하지 않아요. 레거시 호환성 때문이라는 설명은 봤지만 구체적으로 어떤 문제를 해결하기 위한 선택이었는지 더 깊이 알고 싶습니다.

다음 주 계획 = 마지막 주

그리고 4주차 회고에서는 전체 4주를 돌아보는 시간을 가지려고 합니다. 처음 세웠던 목표를 얼마나 달성했는지 앞으로 어떻게 공부를 이어갈지 정리해보려고 해요. 그리고 진행중인 프로젝트가 4천 다운로드를 넘겼습니다.👍🏻 이번 스프린트부터 더 어려운 기능이 들어가는데 이것도 열심히 병행해보겠습니다.

이번 주도 여기까지입니다. 3주 차를 무사히 마쳤네요. 남은 1주도 집중해서 마무리하겠습니다! 🔥

'우아한테크코스 > 레벨0' 카테고리의 다른 글

레벨0 - 지연 초기화와 위임, 그리고 4주간의 마무리  (0) 2026.02.24
레벨0 - 2주차 회고, 그리고 회고를 회고하기  (0) 2026.02.10
레벨0 - 생성자와 프로퍼티, 그리고 컴파일 과정  (2) 2026.02.02
'우아한테크코스/레벨0' 카테고리의 다른 글
  • 레벨0 - 지연 초기화와 위임, 그리고 4주간의 마무리
  • 레벨0 - 2주차 회고, 그리고 회고를 회고하기
  • 레벨0 - 생성자와 프로퍼티, 그리고 컴파일 과정
아키001
아키001
Android 개발자가 되기까지.
  • 아키001
    미래 가젯 연구소
    아키001
  • 전체
    오늘
    어제
    • 분류 전체보기 (37)
      • Android (27)
        • Compose (13)
        • Jetpack (2)
      • Kotlin (2)
      • 우아한테크코스 (8)
        • 일상, 회고 (0)
        • 레벨1 (0)
        • 레벨0 (4)
        • 프리코스 (4)
  • 블로그 메뉴

    • 홈
    • 안드로이드
    • 태그
  • 링크

    • GitHub
  • 인기 글

  • 태그

    Kotlin
    jetpack
    우테코
    coroutine
    Android
    compose
    Gradle
    build-logic
    runcatching
    레벨0
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
아키001
레벨0 - 제네릭과 널, 그리고 3주간의 흐름
상단으로

티스토리툴바