본문 바로가기
안드로이드

lateinit, 정말 안전한가

by 안드뽀개기 2026. 7. 14.
반응형

lateinit은 Kotlin이 주는 편리한 도구다. null 체크 없이 프로퍼티를 나중에 초기화하겠다고 선언하면, 컴파일러는 그 약속을 믿고 넘어간다. 문제는 그 약속이 깨졌을 때다. onCreate에서 초기화하기로 했던 값이 어떤 경로로든 빠지면, onResume이나 다른 시점에서 UninitializedPropertyAccessException이 터진다. 그것도 하필 프로덕션에서, 하필 재현하기 어려운 타이밍에.

이 글에서는 lateinit이 왜 이런 함정을 만드는지, 그리고 nullable로 바꾸는 것이 어떤 점에서 더 안전한 선택인지 코드로 짚어본다.


lateinit의 실체

lateinit var는 사실 컴파일러가 null 체크를 프로퍼티 접근 시점으로 미뤄주는 문법 설탕이다. 내부적으로는 backing field가 초기화되지 않은 상태로 존재하다가, getter가 호출되는 순간 값이 세팅되어 있는지 검사한다. 세팅이 안 되어 있으면 그때 예외를 던진다.

class MainActivity : AppCompatActivity() {
    lateinit var userName: String

    override fun onResume() {
        super.onResume()
        // onCreate에서 초기화를 놓쳤다면 여기서 크래시
        println(userName.length)
    }
}

이 코드는 컴파일 시점에는 아무 문제가 없다. userName이 String 타입이므로 null을 넣을 수도 없고, 안전하지 않은 접근이라는 경고도 없다. 컴파일러 입장에서는 완전히 정상적인 코드다. 하지만 실행 순서가 꼬이거나, 조건부 초기화 로직에 버그가 있거나, 테스트 코드에서 초기화 없이 인스턴스를 만들면 그대로 런타임 크래시로 이어진다.


nullable로 바꾸면 달라지는 것

같은 프로퍼티를 nullable로 선언하면, 컴파일러가 이 프로퍼티를 다룰 때마다 null 가능성을 강제로 처리하게 만든다.

class MainActivity : AppCompatActivity() {
    var userName: String? = null

    override fun onResume() {
        super.onResume()
        // 안전 호출을 안 쓰면 컴파일 경고가 뜬다
        userName?.let {
            println(it.length)
        }
    }
}

차이는 명확하다. lateinit은 초기화 여부를 런타임까지 몰라도 컴파일이 통과되지만, nullable은 매 사용 지점마다 null일 가능성을 코드로 명시하게 강제한다. 버그가 존재한다면 그 버그는 크래시가 아니라 조용히 안전 호출로 스킵되는 방식으로 드러난다. 이건 단순히 예외를 안 던진다는 뜻이 아니라, 개발자가 널 처리 분기를 직접 눈으로 확인하게 만든다는 의미다.


실전 패턴: ViewBinding 예시

Fragment에서 ViewBinding을 lateinit으로 다루다가 onDestroyView 이후 접근해서 크래시 나는 건 흔한 패턴이다. nullable + 커스텀 delegate로 바꾸면 라이프사이클 실수를 컴파일 타임에 가깝게 잡아낼 수 있다.

class HomeFragment : Fragment(R.layout.fragment_home) {

    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentHomeBinding.bind(view)
        binding.title.text = "Hello"
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

여기서 binding get() = _binding!!는 non-null assertion을 쓰지만, 이건 lateinit과 성격이 다르다. Fragment의 뷰 라이프사이클 안에서만 호출된다는 계약이 명시적으로 드러나 있고, onDestroyView에서 null로 되돌리는 코드가 바로 옆에 붙어 있어서 실수를 발견하기 쉽다. lateinit이었다면 이 라이프사이클 계약이 코드 어디에도 드러나지 않는다.


그래도 lateinit이 나은 경우

모든 상황에서 nullable이 정답은 아니다. Dagger나 Hilt 같은 DI 프레임워크는 필드 주입 방식에서 lateinit var를 요구한다. 프레임워크가 생성자 이후 시점에 필드를 채워 넣는 구조이기 때문에, nullable로 바꾸면 오히려 모든 사용처에 불필요한 null 체크가 붙는다.

class HomeFragment : Fragment() {
    @Inject
    lateinit var analyticsTracker: AnalyticsTracker
}

이 경우는 프레임워크가 초기화 타이밍을 보장하기 때문에 lateinit이 자연스럽다. 마찬가지로 테스트 코드에서 @Before에 항상 초기화되는 픽스처 객체도 lateinit이 nullable보다 코드를 깔끔하게 만든다. 문제는 lateinit 자체가 아니라, 초기화 시점이 프레임워크나 테스트 러너처럼 확실히 보장되지 않는 곳에 lateinit을 쓰는 습관이다.


판단 기준

정리하면 기준은 하나다. 초기화 타이밍을 컴파일러나 프레임워크가 보장해주는가, 아니면 개발자가 직접 순서를 관리해야 하는가. 후자라면 lateinit은 그 책임을 코드 밖으로 숨기는 셈이고, 언젠가 그 책임을 놓치는 순간 런타임 크래시로 돌아온다. nullable은 그 책임을 코드 안으로 끌고 들어와서 컴파일러가 매번 확인하게 만든다.

새 코드를 짤 때 lateinit을 쓰고 싶어지는 순간이 있다면, 그 초기화가 정말 프레임워크 수준에서 보장되는지 한 번 더 물어보는 게 좋다. 그게 아니라면 nullable로 시작해서 안전 호출을 붙이는 쪽이, 새벽 2시에 스택 트레이스를 들여다보는 상황을 줄여준다.

※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.

반응형