스레딩을 통한 성능 개선

Android에서 스레드를 잘 사용하면 앱의 성능을 높일 수 있습니다. 이 페이지에서는 스레드 작업의 여러 측면을 설명합니다. UI 스레드(기본 스레드) 작업, 앱 수명 주기와 스레드 우선순위 사이의 관계, 스레드 복잡성 관리를 돕기 위해 플랫폼이 제공하는 메서드 등이 있습니다. 이 페이지에서는 이러한 각각의 영역에서 발생할 수 있는 위험과 이를 방지하는 전략을 설명합니다.

기본 스레드

사용자가 앱을 실행하면 Android는 실행 스레드와 함께 새로운 Linux 프로세스를 만듭니다. UI 스레드라고도 하는 이 기본 스레드는 화면에서 발생하는 모든 작업을 담당합니다. 작동 방식을 이해하면 기본 스레드를 사용하여 가능한 최고의 성능을 내는 앱을 설계하는 데 도움이 될 수 있습니다.

내부

기본 스레드의 디자인은 매우 간단합니다. 유일한 작업은 앱이 종료될 때까지 스레드로부터 안전한 작업 대기열에서 작업 블록을 가져와 실행하는 것입니다. 프레임워크는 다양한 장소에서 이러한 작업 블록의 일부를 생성합니다. 다양한 장소에는 수명 주기 정보와 연결된 콜백, 입력과 같은 사용자 이벤트, 다른 앱 및 프로세스에서 들어오는 이벤트가 포함됩니다. 또한 앱은 프레임워크를 사용하지 않고 단독으로 블록을 대기열에 명시적으로 추가할 수 있습니다.

앱에서 실행하는 거의 모든 코드 블록은 입력, 레이아웃 확장 또는 그리기와 같은 이벤트 콜백에 연결됩니다. 무언가가 이벤트를 트리거하면 이벤트가 발생한 스레드에서 이벤트를 기본 스레드의 메시지 대기열로 푸시합니다. 그러면 기본 스레드가 이벤트를 서비스할 수 있습니다.

애니메이션 또는 화면 업데이트가 발생하는 동안 시스템은 초당 60프레임으로 매끄럽게 렌더링하기 위해 약 16밀리초마다 작업 블록(화면 그리기를 담당)을 실행하려고 합니다. 시스템이 이 목표에 도달하려면 UI/뷰 계층 구조를 기본 스레드에서 업데이트해야 합니다. 그러나 기본 스레드의 메시지 대기열에 포함된 작업이 너무 많거나 너무 길어서 기본 스레드가 업데이트를 빠르게 완료하지 못한다면 앱은 이 작업을 작업자 스레드로 이동해야 합니다. 기본 스레드가 16밀리초 이내에 작업 블록 실행을 완료할 수 없는 경우 사용자는 장애, 지체, 입력에 대한 UI 반응성 부족을 관찰할 수 있습니다. 약 5초 동안 기본 스레드가 차단되면 시스템에서 애플리케이션 응답 없음(ANR) 대화상자를 표시하므로 사용자가 직접 앱을 종료할 수 있습니다.

기본 스레드에서 많은 작업 또는 긴 작업을 이동하여 원활한 렌더링과 사용자 입력에 대한 빠른 응답성을 방해하지 않는 것이 앱에서 스레딩을 채택하는 가장 큰 이유입니다.

스레드 및 UI 객체 참조

의도적으로 Android 뷰 객체는 스레드로부터 안전하지 않습니다. 앱은 기본 스레드에서 모두 UI 객체를 만들고 사용하며 제거해야 합니다. 기본 스레드가 아닌 스레드에서 UI 객체를 수정하거나 참조하려고 하면 예외, 자동 실패, 비정상 종료, 기타 정의되지 않은 오동작이 그 결과로 발생할 수 있습니다.

참조 문제는 명시적 참조와 암시적 참조라는 두 가지 카테고리로 나뉩니다.

명시적 참조

기본 스레드가 아닌 스레드의 많은 작업은 UI 객체 업데이트라는 최종 목표가 있습니다. 그러나 이러한 스레드 중 하나가 뷰 계층 구조의 객체에 액세스하면 애플리케이션이 그 결과로 불안정해질 수 있습니다. 작업자 스레드가 객체의 속성을 변경하는 동시에 다른 스레드에서 그 객체를 참조하면 그 결과는 정의되지 않습니다.

예를 들어 작업자 스레드에서 UI 객체 직접 참조를 보유하는 앱을 생각해보세요. 작업자 스레드의 객체에는 View 참조가 포함될 수 있지만 작업이 완료되기 전에 View는 뷰 계층 구조에서 삭제됩니다. 이러한 두 작업이 동시에 발생하면 참조는 View 객체를 메모리에 보관하고 속성을 설정합니다. 그러나 사용자에게는 이 객체가 전혀 표시되지 않고 앱은 이 객체 참조가 사라지면 객체를 삭제합니다.

또 다른 예에서 View 객체에는 이 객체를 소유한 활동 참조가 포함됩니다. 이 활동이 제거되었지만 직접 또는 간접적으로 이를 참조하는 스레드된 작업 블록이 계속 남아 있다면 가비지 컬렉터는 이 작업 블록이 실행을 완료할 때까지 활동을 수집하지 않습니다.

이 시나리오는 화면 회전과 같은 활동 수명 주기 이벤트가 발생하는 동안 스레드된 작업이 진행 중인 상황에서 문제를 야기할 수 있습니다. 시스템은 진행 중인 작업이 완료될 때까지 가비지 컬렉션을 실행할 수 없습니다. 결과적으로 가비지 컬렉션이 실행될 때까지 Activity 객체 두 개가 메모리에 있을 수 있습니다.

이와 같은 시나리오에서는 스레드된 작업에 있는 UI 객체의 명시적 참조를 앱에 포함하지 않는 것이 좋습니다. 이러한 참조를 피하면 위와 같은 메모리 누수를 방지하고 스레딩 경합도 피할 수 있습니다.

모든 경우에 앱은 기본 스레드에서 UI 객체만 업데이트해야 합니다. 즉, 여러 스레드가 작업을 기본 스레드에 다시 전달할 수 있는 협상 정책을 만들어야 합니다. 이를 통해 실제 UI 객체 업데이트 작업과 함께 최상위 활동이나 프래그먼트를 처리합니다.

암시적 참조

스레드된 객체의 일반적인 코드 디자인 결함은 아래 코드 스니펫에서 확인할 수 있습니다.

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

이 스니펫에서 결함은 코드가 스레딩 객체 MyAsyncTask를 일부 활동의 비정적 내부 클래스(또는 Kotlin의 내부 클래스)로 선언한다는 것입니다. 이 선언은 인클로징 Activity 인스턴스의 암시적 참조를 만듭니다. 결과적으로 객체에는 스레드된 작업이 완료될 때까지 활동 참조가 포함되어 참조된 활동의 삭제가 지연됩니다. 이러한 지연의 결과 메모리에 더 큰 부담을 줍니다.

이 문제를 해결하는 직접적인 방법은 오버로드된 클래스 인스턴스를 정적 클래스로 정의하거나 자체 파일에서 정의하여 암시적 참조를 삭제하는 것입니다.

또 다른 솔루션은 onDestroy와 같은 적절한 Activity 수명 주기 콜백에서 백그라운드 작업을 항상 취소 및 정리하는 것입니다. 하지만 이 방법은 손이 많이 가고 오류가 발생할 수 있습니다. 일반적으로 UI가 아닌 복잡한 로직을 활동에 직접 배치해서는 안 됩니다. 또한 AsyncTask는 현재 지원 중단되었으므로 새 코드에서 사용하지 않는 것이 좋습니다. 사용 가능한 동시 실행 프리미티브에 관한 자세한 내용은 Android의 스레딩을 참고하세요.

스레드 및 앱 활동 수명 주기

앱 수명 주기는 애플리케이션에서 스레딩이 작동하는 방식에 영향을 주기도 합니다. 활동이 제거된 후 스레드가 지속되어야 하는지 여부를 판단해야 할 수 있습니다. 스레드 우선순위 지정과 포그라운드/백그라운드에서의 활동 실행 여부 사이의 관계도 알고 있어야 합니다.

스레드 지속

스레드는 스레드를 생성하는 활동의 전체 기간을 지나 지속됩니다. 스레드는 활동의 생성이나 소멸에 상관없이 계속 중단없이 실행됩니다(단, 더 이상 활성 애플리케이션 구성요소가 없어지면 애플리케이션 프로세스와 함께 종료됩니다). 어떤 경우에는 이러한 지속성이 바람직합니다.

활동이 스레드된 작업 블록 세트를 생성한 다음 작업자 스레드가 블록을 실행할 수 있기 전에 제거되는 경우를 생각해 보세요. 앱은 진행 중인 블록으로 무엇을 해야 할까요?

블록이 더 이상 존재하지 않는 UI를 업데이트하려고 한다면 작업이 계속될 이유가 없습니다. 예를 들어 작업이 데이터베이스에서 사용자 정보를 로드한 후 뷰를 업데이트하려 한다면 스레드가 더 이상 필요하지 않습니다.

반면에 작업 패킷에는 UI와 완전히 관련되지 않은 몇 가지 이점이 있을 수 있습니다. 이 경우에는 스레드를 유지해야 합니다. 예를 들어 패킷은 이미지를 다운로드하고 디스크에 캐시하며 연결된 View 객체를 업데이트하려고 대기할 수 있습니다. 객체가 더 이상 존재하지 않더라도 사용자가 제거된 활동으로 돌아가는 경우에 이미지를 다운로드하고 캐시하는 작업은 여전히 유용할 수 있습니다.

모든 스레딩 객체의 수명 주기 응답을 수동으로 관리하는 작업은 대단히 복잡해질 수 있습니다. 올바르게 관리하지 않으면 앱에 메모리 경합과 성능 문제가 발생할 수 있습니다. ViewModelLiveData를 결합하면 수명 주기를 걱정하지 않고도 데이터를 로드하고 데이터가 변경될 때 알림을 받을 수 있습니다. ViewModel 객체가 이 문제를 해결하는 하나의 솔루션입니다. ViewModel은 구성 변경 전체에 걸쳐 유지 관리되므로 뷰 데이터를 쉽게 지속할 수 있습니다. ViewModel에 관한 자세한 내용은 ViewModel 가이드를, LiveData에 관한 자세한 내용은 LiveData 가이드를 참고하세요. 애플리케이션 아키텍처에 관해 자세히 알아보려면 앱 아키텍처 가이드를 읽어보세요.

스레드 우선순위

프로세스 및 애플리케이션 수명 주기에서 설명한 것처럼 앱의 스레드가 수신하는 우선순위는 앱 수명 주기에서 앱의 위치에 따라 부분적으로 달라집니다. 애플리케이션에서 스레드를 만들고 관리할 때 우선순위를 설정하여 올바른 스레드가 제때 올바른 우선순위를 갖도록 하는 것이 중요합니다. 너무 높게 설정하면 스레드가 UI 스레드와 RenderThread를 중단하여 앱이 프레임을 빠뜨릴 수 있습니다. 너무 낮게 설정하면 이미지 로드와 같은 비동기 작업이 필요한 속도보다 느리게 실행될 수 있습니다.

스레드를 만들 때마다 setThreadPriority()를 호출해야 합니다. 시스템의 스레드 스케줄러는 우선순위가 높은 스레드를 우선시하며 이러한 우선순위와 최종적으로 모든 작업을 완수해야 할 필요성 사이에서 균형을 유지합니다. 일반적으로 기기에서 총 실행 시간의 약 95%를 포그라운드 그룹의 스레드가 얻는 반면 백그라운드 그룹은 약 5%를 얻습니다.

또한 시스템은 Process 클래스를 사용하여 각 스레드에 자체 우선순위 값을 할당합니다.

기본적으로 시스템은 스레드의 우선순위를 생성 스레드와 동일한 우선순위 및 그룹 멤버십으로 설정합니다. 그러나 애플리케이션은 setThreadPriority()를 사용하여 스레드 우선순위를 명시적으로 조정할 수 있습니다.

Process 클래스는 앱이 스레드 우선순위를 설정하는 데 사용할 수 있는 상수 세트를 제공하여 우선순위 값을 할당하는 작업의 복잡성을 줄이는 데 도움이 됩니다. 예를 들어 THREAD_PRIORITY_DEFAULT는 스레드의 기본값을 나타냅니다. 앱은 덜 긴급한 작업을 실행하는 스레드의 스레드 우선순위를 THREAD_PRIORITY_BACKGROUND로 설정해야 합니다.

앱은 THREAD_PRIORITY_LESS_FAVORABLETHREAD_PRIORITY_MORE_FAVORABLE 상수를 증분기로 사용하여 상대적 우선순위를 설정할 수 있습니다. 스레드 우선순위 목록은 Process 클래스의 THREAD_PRIORITY 상수를 참고하세요.

스레드 관리에 관한 자세한 내용은 ThreadProcess 클래스에 관한 참조 문서를 확인하세요.

스레딩을 위한 도우미 클래스

Kotlin을 기본 언어로 사용하는 개발자의 경우 코루틴을 사용하는 것이 좋습니다. 코루틴은 콜백 없는 비동기 코드 작성과 범위 지정, 취소, 오류 처리를 위한 구조화된 동시 실행 등 다양한 이점을 제공합니다.

프레임워크는 스레딩을 지원하기 위해 Thread, Runnable, Executors 클래스 및 추가적인 HandlerThread 클래스와 같은 동일한 자바 클래스 및 프리미티브도 제공합니다. 자세한 내용은 Android의 스레딩을 참고하세요.

HandlerThread 클래스

핸들러 스레드는 대기열에서 작업을 가져와서 작동하는 사실상 장기 실행 스레드입니다.

Camera 객체에서 미리보기 프레임을 가져오는 데 있어 일반적인 도전과제를 생각해 보세요. 카메라 미리보기 프레임에 등록하면 onPreviewFrame() 콜백에서 프레임을 수신합니다. 이 콜백은 호출된 이벤트 스레드에서 호출됩니다. 이 콜백이 UI 스레드에서 호출되었다면 매우 큰 픽셀 배열을 처리하는 작업은 렌더링 및 이벤트 처리 작업을 방해할 수 있습니다.

이 예에서는 앱이 Camera.open() 명령어를 핸들러 스레드의 작업 블록에 위임하면 연결된 onPreviewFrame() 콜백이 UI 스레드가 아닌 핸들러 스레드에 배치됩니다. 따라서 픽셀에서 장기 실행 작업을 하려는 경우 더 나은 솔루션일 수 있습니다.

앱에서 HandlerThread를 사용하여 스레드를 생성할 때 실행되는 작업의 유형을 기반으로 스레드의 우선순위를 설정하는 것을 잊지 마세요. CPU는 적은 수의 스레드만 병렬로 처리할 수 있습니다. 우선순위를 설정하면 다른 모든 스레드가 주의를 끌려고 경합할 때 시스템에서 이 작업을 예약하는 올바른 방법을 알 수 있습니다.

ThreadPoolExecutor 클래스

고도의 병렬 분산 작업으로 축소할 수 있는 특정 유형의 작업이 있습니다. 이러한 작업 중 한 예가 8메가픽셀 이미지의 각 8x8 블록 필터를 계산하는 것입니다. 이 계산으로 생성되는 순전한 작업 패킷 양 때문에 HandlerThread는 적절한 클래스가 아닙니다.

ThreadPoolExecutor는 이 프로세스를 더 쉽게 해주는 도우미 클래스입니다. 이 클래스는 스레드 그룹 생성을 관리하고 우선순위를 설정하며 스레드 간에 작업을 분산하는 방식을 관리합니다. 워크로드가 증가하거나 감소함에 따라 이 클래스가 더 많은 스레드를 만들거나 제거하여 워크로드에 맞춥니다.

또한 이 클래스는 앱에서 최적의 스레드 수를 생성하도록 도와줍니다. ThreadPoolExecutor 객체를 구성할 때 앱은 최소 및 최대 스레드 수를 설정합니다. ThreadPoolExecutor에 지정된 워크로드가 증가하면 이 클래스는 초기화된 최소 및 최대 스레드 수를 고려하고 해야 할 대기 중인 작업의 양을 고려합니다. 이러한 요소를 기반으로 ThreadPoolExecutor는 특정 시점에 활성 상태여야 하는 스레드 수를 결정합니다.

얼마나 많은 스레드를 만들어야 하나요?

소프트웨어 수준에서 코드에 수백 개의 스레드를 만드는 기능이 있다 하더라도 실제로 그렇게 하면 성능 문제가 발생할 수 있습니다. 앱은 백그라운드 서비스, 렌더기, 오디오 엔진, 네트워킹 등과 제한된 CPU 리소스를 공유합니다. CPU는 실제로 적은 수의 스레드만 병렬로 처리할 수 있습니다. 이 외의 모든 것은 우선순위 및 예약 문제를 일으킵니다. 이렇기 때문에 워크로드에 필요한 만큼의 스레드만 만드는 것이 중요합니다.

실제로 이 문제에 책임이 있는 여러 변수가 있지만 값(예: 4)을 선택하고 Systrace로 테스트하는 것이 다른 전략과 마찬가지로 견고한 전략입니다. 시행착오를 거쳐 문제에 부딪히지 않고 사용할 수 있는 최소 스레드 수를 알아낼 수 있습니다.

있어야 할 스레드 수를 판단할 때 또 다른 고려사항은 스레드가 무료가 아니고 메모리를 차지한다는 것입니다. 각 스레드는 최소 64k의 메모리 비용이 듭니다. 이는 특히 호출 스택이 크게 늘어나는 상황에서 기기에 설치된 많은 앱에 걸쳐 빠르게 증가합니다.

많은 시스템 프로세스와 타사 라이브러리는 자체 threadpool을 만드는 경우가 많습니다. 앱이 기존 threadpool을 재사용할 수 있다면 이 재사용으로 메모리 경합과 처리 리소스를 줄여 성능을 개선할 수 있습니다.