본문 바로가기
■Development■/《Android》

[Android] Android 개발의 기본, Activity 완벽 이해하기 (초보자 가이드) 2편

by 은스타 2020. 4. 8.
반응형

안드로이드 Activity 자주 발생하는 오류와 해결책 및 성능 최적화 팁

안녕하세요. Android 개발의 기본, Activity 완벽 이해하기 (초보자 가이드) 1편에 이어 2편을 연재하도록 하겠습니다. 2편은 1편보다 심화된 내용으로 정리해 보도록 하겠습니다.

1편은 아래 링크에서 다시 보실 수 있습니다.

Android 개발의 기본, Activity 완벽 이해하기 (초보자 가이드) 1편 (ttps://eunplay.tistory.com/169)


 

안드로이드 Activity 오류 해결 및 성능 최적화 완벽 가이드

 

안드로이드 앱을 개발하다 보면 Activity 관련 오류를 자주 마주치게 됩니다. 이런 오류는 앱 충돌이나 성능 저하로 이어질 수 있죠. 이 글에서는 안드로이드 Activity 개발 시 자주 발생하는 오류와 해결책, 그리고 성능을 최적화하는 방법을 쉽고 자세하게 알아보겠습니다.

목차

 

#10. 자주 발생하는 Activity 오류와 해결책

1. 메모리 누수(Memory Leak)

메모리 누수는 안드로이드 앱 개발에서 가장 흔히 발생하는 문제 중 하나입니다. 특히 Activity 컨텍스트를 잘못 사용할 때 자주 발생합니다.

주요 원인:

  • Activity 컨텍스트를 정적(static) 변수에 저장
  • 내부 클래스에서 암시적으로 Activity 참조 유지
  • 비동기 작업에서 Activity 참조를 오래 유지

해결책:

① Application 컨텍스트 사용하기

긴 수명주기가 필요한 객체에는 Activity 컨텍스트 대신 Application 컨텍스트를 사용합니다.

// 잘못된 방법 ❌
val context = this  // Activity 컨텍스트

// 올바른 방법 ✅
val context = applicationContext

② 약한 참조(WeakReference) 사용하기

비동기 작업에서는 Activity에 대한 강한 참조 대신 약한 참조를 사용합니다.

class MyAsyncTask(activity: MainActivity) {
    // 약한 참조로 Activity 저장
    private val weakActivity = WeakReference(activity)

    fun doWork() {
        // 작업 수행 후 UI 업데이트
        val activity = weakActivity.get()
        if (activity != null && !activity.isFinishing) {
            // 안전하게 UI 작업 수행
        }
    }
}

③ 생명주기 인식 컴포넌트 사용하기

ViewModel, LiveData, Coroutines와 LifecycleScope 등 생명주기를 인식하는 컴포넌트를 활용합니다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 생명주기에 맞게 자동으로 정리됨
        lifecycleScope.launch {
            // 비동기 작업 수행
        }
    }
}

2. 화면 회전 시 데이터 손실

안드로이드 기기를 회전하면 Activity가 재생성되면서 일시적인 데이터가 손실될 수 있습니다.

주요 원인:

  • 화면 회전 시 Activity 재생성
  • 임시 데이터를 로컬 변수에만 저장
  • 구성 변경 이벤트 처리 부재

해결책:

① ViewModel 사용하기

ViewModel은 화면 회전과 같은 구성 변경에도 데이터를 유지합니다.

// ViewModel 정의
class MainViewModel : ViewModel() {
    var userName: String = ""
    var userScore: Int = 0
}

// Activity에서 사용
class MainActivity : AppCompatActivity() {
    private lateinit var viewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // ViewModel 초기화
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)

        // 데이터 표시 또는 변경
        binding.textUserName.text = viewModel.userName
    }
}

② onSaveInstanceState() 활용하기

Bundle을 통해 데이터를 저장하고 복원할 수 있습니다.

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)

    // 데이터 저장
    outState.putString("user_name", userName)
    outState.putInt("score", score)
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    // 저장된 데이터 복원
    if (savedInstanceState != null) {
        userName = savedInstanceState.getString("user_name", "")
        score = savedInstanceState.getInt("score", 0)

        // UI 업데이트
        updateUI()
    }
}

③ savedStateHandle 사용하기 (권장)

최신 안드로이드에서는 ViewModel과 savedStateHandle을 함께 사용하는 방식을 권장합니다.

class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    var userName: String
        get() = savedStateHandle.get<String>("user_name") ?: ""
        set(value) { savedStateHandle.set("user_name", value) }
}

3. IllegalStateException: 이미 종료된 Activity

Activity가 이미 종료되었거나 종료 중인데 UI를 업데이트하려고 할 때 발생하는 오류입니다.

주요 원인:

  • 비동기 콜백에서 Activity가 종료된 후 UI 업데이트 시도
  • 생명주기를 고려하지 않은 지연된 작업

해결책:

① Activity 상태 확인하기

UI를 업데이트하기 전에 Activity가 유효한 상태인지 확인합니다.

private fun updateUI(data: String) {
    if (!isFinishing && !isDestroyed) {
        binding.textView.text = data
    }
}

② 생명주기 인식 API 사용하기

LiveData나 Flow와 같은 생명주기 인식 API를 사용하면 자동으로 안전하게 처리됩니다.

viewModel.userData.observe(this) { data ->
    // LiveData는 자동으로 생명주기를 인식하므로 안전하게 UI 업데이트
    binding.textView.text = data
}

③ 작업 취소 구현하기

코루틴의 경우 생명주기에 맞춰 작업을 자동으로 취소할 수 있습니다.

// Activity나 Fragment의 lifecycleScope 사용
val job = lifecycleScope.launch {
    val result = fetchData() // 시간이 오래 걸리는 작업
    updateUI(result) // 자동으로 생명주기 확인
}

override fun onDestroy() {
    super.onDestroy()
    // 명시적으로 취소할 필요 없음 - lifecycleScope가 알아서 처리
}

4. ANR(Application Not Responding)

앱이 메인 스레드에서 오래 걸리는 작업을 실행할 때 발생하는 오류입니다. 시스템이 "앱이 응답하지 않습니다" 대화상자를 표시하게 됩니다.

주요 원인:

  • 메인 스레드에서 네트워크 요청 수행
  • 메인 스레드에서 파일 I/O 작업 수행
  • 무거운 계산 작업을 UI 스레드에서 실행

해결책:

① 코루틴 사용하기

코루틴은 비동기 작업을 간결하게 처리할 수 있게 해줍니다.

// IO 디스패처에서 무거운 작업 수행
lifecycleScope.launch(Dispatchers.IO) {
    // 네트워크 요청, 파일 읽기 등 무거운 작업
    val data = repository.fetchData()

    // UI 업데이트는 메인 스레드에서
    withContext(Dispatchers.Main) {
        binding.textView.text = data
    }
}

② WorkManager 활용하기

백그라운드에서 실행해야 하는 작업, 특히 앱이 종료된 후에도 실행되어야 하는 작업에는 WorkManager를 사용합니다.

val workRequest = OneTimeWorkRequestBuilder<DataSyncWorker>()
    .setConstraints(Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build())
    .build()

WorkManager.getInstance(context).enqueue(workRequest)

③ 무거운 작업 분리하기

초기화 과정에서 무거운 작업을 분리하고 지연 초기화를 활용합니다.

// 필요할 때만 초기화되는 무거운 객체
private val expensiveResource by lazy {
    // 시간이 오래 걸리는 초기화 작업
    HeavyResourceManager()
}

private fun onButtonClick() {
    // 버튼 클릭 시에만 초기화됨
    val result = expensiveResource.process()
}

5. 프래그먼트 트랜잭션 오류

Activity의 상태가 저장된 후 프래그먼트 트랜잭션을 수행할 때 발생하는 오류입니다.

주요 원인:

  • onSaveInstanceState() 호출 후 프래그먼트 트랜잭션 시도
  • Activity 종료 중에 새 프래그먼트 추가 시도

해결책:

① 올바른 생명주기 단계에서 트랜잭션 수행하기

프래그먼트 트랜잭션은 Activity가 활성 상태일 때만 수행해야 합니다.

override fun onResumeFragments() {
    super.onResumeFragments()
    // 이 시점에서 프래그먼트 트랜잭션 수행이 안전함
    if (shouldShowDetailsFragment) {
        supportFragmentManager.beginTransaction()
            .replace(R.id.container, DetailsFragment())
            .commit()
    }
}

② 상태 확인 후 트랜잭션 수행하기

트랜잭션 전에 Activity 상태를 확인합니다.

private fun showFragment() {
    if (!isFinishing && !supportFragmentManager.isStateSaved) {
        supportFragmentManager.beginTransaction()
            .replace(R.id.container, MyFragment())
            .commit()
    }
}

③ commitAllowingStateLoss() 사용하기 (주의해서 사용)

정말 필요한 경우에만 commitAllowingStateLoss()를 사용할 수 있습니다. 하지만 상태 손실 가능성을 염두에 두어야 합니다.

// 상태 손실을 허용하지만, 가능하면 피하는 것이 좋음
supportFragmentManager.beginTransaction()
    .replace(R.id.container, fragment)
    .commitAllowingStateLoss()

6. Activity 스택 관리 문제

Activity 스택을 잘못 관리하면 사용자 경험이 저하되고, 백 버튼 동작이 이상해질 수 있습니다.

주요 원인:

  • Intent 플래그를 잘못 사용
  • Activity 인스턴스가 중복 생성
  • 잘못된 실행 모드(launchMode) 설정

해결책:

① 적절한 launchMode 설정하기

Activity의 실행 모드를 목적에 맞게 설정합니다.

<!-- AndroidManifest.xml -->
<activity
    android:name=".MainActivity"
    android:launchMode="singleTop" />

주요 launchMode 옵션:

  • standard: 기본값, 항상 새 인스턴스 생성
  • singleTop: 최상위에 있을 때만 재사용
  • singleTask: 태스크당 하나의 인스턴스만 허용
  • singleInstance: 전용 태스크에서 단일 인스턴스

② Intent 플래그 활용하기

상황에 맞는 Intent 플래그를 사용합니다.

// 기존 인스턴스로 이동하고 그 위의 Activity 모두 제거
val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
startActivity(intent)

③ 백 스택 관리하기

TaskStackBuilder를 사용하여 올바른 백 스택을 구성합니다.

// 알림에서 딥링크 처리 시 백 스택 구성
val stackBuilder = TaskStackBuilder.create(this)
stackBuilder.addParentStack(DetailActivity::class.java)
stackBuilder.addNextIntent(detailIntent)
stackBuilder.startActivities()

7. Context 사용 오류

잘못된 Context를 사용하면 예상치 못한 문제가 발생할 수 있습니다.

주요 원인:

  • 잘못된 종류의 Context 사용
  • Context 누수
  • 생명주기가 짧은 Context를 오래 유지

해결책:

① 적절한 Context 유형 사용하기

작업에 맞는 Context 유형을 선택합니다.

// UI 관련 작업 (대화상자, 토스트 등)
val uiContext = this  // Activity Context

// 앱 전체 리소스 접근 (데이터베이스, 파일 등)
val appContext = applicationContext

② Context 참조 제한하기

싱글톤이나 정적 변수에 Activity Context를 저장하지 않습니다.

// 잘못된 방법 ❌
object MySingleton {
    private var activityContext: Context? = null

    fun init(context: Context) {
        this.activityContext = context  // 메모리 누수 위험
    }
}

// 올바른 방법 ✅
object MySingleton {
    private var appContext: Context? = null

    fun init(context: Context) {
        // Application Context만 저장
        this.appContext = context.applicationContext
    }
}

③ 컨텍스트 참조 정리하기

필요 없어진 Context 참조는 명시적으로 정리합니다.

class MyManager(context: Context) {
    private var contextRef = WeakReference(context)

    fun doSomething() {
        val context = contextRef.get() ?: return
        // 작업 수행
    }

    fun release() {
        contextRef.clear()  // 참조 정리
    }
}

 

#11. Activity 성능 최적화 팁

1. 레이아웃 최적화

복잡한 레이아웃은 렌더링 성능을 저하시키고 메모리를 더 많이 사용합니다.

① 뷰 계층 간소화하기

중첩된 레이아웃을 최소화하고 ConstraintLayout을 활용합니다.

<!-- 비효율적인 중첩 레이아웃 ❌ -->
<LinearLayout>
    <LinearLayout>
        <RelativeLayout>
            <TextView />
        </RelativeLayout>
    </LinearLayout>
</LinearLayout>

<!-- 효율적인 ConstraintLayout 사용 ✅ -->
<androidx.constraintlayout.widget.ConstraintLayout>
    <TextView
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

② ViewBinding 사용하기

ViewBinding은 findViewById보다 효율적이고 타입 안전합니다.

// build.gradle
android {
    buildFeatures {
        viewBinding true
    }
}

// Activity에서 사용
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // findViewById 대신 바인딩 사용
        binding.buttonSubmit.setOnClickListener {
            // 클릭 처리
        }
    }
}

③ 불필요한 inflate 줄이기

반복되는 항목은 RecyclerView를 사용하고, ViewHolder 패턴을 적용합니다.

class MyAdapter(private val items: List<Item>) : 
    RecyclerView.Adapter<MyAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        // inflate는 필요할 때만 수행됨
        val binding = ItemLayoutBinding.inflate(
            LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(items[position])
    }

    class ViewHolder(private val binding: ItemLayoutBinding) : 
        RecyclerView.ViewHolder(binding.root) {

        fun bind(item: Item) {
            binding.textTitle.text = item.title
            // 바인딩 작업
        }
    }
}

2. 생명주기에 맞춘 리소스 관리

리소스를 생명주기에 맞게 관리하면 메모리 누수를 방지하고 앱 성능을 향상시킬 수 있습니다.

① 리소스 초기화 및 해제 타이밍

각 리소스를 적절한 생명주기 메서드에서 초기화하고 해제합니다.

class CameraActivity : AppCompatActivity() {
    private var camera: Camera? = null

    override fun onResume() {
        super.onResume()
        // 카메라 초기화
        camera = Camera.open()
    }

    override fun onPause() {
        super.onPause()
        // 카메라 해제
        camera?.release()
        camera = null
    }
}

② DefaultLifecycleObserver 활용하기

생명주기 관찰자를 사용하여 리소스 관리를 모듈화합니다.

class LocationManager(private val activity: AppCompatActivity) : DefaultLifecycleObserver {

    init {
        activity.lifecycle.addObserver(this)
    }

    override fun onResume(owner: LifecycleOwner) {
        // 위치 업데이트 시작
        startLocationUpdates()
    }

    override fun onPause(owner: LifecycleOwner) {
        // 위치 업데이트 중지
        stopLocationUpdates()
    }
}

// Activity에서 사용
class MainActivity : AppCompatActivity() {
    private lateinit var locationManager: LocationManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 생명주기에 맞게 자동으로 관리됨
        locationManager = LocationManager(this)
    }
}

③ 이미지 리소스 효율적으로 관리하기

비트맵을 적절한 크기로 로드하고 사용하지 않을 때 해제합니다.

private fun decodeSampledBitmap(res: Resources, resId: Int, reqWidth: Int, reqHeight: Int): Bitmap {
    // 이미지 크기 확인
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = true
    }
    BitmapFactory.decodeResource(res, resId, options)

    // 샘플 크기 계산
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)

    // 리사이즈된 비트맵 로드
    options.inJustDecodeBounds = false
    return BitmapFactory.decodeResource(res, resId, options)
}

private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val height = options.outHeight
    val width = options.outWidth
    var inSampleSize = 1

    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2

        while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
            inSampleSize *= 2
        }
    }

    return inSampleSize
}

3. 비동기 처리 최적화

효율적인 비동기 처리는 UI 응답성을 향상시키고 ANR을 방지합니다.

① 코루틴 효율적으로 사용하기

코루틴 컨텍스트와 스코프를 적절히 사용합니다.

class MainActivity : AppCompatActivity() {

    // 네트워크 요청을 위한 코루틴
    private fun fetchData() {
        // IO 디스패처에서 실행
        lifecycleScope.launch(Dispatchers.IO) {
            try {
                val result = api.getData()

                // UI 업데이트는 메인 스레드에서
                withContext(Dispatchers.Main) {
                    updateUI(result)
                }
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    showError(e.message)
                }
            }
        }
    }

    // 무거운 계산 작업을 위한 코루틴
    private fun processData(data: List<Item>) {
        // Default 디스패처에서 실행 (CPU 집약적 작업)
        lifecycleScope.launch(Dispatchers.Default) {
            val processed = data.map { processItem(it) }

            withContext(Dispatchers.Main) {
                showResults(processed)
            }
        }
    }
}

② 작업 취소 처리하기

사용자가 화면을 떠날 때 불필요한 작업을 취소합니다.

class SearchActivity : AppCompatActivity() {
    private var searchJob: Job? = null

    private fun performSearch(query: String) {
        // 이전 검색 작업 취소
        searchJob?.cancel()

        // 새 검색 작업 시작
        searchJob = lifecycleScope.launch {
            delay(300) // 디바운싱
            val results = repository.search(query)
            updateSearchResults(results)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        searchJob?.cancel()
    }
}

③ Flow 활용하기

데이터 스트림을 효율적으로 처리하기 위해 Flow를 사용합니다.

class UserViewModel : ViewModel() {

    private val _searchResults = MutableStateFlow<List<User>>(emptyList())
    val searchResults = _searchResults.asStateFlow()

    fun searchUsers(query: String) {
        viewModelScope.launch {
            // 디바운싱, 필터링, 변환 등을 한 번에 처리
            userRepository.searchUsers(query)
                .debounce(300) // 타이핑 중 연속 요청 방지
                .filter { it.isNotEmpty() }
                .map { users -> users.sortedBy { it.name } }
                .collect { results ->
                    _searchResults.value = results
                }
        }
    }
}

// Activity에서 사용
lifecycleScope.launch {
    viewModel.searchResults
        .collectLatest { users ->
            adapter.submitList(users)
        }
}

4. 렌더링 성능 향상

UI 렌더링 성능을 최적화하면 앱이 더 부드럽게 작동합니다.

① 하드웨어 가속 활성화

하드웨어 가속을 활성화하여 렌더링 성능을 향상시킵니다.

<!-- AndroidManifest.xml -->
<application
    android:hardwareAccelerated="true"
    ... >

    <!-- 특정 Activity만 하드웨어 가속 비활성화 (필요한 경우) -->
    <activity
        android:name=".LegacyActivity"
        android:hardwareAccelerated="false" />
</application>

② 오버드로우 줄이기

불필요한 배경이나 중첩된 투명 뷰를 제거합니다.

// 불필요한 배경 제거
view.background = null

// 투명도 최소화
view.alpha = 1.0f

// 중첩된 뷰에 동일한 배경색 설정 방지
containerView.setBackgroundColor(color)
childView.background = null // 자식 뷰 배경 제거

③ 애니메이션 최적화

Property Animation을 사용하고 불필요한 애니메이션을 제거합니다.

// ObjectAnimator 사용
val animator = ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f)
animator.duration = 300
animator.start()

// 여러 애니메이션을 함께 실행
val animSet = AnimatorSet()
animSet.playTogether(
    ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 0f, 100f),
    ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f)
)
animSet.duration = 300
animSet.start()

5. 메모리 사용 최적화

메모리를 효율적으로 사용하면 앱의 안정성과 성능이 향상됩니다.

① 효율적인 컬렉션 사용하기

상황에 맞는 적절한 컬렉션을 선택합니다.

// 정수 키를 사용하는 경우 HashMap 대신 SparseArray 사용
val userMap = SparseArray<User>()
userMap.put(1, User("John"))
val user = userMap.get(1)

// 문자열 키를 사용하는 경우 ArrayMap 고려
val configMap = ArrayMap<String, String>()
configMap["theme"] = "dark"

② 객체 풀 활용하기

자주 생성되고 폐기되는 객체는 풀링을 통해 재사용합니다.

class ObjectPool<T>(
    private val maxSize: Int,
    private val factory: () -> T
) {
    private val pool = ArrayDeque<T>(maxSize)

    fun acquire(): T {
        return if (pool.isEmpty()) factory() else pool.removeFirst()
    }

    fun release(obj: T) {
        if (pool.size < maxSize) {
            pool.addLast(obj)
        }
    }
}

// 사용 예시
val rectPool = ObjectPool(50) { Rect() }

fun processRects() {
    val rect = rectPool.acquire()
    try {
        // rect 사용
    } finally {
        rectPool.release(rect)
    }
}

③ 메모리 캐시 구현하기

자주 사용되는 리소스는 메모리에 캐싱합니다.

class BitmapCache {
    private val maxMemory = Runtime.getRuntime().maxMemory() / 1024
    private val cacheSize = (maxMemory / 4).toInt()

    private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            // 크기를 KB 단위로 반환
            return bitmap.byteCount / 1024
        }
    }

    fun addBitmapToCache(key: String, bitmap: Bitmap) {
        if (getBitmapFromCache(key) == null) {
            memoryCache.put(key, bitmap)
        }
    }

    fun getBitmapFromCache(key: String): Bitmap? {
        return memoryCache.get(key)
    }

    fun clear() {
        memoryCache.evictAll()
    }
}

6. 앱 시작 시간 단축

앱 시작 시간은 사용자 경험에 큰 영향을 미칩니다.

① 콜드 스타트 최적화하기

시작 화면을 테마로 구현하여 콜드 스타트 지연을 감춥니다.

<!-- res/drawable/splash_background.xml -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@color/colorBackground" />
    <item>
        <bitmap
            android:gravity="center"
            android:src="@drawable/logo" />
    </item>
</layer-list>

<!-- res/values/styles.xml -->
<style name="SplashTheme" parent="Theme.AppCompat.NoActionBar">
    <item name="android:windowBackground">@drawable/splash_background</item>
</style>

<!-- AndroidManifest.xml -->
<activity
    android:name=".SplashActivity"
    android:theme="@style/SplashTheme"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

② 초기화 작업 지연시키기

필수적이지 않은 초기화 작업은 백그라운드로 이동하거나 필요할 때까지 지연시킵니다.

class MyApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        // 필수 초기화만 즉시 수행
        initializeCriticalComponents()

        // 나머지는 지연 초기화
        delayedInit()
    }

    private fun initializeCriticalComponents() {
        // 앱 실행에 꼭 필요한 초기화만 수행
    }

    private fun delayedInit() {
        // 메인 스레드의 유휴 시간에 초기화 예약
        Looper.myQueue().addIdleHandler {
            // 우선순위가 낮은 초기화 수행
            initializeNonCriticalComponents()
            false // 한 번만 실행
        }

        // 또는 백그라운드에서 초기화
        Thread {
            // 백그라운드 초기화 작업
            initializeInBackground()
        }.start()
    }
}

③ 필요할 때만 로드하기

필요한 기능만 먼저 로드하고 나머지는 필요할 때 로드합니다.

// ViewPager에서 필요한 페이지만 미리 로드
viewPager.offscreenPageLimit = 1

// 지연 초기화 사용
private val heavyFeature by lazy {
    // 필요할 때만 초기화됨
    HeavyFeature()
}

private fun onFeatureButtonClick() {
    // 버튼 클릭 시에만 초기화됨
    val result = heavyFeature.doWork()
}

7. 효율적인 데이터 로딩

데이터를 효율적으로 로드하면 앱의 반응성이 향상됩니다.

① 페이징 구현하기

대량의 데이터는 한 번에 모두 로드하지 않고 페이징을 사용합니다.

// Paging 3 라이브러리 사용
class UserPagingSource(private val api: UserApi) : PagingSource<Int, User>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        val page = params.key ?: 1
        return try {
            val response = api.getUsers(page, params.loadSize)

            LoadResult.Page(
                data = response.users,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.isLastPage) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

// ViewModel에서 사용
val userFlow = Pager(
    config = PagingConfig(
        pageSize = 20,
        enablePlaceholders = false,
        prefetchDistance = 5
    ),
    pagingSourceFactory = { UserPagingSource(api) }
).flow.cachedIn(viewModelScope)

// Activity에서 사용
lifecycleScope.launch {
    viewModel.userFlow.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

② 데이터 프리페칭 구현하기

사용자가 필요로 할 가능성이 높은 데이터를 미리 로드합니다.

class DataPreloader(
    private val repository: DataRepository,
    private val scope: CoroutineScope
) {

    fun preloadRelatedItems(currentItemId: String) {
        scope.launch(Dispatchers.IO) {
            // 연관 항목 미리 로드
            val relatedIds = repository.getRelatedItemIds(currentItemId)

            // 병렬로 프리페칭
            relatedIds.map { itemId ->
                async {
                    repository.preloadItem(itemId)
                }
            }.awaitAll()
        }
    }
}

③ 캐싱 전략 구현하기

데이터를 메모리와 디스크에 적절히 캐싱합니다.

class DataRepository(
    private val api: ApiService,
    private val database: AppDatabase,
    private val preferences: SharedPreferences
) {
    // 메모리 캐시
    private val memoryCache = LruCache<String, Data>(100)

    suspend fun getData(id: String, forceRefresh: Boolean = false): Data {
        // 1. 메모리 캐시 확인
        memoryCache.get(id)?.let {
            if (!forceRefresh) return it
        }

        // 2. 로컬 데이터베이스 확인
        if (!forceRefresh) {
            val localData = database.dataDao().getById(id)
            if (localData != null) {
                memoryCache.put(id, localData)
                return localData
            }
        }

        // 3. 네트워크에서 로드
        val networkData = api.fetchData(id)

        // 4. 캐시에 저장
        memoryCache.put(id, networkData)
        database.dataDao().insert(networkData)

        return networkData
    }
}

실제 앱 개발에 적용하기

성능 모니터링 도구

앱 성능을 모니터링하고 개선하기 위한 도구들입니다.

① Android Profiler

메모리, CPU, 네트워크 사용량을 실시간으로 모니터링합니다.

② Android Vitals

Google Play Console에서 실제 사용자 환경에서의 앱 성능을 확인합니다.

③ StrictMode

개발 중에 메인 스레드 차단 및 디스크/네트워크 액세스를 감지합니다.

// 개발 빌드에서만 사용
if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .build()
    )

    StrictMode.setVmPolicy(
        StrictMode.VmPolicy.Builder()
            .detectLeakedSqlLiteObjects()
            .detectLeakedClosableObjects()
            .penaltyLog()
            .build()
    )
}

결론

안드로이드 Activity의 자주 발생하는 오류를 해결하고 성능을 최적화하는 방법에 대해 알아보았습니다. 메모리 누수, ANR, 화면 회전 시 데이터 손실 등의 문제는 적절한 방법으로 해결할 수 있습니다. 또한, 레이아웃 최적화, 효율적인 리소스 관리, 비동기 처리 개선 등을 통해 앱의 성능을 향상시킬 수 있습니다.

앱 개발 시 이러한 베스트 프랙티스를 따르면 안정적이고 빠른 앱을 만들 수 있으며, 사용자 경험을 크게 향상시킬 수 있습니다. 가장 중요한 것은 생명주기를 제대로 이해하고 관리하는 것이며, 최신 안드로이드 아키텍처 구성요소를 활용하는 것입니다.

여러분의 앱이 더 안정적이고 빠르게 동작하기를 바랍니다!

긴 글 읽어주셔서 감사합니다

끝.

반응형