안드로이드 View의 생명주기

aufcl4858·2025년 10월 30일

안드로이드 개발을 하다 보면 커스텀 View를 만들거나 성능 최적화를 해야 하는 경우가 종종 있다. 이때 View의 생명주기를 제대로 이해하지 못하면 메모리 누수나 성능 문제가 발생할 수 있다. 이번 글에서는 안드로이드 View의 생명주기와 실무에서 자주 마주치는 문제들에 대해 정리해보려 한다.

View 생명주기

안드로이드 View는 생성부터 화면에 표시되기까지 여러 단계를 거친다. 각 단계는 명확한 목적을 가지고 있으며, 적절한 시점에 적절한 작업을 수행하는 것이 중요하다.

1. Constructors (생성자)

View 객체가 생성되는 첫 번째 단계이다. XML 레이아웃에서 인플레이트되거나 코드에서 직접 생성될 때 호출된다. 이 단계에서는 기본적인 초기화만 수행하는 것이 좋다.

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
    // 기본 속성만 초기화
    private val paint = Paint()
}

2. onAttachedToWindow()

View가 Window에 연결되는 시점이다. 이 단계부터 View는 실제로 화면에 그려질 준비가 된다. 여기서 중요한 것은 이 시점이 리소스를 초기화하기에 가장 적절한 타이밍이라는 것이다.

3. measure() → onMeasure()

View의 크기를 측정하는 단계이다. 부모 View로부터 받은 크기 제약(MeasureSpec)을 바탕으로 자신의 크기를 결정한다. 커스텀 View를 만들 때 onMeasure()를 오버라이드하여 원하는 크기 계산 로직을 구현할 수 있다.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val desiredWidth = 200.dpToPx()
    val desiredHeight = 200.dpToPx()
    
    val width = resolveSize(desiredWidth, widthMeasureSpec)
    val height = resolveSize(desiredHeight, heightMeasureSpec)
    
    setMeasuredDimension(width, height)
}

4. layout() → onLayout()

측정된 크기를 바탕으로 View의 실제 위치를 결정하는 단계이다. ViewGroup의 경우 이 단계에서 자식 View들의 위치도 함께 배치한다.

5. draw() → onDraw()

실제로 View를 그리는 단계이다. Canvas 객체를 사용하여 화면에 원하는 그래픽을 그린다. 커스텀 View의 핵심 로직이 들어가는 부분이다.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    // Canvas를 사용한 그리기 작업
    canvas.drawCircle(centerX, centerY, radius, paint)
}

6. onDetachedFromWindow

View가 화면과 부모 ViewGroup에서 제거될 때(가령, Activity 또는 Fragment가 소멸 중일 때), onDetachedFromWindow() 메서드가 호출된다. 이 단계는 리소스를 정리하거나 리스너를 분리하는 데 사용된다.

추가로 알아야 할 메서드

invalidate()

View를 다시 그려야 할 때 호출하는 메서드이다. onDraw()만 다시 실행되므로 비교적 가벼운 작업이다. UI 업데이트가 필요할 때 주로 사용한다.

requestLayout()

View의 크기나 위치가 변경되어 measure()부터 전체 과정을 다시 실행해야 할 때 사용한다. invalidate()보다 비용이 크므로 정말 필요한 경우에만 호출해야 한다.

리소스 관리

커스텀 View를 만들 때 가장 흔하게 겪는 문제는 메모리 누수이다. 특히 이미지 로딩이나 애니메이션 같은 비용이 큰 작업을 다룰 때 주의해야 한다.

리소스 초기화와 정리 규칙

초기화는 onAttachedToWindow()에서, 정리는 onDetachedFromWindow()에서 수행하는 것이 기본 원칙이다.

class CustomImageView : View {
    private var animator: ValueAnimator? = null
    private var bitmap: Bitmap? = null
    
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        // View가 Window에 연결될 때 리소스 초기화
        loadImage()
        startAnimation()
    }
    
    private fun startAnimation() {
        animator = ValueAnimator.ofFloat(0f, 1f).apply {
            duration = 1000
            repeatCount = ValueAnimator.INFINITE
            addUpdateListener { 
                invalidate()
            }
            start()
        }
    }
    
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        // View가 Window에서 분리될 때 리소스 정리
        animator?.cancel()
        animator = null
        bitmap?.recycle()
        bitmap = null
    }
}

이렇게 대칭적으로 리소스를 관리하면 메모리 누수를 효과적으로 방지할 수 있다.

왜 생성자가 아닌 onAttachedToWindow()인가?

생성자나 init 블록에서 리소스를 초기화하면 View가 실제로 사용되지 않는 상황에서도 리소스를 점유하게 된다. 특히 RecyclerView처럼 View를 재활용하는 경우 불필요한 리소스 낭비가 발생할 수 있다.

성능 최적화: onMeasure()와 onLayout()

복잡한 레이아웃을 다룰 때는 측정과 배치 과정에서 성능 병목이 발생하기 쉽다. 몇 가지 최적화 기법을 알아보자.

onMeasure() 최적화

1. 측정 결과 캐싱

동일한 MeasureSpec으로 반복 호출되는 경우 이전 결과를 재사용할 수 있다.

private var lastWidthSpec = 0
private var lastHeightSpec = 0
private var lastWidth = 0
private var lastHeight = 0

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    if (widthMeasureSpec == lastWidthSpec && heightMeasureSpec == lastHeightSpec) {
        setMeasuredDimension(lastWidth, lastHeight)
        return
    }
    
    // 측정 로직...
    val width = calculateWidth(widthMeasureSpec)
    val height = calculateHeight(heightMeasureSpec)
    
    setMeasuredDimension(width, height)
    
    lastWidthSpec = widthMeasureSpec
    lastHeightSpec = heightMeasureSpec
    lastWidth = width
    lastHeight = height
}

2. 복잡한 계산은 한 번만

크기가 변경될 때만 계산하고 결과를 저장해두는 것이 효율적이다.

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    // 크기 관련 계산을 여기서 한 번만 수행
    centerX = w / 2f
    centerY = h / 2f
    radius = min(w, h) / 2f * 0.8f
}

onLayout() 최적화

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    if (!changed) return // 크기나 위치가 변경되지 않았으면 스킵
    
    // 자식 View 배치 로직...
}

일반적인 최적화 전략

  1. 직접 그리지 않는 ViewGroup은 setWillNotDraw(true) 설정
  2. 하드웨어 가속 활용: 복잡한 그래픽은 하드웨어 레이어 사용
  3. requestLayout() 대신 invalidate(): 크기 변경이 없으면 invalidate()만 호출
  4. 동적 View가 많다면 RecyclerView 사용: View 재활용으로 성능 향상
  5. ConstraintLayout으로 레이아웃 평탄화: 중첩된 구조는 측정 횟수를 늘림

Lifecycle과의 통합: findViewTreeLifecycleOwner()

최근 안드로이드 개발에서는 Jetpack의 Lifecycle을 적극 활용하는 추세이다. View에서도 LifecycleOwner를 찾아 사용할 수 있는 편리한 기능이 있다. 이 함수는 LiveData, ViewModel 또는 LifecycleObserver와 같은 생명주기
인식 요소와 상호 작용해야 하는 커스텀 View나 서드파티 컴포넌트
로 작업할 때 특히 유용하다. 이를 통해 View는 호스팅 Activity나
Fragment에 대한 명시적인 의존성 없이 연관된 생명주기에 접근할 수
있다.

기본 사용법

class CustomView : View {
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        
        findViewTreeLifecycleOwner()?.lifecycle?.addObserver(object : LifecycleEventObserver {
            override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
                when (event) {
                    Lifecycle.Event.ON_PAUSE -> stopAnimation()
                    Lifecycle.Event.ON_RESUME -> startAnimation()
                    Lifecycle.Event.ON_DESTROY -> cleanup()
                    else -> {}
                }
            }
        })
    }
}

LiveData 관찰

커스텀 View에서 LiveData를 관찰할 수 있다.

fun observeData(liveData: LiveData<String>) {
    findViewTreeLifecycleOwner()?.let { owner ->
        liveData.observe(owner) { data ->
            updateUI(data)
        }
    }
}

코루틴 스코프 활용

findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
    // Lifecycle에 맞춰 자동으로 취소되는 코루틴
    repository.getData().collect { data ->
        updateView(data)
    }
}

findViewTreeLifecycleOwner()의 한계점

편리한 기능이지만 몇 가지 주의할 점이 있다.

1. null 반환 가능성

View가 아직 Window에 attach되지 않았거나 LifecycleOwner가 설정되지 않은 경우 null을 반환한다. 항상 null 체크가 필요하다.

2. 타이밍 문제

class CustomView : View {
    init {
        findViewTreeLifecycleOwner() // null! 아직 사용 불가
    }
    
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        findViewTreeLifecycleOwner() // 여기서부터 사용 가능
    }
}

생성자나 초기화 블록에서는 사용할 수 없다. onAttachedToWindow() 이후에만 안전하게 사용 가능하다.

3. 메모리 누수 주의

Observer를 등록했다면 반드시 해제해야 한다.

class CustomView : View {
    private var lifecycleObserver: LifecycleEventObserver? = null
    
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        
        lifecycleObserver = LifecycleEventObserver { _, event ->
            handleLifecycleEvent(event)
        }.also { observer ->
            findViewTreeLifecycleOwner()?.lifecycle?.addObserver(observer)
        }
    }
    
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        
        lifecycleObserver?.let { observer ->
            findViewTreeLifecycleOwner()?.lifecycle?.removeObserver(observer)
        }
        lifecycleObserver = null
    }
}

4. 잘못된 LifecycleOwner 참조

중첩된 Fragment 구조에서는 의도하지 않은 상위 Lifecycle을 가져올 수 있다. 예를 들어 DialogFragment 내부의 View가 Activity의 Lifecycle을 참조하는 경우가 있다.

마치며

View의 생명주기를 제대로 이해하는 것은 안드로이드 개발의 기본이다. 특히 커스텀 View를 만들거나 성능 최적화를 할 때 각 생명주기 메서드의 목적과 호출 시점을 정확히 아는 것이 중요하다.

핵심을 정리하면 다음과 같다.

  • 리소스 초기화는 onAttachedToWindow(), 정리는 onDetachedFromWindow()
  • 측정 결과 캐싱으로 onMeasure() 최적화
  • 크기 변경이 없으면 invalidate(), 있으면 requestLayout()
  • findViewTreeLifecycleOwner()는 편리하지만 null 체크와 정리 필수

실무에서 이런 원칙들을 지키면 메모리 누수나 성능 문제를 크게 줄일 수 있다. 특히 복잡한 커스텀 View를 다룰 때는 각 생명주기 단계에서 어떤 작업을 해야 하는지 명확히 설계하는 것이 중요하다.

profile
데브누누

0개의 댓글