마지막 주를 시작하면서
드디어 4주차입니다. 솔직히 마지막 주가 가장 실용적인 주차라고 생각했어요. lateinit이랑 by lazy는 안드로이드 프로젝트에서 거의 매일 쓰는 키워드인데 그냥 "Hilt 필드 주입에는 lateinit, 무거운 연산에는 by lazy" 정도로만 사용해왔습니다. 이번 주차도 디컴파일로 학습을 진행했어요.
lateinit과 by lazy, 뭐가 다른가
이 둘을 "lateinit은 var에만, by lazy는 val에만" 같은, 사용 상황으로만 구분하는 글은 많지만 바이트코드를 보면 두 개념이 얼마나 다른 방식으로 구현되는지 훨씬 명확하게 드러납니다.
lateinit은 그냥 null임
Compose 환경에서 lateinit이 가장 자주 등장하는 곳은 Hilt 필드 주입이에요. 생성자 주입이 불가능한 상황에서 DI 프레임워크가 주입 시점을 보장해주기 때문에 lateinit을 씁니다.
@HiltViewModel
class ProfileViewModel @Inject constructor() : ViewModel() {
@Inject
lateinit var repository: UserRepository
fun loadProfile(userId: String) = repository.findById(userId)
}
이걸 디컴파일하면 꽤 단순합니다.
@HiltViewModel
public final class ProfileViewModel extends ViewModel {
public UserRepository repository; // 그냥 null로 초기화, Hilt가 나중에 채워줌
public final Object loadProfile(@NotNull String userId) {
return this.repository.findById(userId);
}
}
lateinit var는 JVM 레벨에서 특별한 게 없어요. 그냥 null로 시작하는 일반 필드입니다. 코틀린이 하는 일은 접근 시점에 null 체크 코드를 삽입하는 것뿐이에요.
val profile = repository.findById("1") // Hilt 주입 전에 접근하면?
UserRepository var1 = this.repository;
if (var1 == null) {
Intrinsics.throwUninitializedPropertyAccessException("repository");
}
return var1.findById("1");
repository가 null이면 NullPointerException이 아니라 UninitializedPropertyAccessException을 던져요. 에러 메시지에 변수 이름이 포함되니까 어디서 초기화를 빠뜨렸는지 바로 알 수 있어서 디버깅에 유리합니다.
결국 lateinit이 하는 건 딱 두 가지예요. "나는 나중에 반드시 초기화할 거야"라는 컴파일러와의 약속, 그리고 접근 시 null 체크 자동 삽입. 그 이상도 이하도 아닙니다.
by lazy는 함수다
반면 by lazy는 구현 방식이 완전히 달라요. Compose에서는 ViewModel 안에서 초기화 비용이 큰 객체를 지연 생성할 때 자주 씁니다.
class SearchViewModel : ViewModel() {
// Flow를 결합하거나 복잡한 초기 상태를 계산할 때 by lazy를 활용
val recommendations: List<String> by lazy {
println("추천 목록 계산 중...")
computeRecommendations()
}
private fun computeRecommendations(): List<String> {
// 무거운 연산
return listOf("Kotlin", "Compose", "Coroutines")
}
}
디컴파일하면 이렇습니다.
public final class SearchViewModel extends ViewModel {
@NotNull
private final Lazy recommendations$delegate;
public SearchViewModel() {
this.recommendations$delegate = LazyKt.lazy(new Function0<List<String>>() {
public List<String> invoke() {
System.out.println("추천 목록 계산 중...");
return SearchViewModel.this.computeRecommendations();
}
});
}
@NotNull
public final List<String> getRecommendations() {
return (List) this.recommendations$delegate.getValue();
}
}
data 필드가 아예 없어요. 대신 Lazy<List<String>> 타입의 data$delegate라는 위임 객체가 생겼습니다. data에 접근할 때마다 getData()가 호출되고 그게 내부적으로 delegate.getValue()를 부릅니다.
LazyKt.lazy()의 기본 모드는 LazyThreadSafetyMode.SYNCHRONIZED예요. 내부를 보면 이중 확인 잠금(Double-Checked Locking) 패턴으로 구현돼 있어요.
private class SynchronizedLazyImpl<out T>(initializer: () -> T) : Lazy<T> {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
private val lock = this
override val value: T
get() {
val v1 = _value
if (v1 !== UNINITIALIZED_VALUE) return v1 as T // 이미 초기화됨
return synchronized(lock) {
val v2 = _value
if (v2 !== UNINITIALIZED_VALUE) v2 as T // 다른 스레드가 먼저 초기화했을 수도
else {
val typedValue = initializer!!()
_value = typedValue
initializer = null // GC 대상이 되도록
typedValue
}
}
}
}
처음 접근 시 synchronized 블록 안에서 한 번만 계산하고, 이후에는 캐시된 값을 바로 반환해요. @Volatile로 메모리 가시성을 보장합니다. 주목할 점은 초기화 후 initializer = null로 람다 참조를 날려버린다는 건데요. 람다가 캡처한 외부 변수들이 GC될 수 있도록 의도적으로 참조를 끊습니다.
lateinit은 null 위에서 작동하는 약속이고, by lazy는 위임 객체 위에서 작동하는 캡슐화된 상태 기계입니다. 같아 보이는 "지연 초기화"지만 내부는 완전히 다른 메커니즘이에요.
by 키워드를 상속 없이 확장하기
by lazy에서 썼던 by 키워드는 사실 더 넓은 개념이에요. 클래스 위임(Class Delegation)이라고 해서 인터페이스를 구현할 때 구현을 다른 객체에게 떠넘길 수 있습니다.
interface Printer {
fun print(message: String)
fun printLine(message: String) = println(message)
}
class ConsolePrinter : Printer {
override fun print(message: String) = print(message)
}
class LoggingPrinter(private val printer: Printer) : Printer by printer {
override fun print(message: String) {
println("[LOG] ${System.currentTimeMillis()}")
printer.print(message)
}
}
LoggingPrinter는 Printer by printer로 선언했어요. print()는 직접 오버라이드하고 printLine()은 printer에게 위임합니다. 디컴파일하면 위임된 메서드들이 자동 생성돼요.
public final class LoggingPrinter implements Printer {
private final Printer printer;
// 위임된 메서드 - 컴파일러가 자동 생성
public void printLine(@NotNull String message) {
this.printer.printLine(message);
}
// 직접 오버라이드한 메서드
public void print(@NotNull String message) {
System.out.println("[LOG] " + System.currentTimeMillis());
this.printer.print(message);
}
}
상속을 쓰면 부모 클래스의 모든 구현에 의존하게 되지만, 위임은 특정 인터페이스의 구현만 위임하고 나머지는 독립적으로 제어할 수 있어요. 2주차에서 "코틀린이 final을 기본으로 하는 이유"를 공부했는데 위임 패턴은 그 대안으로서 딱 맞는 자리에 있었습니다.
Scope Function 언제 뭘 쓰는데
let, run, with, apply, also. 다섯 개나 되는 스코프 함수를 상황에 맞게 쓰는 규칙을 이번 주에 정리했어요. 그동안은 apply가 편하다고 걸 잡아다 쓰는 식이었는데요.
스코프 함수를 구분하는 기준은 두 가지예요.
- 람다 안에서 객체를 어떻게 참조하는가:
this(확장 함수 형태) vsit(파라미터 형태) - 무엇을 반환하는가: 람다의 결과 vs 객체 자신
이걸 표로 정리하면 딱 떨어집니다.
| 함수 | 객체 참조 | 반환값 | 주 용도 |
|---|---|---|---|
let |
it | 람다 결과 | null 체크 후 변환 |
run |
this | 람다 결과 | 객체 설정 후 결과 계산 |
with |
this | 람다 결과 | 객체를 수신자로 연산 묶기 |
apply |
this | 객체 자신 | 빌더 패턴, 객체 초기화 |
also |
it | 객체 자신 | 사이드 이펙트 (로깅, 디버깅) |
실제 코드에서 제가 가장 잘못 쓰고 있던 건 apply였어요.
// apply를 로그 출력에 쓰는 경우
val user = User("Alice").apply {
println("생성됨: $name") // 사이드 이펙트인데 apply를?
}
// 사실은 also가 맞는 자리
val user = User("Alice").also {
println("생성됨: ${it.name}")
}
apply는 객체를 초기화하거나 설정할 때, also는 객체를 건드리지 않고 부수 효과를 실행할 때 씁니다. 두 개 모두 객체 자신을 반환하지만 "객체를 변경하는가(apply) vs 객체를 관찰하는가(also)"로 구분할 수 있어요.
let은 null 체크와 아주 잘 맞습니다.
val trimmed = input?.let {
it.trim().takeIf { s -> s.isNotEmpty() }
}
?.let은 null이 아닐 때만 블록을 실행하고, 블록의 결과를 반환해요. null 체크, 변환, 필터링을 체이닝으로 깔끔하게 엮을 수 있는 패턴입니다. 3주차에서 null 안전성이 컴파일러의 null 체크 삽입으로 구현된다는 걸 배웠는데 스코프 함수는 그 null 체크를 더 선언적으로 표현하는 수단이라는 연결이 됐어요. 하지만 단순 null 체킹의 경우는 if 가 더 가독성이 좋다고 생각해요.
불변성을 지향한다는 것의 의미
4주간 공부하면서 가장 자주 돌아온 키워드가 불변성이에요.
- 1주차: Expression으로
val변수를 한 번만 할당하는 패턴 - 2주차:
data class가final인 이유,copy()로 새 인스턴스를 만드는 방식 - 3주차:
List가 읽기 전용이라 공변성(out)이 가능한 원리 - 4주차:
by lazy의val, 스코프 함수로 상태 변경을 최소화하는 패턴
코틀린은 언어 설계 자체가 불변성을 지향하도록 유도하고 있어요. val이 기본이고 var이 예외적인 선택이 되도록, 컬렉션은 기본이 읽기 전용이고 Mutable을 붙여야 변경 가능하도록, data class는 copy()로 새 인스턴스를 만들도록.
이게 단순한 컨벤션이 아니라 언어 차원에서 강제하는 방향이라는 걸 이번 4주 동안 바이트코드를 통해 계속 확인했습니다.
4주, 전체를 돌아보며
처음 세웠던 목표를 다시 읽어봤어요.
동작 원리의 시각화, 정확한 용어 정립, Java와의 비교를 통한 설계 철학 이해
4주 전의 저는 "Hilt 주입이 어떤 순서로 일어나는지", "by lazy와 lateinit의 내부 차이가 뭔지" 설명하기 어려운 상태였어요. 기능은 쓰는데 원리는 모르는 상태요.
하지만 지금은 코틀린 코드를 보면 자연스럽게 "이게 JVM에서 어떻게 돌아가지?"라는 질문이 떠올라요. (지금의 나는 다르다)
lateinit을 쓸 때 내부적으로 null 필드라는 게 보이고 by lazy를 쓸 때 위임 객체와 이중 잠금이 보입니다.
그리고 "왜 이렇게 설계했을까?"를 묻는 습관이 더욱 굳어졌어요. out 키워드가 왜 필요한지, 클래스가 왜 기본적으로 final인지, 부 생성자가 왜 주 생성자를 반드시 호출해야 하는지. 이 질문들에 공식 문서와 바이트코드를 통해 직접 답을 찾는 과정이 4주간의 핵심이었어요.
예상 못 했던 수확
회고를 쓰면서 이해가 정리됐다는 게 가장 컸어요. 디컴파일해서 "이렇게 되네"까지는 했는데, 그걸 타인에게 설명하는 글로 쓰다 보면 논리에 구멍이 어디 있는지 보였어요. 3주차에 "변성은 여전히 어렵다"고 썼던 것도, 글로 정리하면서 내가 아직 완전히 이해 못 했다는 걸 인식하게 만들어줬어요. 쓰는 것 자체가 메타인지의 도구였습니다.
앞으로의 방향
레벨1이 시작되면 이 4주간의 공부를 실제 코드 설계에 연결해보고 싶어요. "왜 이렇게 만들었는가"를 공부했다면, 다음은 "그 이유를 알고 있으니 이렇게 설계하겠다"로 이어져야 한다고 생각해요.
코드 리뷰를 받을 때 "이렇게 짰어요"가 아니라 "이렇게 짠 이유는 이 설계 원칙 때문이에요"라고 말할 수 있는 상태. 4주간의 공부가 그 방향으로 가는 첫 발판이 됐다고 생각합니다.
4주간 수고 많으셨습니다. 🚀
'우아한테크코스 > 레벨0' 카테고리의 다른 글
| 레벨0 - 제네릭과 널, 그리고 3주간의 흐름 (0) | 2026.02.17 |
|---|---|
| 레벨0 - 2주차 회고, 그리고 회고를 회고하기 (0) | 2026.02.10 |
| 레벨0 - 생성자와 프로퍼티, 그리고 컴파일 과정 (2) | 2026.02.02 |
