Google은 흑인 공동체를 위한 인종 간 평등을 진전시키기 위해 노력하고 있습니다. Google에서 어떤 노력을 하고 있는지 확인하세요.

스레딩을 통한 성능 개선

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) {...}
        }
    }

자바

    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 인스턴스의 암시적 참조를 만듭니다. 결과적으로 객체에는 스레드된 작업이 완료될 때까지 활동 참조가 포함되어 참조된 활동의 삭제가 지연됩니다. 이러한 지연의 결과 메모리에 더 큰 부담을 줍니다.

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

또 다른 솔루션은 AsyncTask 객체를 정적 중첩 클래스로 선언(또는 Kotlin에서 내부 한정자를 삭제)하는 것입니다. 이렇게 하면 정적 중첩 클래스의 방식이 내부 클래스와 달라지기 때문에 암시적 참조 문제가 제거됩니다. 내부 클래스의 인스턴스는 외부 클래스의 인스턴스를 인스턴스화해야 하며 인클로징 인스턴스의 메서드와 필드에 직접 액세스할 수 있습니다. 반대로 정적 중첩 클래스에는 인클로징 클래스의 인스턴스에 대한 참조가 필요하지 않으므로 외부 클래스 멤버에 대한 참조가 없습니다.

Kotlin

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

자바

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

스레드 및 앱 활동 수명 주기

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

스레드 지속

스레드는 스레드를 생성하는 활동의 전체 기간을 지나 지속됩니다. 스레드는 활동의 생성이나 삭제에 상관없이 계속 중단없이 실행됩니다. 어떤 경우에는 이러한 지속성이 바람직합니다.

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

블록이 더 이상 존재하지 않는 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 클래스에 관한 참조 문서를 참조하세요.

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

프레임워크는 동일한 자바 클래스 및 프리미티브를 제공하여 Thread, RunnableExecutors 클래스와 같은 스레딩을 촉진합니다. Android용 스레드 애플리케이션 개발과 관련된 인지 부하를 줄이기 위해 프레임워크는 AsyncTaskLoaderAsyncTask와 같이 개발을 도울 수 있는 도우미 세트를 제공합니다. 각 도우미 클래스에는 일부 특정 스레딩 문제에 고유한 특정 성능의 미묘한 차이가 있습니다. 잘못된 상황에 잘못된 클래스를 사용하면 성능 문제가 발생할 수 있습니다.

AsyncTask 클래스

AsyncTask 클래스는 작업을 기본 스레드에서 작업자 스레드로 빠르게 이동해야 하는 앱에 유용한 간단한 프리미티브입니다. 예를 들어 입력 이벤트는 로드된 비트맵으로 UI를 업데이트할 필요성을 트리거할 수 있습니다. AsyncTask 객체는 비트맵 로드 및 디코딩을 대체 스레드에 오프로드할 수 있습니다. 이 처리 작업이 완료되면 AsyncTask 객체는 기본 스레드에서 다시 작업을 수신하여 UI를 업데이트하게 됩니다.

AsyncTask를 사용할 때 몇 가지 중요한 성능 측면에 유의해야 합니다. 먼저 기본적으로 앱은 앱이 생성한 모든 AsyncTask 객체를 단일 스레드로 푸시합니다. 따라서 이 객체는 직렬 방식으로 실행되며 기본 스레드와 마찬가지로, 특히 긴 작업 패킷이 대기열을 차단할 수 있습니다. 이러한 이유로 지속 시간이 5ms보다 짧은 작업 항목을 처리하는 데만 AsyncTask를 사용하는 것이 좋습니다.

AsyncTask 객체는 암시적 참조 문제의 가장 일반적인 위반자이기도 합니다. AsyncTask 객체는 명시적 참조와 관련된 위험을 제기하기도 하지만 이러한 위험은 때로 해결하기가 더 쉽습니다. 예를 들어 AsyncTask가 기본 스레드에서 콜백을 실행하면 AsyncTask에는 UI 객체를 올바르게 업데이트하기 위해 UI 객체 참조가 필요할 수 있습니다. 이러한 상황에서는 WeakReference를 사용하여 필수 UI 객체 참조를 저장할 수 있으며 AsyncTask가 기본 스레드에서 작동하면 객체에 액세스할 수 있습니다. 분명히 하자면 객체의 WeakReference를 유지한다고 해서 객체가 스레드로부터 안전한 것은 아닙니다. WeakReference는 명시적 참조 및 가비지 컬렉션 관련 문제를 처리하는 메서드를 제공할 뿐입니다.

HandlerThread 클래스

AsyncTask가 유용하기는 하지만 스레딩 문제와 관련하여 항상 적절한 솔루션은 아닐 수 있습니다. 대신 더 오래 실행되는 스레드에서 작업 블록을 실행하는 데 좀 더 전통적인 접근법과 수동으로 워크플로를 관리하는 기능이 필요할 수 있습니다.

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

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

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

ThreadPoolExecutor 클래스

고도의 병렬 분산 작업으로 축소할 수 있는 특정 유형의 작업이 있습니다. 이러한 작업 중 한 예가 8메가픽셀 이미지의 각 8x8 블록 필터를 계산하는 것입니다. 이 계산으로 생성되는 순전한 작업 패킷 양 때문에 AsyncTaskHandlerThread는 적절한 클래스가 아닙니다. AsyncTask의 단일 스레드 특성은 모든 threadpooled 작업을 선형 시스템으로 바꿀 수 있습니다. 반면에 HandlerThread 클래스를 사용하면 프로그래머가 스레드 그룹 간의 부하 분산을 수동으로 관리해야 할 수 있습니다.

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

또한 이 클래스는 앱에서 최적의 스레드 수를 생성하도록 도와줍니다. ThreadPoolExecutor 객체를 구성할 때 앱은 최소 및 최대 스레드 수를 설정합니다. ThreadPoolExecutor에 주어진 워크로드가 증가할 때 이 클래스는 초기화된 최소 및 최대 스레드 수를 고려하고 해야 할 대기 중인 작업량을 감안합니다. 이러한 요소를 기반으로 ThreadPoolExecutor는 주어진 시간에 얼마나 많은 스레드가 활성화되어야 하는지 판단합니다.

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

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

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

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

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