자바 스레드를 사용한 비동기 작업

모든 Android 앱은 기본 스레드를 사용하여 UI 작업을 처리합니다. 이 기본 스레드에서 장기 실행 작업을 호출하면 정지되거나 응답하지 않을 수 있습니다. 예를 들어 앱이 기본 스레드에서 네트워크 요청을 하면 앱의 UI는 네트워크 응답을 받을 때까지 정지됩니다. 자바를 사용하는 경우 기본 스레드가 UI 업데이트를 계속 처리하는 동안 추가 백그라운드 스레드를 만들어 장기 실행 작업을 처리할 수 있습니다.

이 가이드에서는 자바 프로그래밍 언어를 사용하는 개발자가 스레드 풀을 사용하여 Android 앱에서 여러 스레드를 설정하고 사용하는 방법을 보여줍니다. 또한 스레드에서 실행할 코드를 정의하는 방법과 이러한 스레드 중 하나와 기본 스레드 간에 통신하는 방법도 보여줍니다.

동시 실행 라이브러리

스레딩의 기본사항 및 기본 메커니즘을 이해하는 것이 중요합니다. 그러나 이러한 개념에 관한 높은 수준의 추상화를 제공하고 스레드 간에 데이터를 전달하는 데 즉시 사용 가능한 유틸리티를 제공하는 인기 있는 라이브러리가 많이 있습니다. 이러한 라이브러리에는 자바 프로그래밍 언어 사용자를 위한 GuavaRxJava와 Kotlin 사용자에게 권장되는 코루틴이 포함되어 있습니다.

실제로 앱과 개발팀에 가장 적합한 방법을 선택해야 합니다. 스레딩 규칙은 동일하게 유지됩니다.

예시 개요

이 주제의 예에서는 앱 아키텍처 가이드에 따라 네트워크 요청을 하고 결과를 기본 스레드로 반환합니다. 그러면 앱이 그 결과를 화면에 표시할 수 있습니다.

특히 ViewModel는 기본 스레드의 데이터 영역을 호출하여 네트워크 요청을 트리거합니다. 데이터 레이어는 네트워크 요청 실행을 기본 스레드 외부로 이동하고 콜백을 사용하여 결과를 기본 스레드에 다시 게시하는 역할을 합니다.

네트워크 요청 실행을 기본 스레드 외부로 이동하려면 앱에 다른 스레드를 만들어야 합니다.

여러 스레드 만들기

스레드 풀은 큐에서 작업을 동시에 실행하는 관리형 스레드 모음입니다. 새 작업은 기존 스레드가 유휴 상태가 되면 이러한 스레드에서 실행됩니다. 작업을 스레드 풀에 보내려면 ExecutorService 인터페이스를 사용합니다. ExecutorService는 Android 애플리케이션 구성요소인 서비스와 관련이 없습니다.

스레드를 만드는 데는 비용이 많이 들므로 앱이 초기화될 때 스레드 풀을 한 번만 만들어야 합니다. ExecutorService의 인스턴스를 Application 클래스나 종속 항목 삽입 컨테이너에 저장해야 합니다. 다음 예에서는 백그라운드 작업을 실행하는 데 사용할 수 있는 4개의 스레드로 구성된 스레드 풀을 만듭니다.

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
}

예상되는 워크로드에 따라 스레드 풀을 구성할 수 있는 다른 방법이 있습니다. 자세한 내용은 스레드 풀 구성을 참조하세요.

백그라운드 스레드에서 실행

기본 스레드에서 네트워크 요청을 하면 스레드가 응답을 받을 때까지 대기하거나 차단됩니다. 스레드가 차단되므로 OS는 onDraw()를 호출할 수 없고 앱이 정지되어 애플리케이션 응답 없음 (ANR) 대화상자가 발생할 수 있습니다. 대신 백그라운드 스레드에서 이 작업을 실행해 보겠습니다.

요청하기

먼저 LoginRepository 클래스를 살펴보고 네트워크 요청 방식을 확인해 보겠습니다.

// Result.java
public abstract class Result<T> {
    private Result() {}

    public static final class Success<T> extends Result<T> {
        public T data;

        public Success(T data) {
            this.data = data;
        }
    }

    public static final class Error<T> extends Result<T> {
        public Exception exception;

        public Error(Exception exception) {
            this.exception = exception;
        }
    }
}

// LoginRepository.java
public class LoginRepository {

    private final String loginUrl = "https://example.com/login";
    private final LoginResponseParser responseParser;

    public LoginRepository(LoginResponseParser responseParser) {
        this.responseParser = responseParser;
    }

    public Result<LoginResponse> makeLoginRequest(String jsonBody) {
        try {
            URL url = new URL(loginUrl);
            HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
            httpConnection.setRequestMethod("POST");
            httpConnection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
            httpConnection.setRequestProperty("Accept", "application/json");
            httpConnection.setDoOutput(true);
            httpConnection.getOutputStream().write(jsonBody.getBytes("utf-8"));

            LoginResponse loginResponse = responseParser.parse(httpConnection.getInputStream());
            return new Result.Success<LoginResponse>(loginResponse);
        } catch (Exception e) {
            return new Result.Error<LoginResponse>(e);
        }
    }
}

makeLoginRequest()가 동기식이며 호출 스레드를 차단합니다. 네트워크 요청의 응답을 모델링하기 위해 자체 Result 클래스가 있습니다.

요청 트리거

ViewModel는 사용자가 예를 들어 버튼을 탭할 때 네트워크 요청을 트리거합니다.

public class LoginViewModel {

    private final LoginRepository loginRepository;

    public LoginViewModel(LoginRepository loginRepository) {
        this.loginRepository = loginRepository;
    }

    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody);
    }
}

위의 코드에서 LoginViewModel는 네트워크 요청을 할 때 기본 스레드를 차단하고 있습니다. 인스턴스화한 스레드 풀을 사용하여 실행을 백그라운드 스레드로 이동할 수 있습니다.

종속 항목 삽입 처리

첫째, 종속 항목 삽입 원칙에 따라 LoginRepositoryExecutorService가 아닌 Executor의 인스턴스를 사용합니다. 코드를 실행하고 스레드를 관리하지 않기 때문입니다.

public class LoginRepository {
    ...
    private final Executor executor;

    public LoginRepository(LoginResponseParser responseParser, Executor executor) {
        this.responseParser = responseParser;
        this.executor = executor;
    }
    ...
}

Executor의 execute() 메서드는 Runnable을 사용합니다. Runnable는 호출 시 스레드에서 실행되는 run() 메서드가 포함된 단일 추상 메서드 (SAM) 인터페이스입니다.

백그라운드에서 실행

실행을 백그라운드 스레드로 이동하고 현재 응답을 무시하는 makeLoginRequest()라는 또 다른 함수를 만들어 보겠습니다.

public class LoginRepository {
    ...
    public void makeLoginRequest(final String jsonBody) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                Result<LoginResponse> ignoredResponse = makeSynchronousLoginRequest(jsonBody);
            }
        });
    }

    public Result<LoginResponse> makeSynchronousLoginRequest(String jsonBody) {
        ... // HttpURLConnection logic
    }
    ...
}

execute() 메서드 내에서 백그라운드 스레드에서 실행하려는 코드 블록(이 경우에는 동기식 네트워크 요청 메서드)으로 새 Runnable를 만듭니다. 내부적으로 ExecutorServiceRunnable를 관리하고 사용 가능한 스레드에서 실행합니다.

고려사항

앱의 모든 스레드는 기본 스레드를 포함하여 다른 스레드와 동시에 실행될 수 있으므로 코드가 스레드로부터 안전한지 확인해야 합니다. 이 예에서는 스레드 간에 공유되는 변수에 쓰는 것을 피하고 대신 변경 불가능한 데이터를 전달합니다. 각 스레드가 자체 데이터 인스턴스와 함께 작동하고 동기화의 복잡성을 피할 수 있으므로 이는 좋은 방법입니다.

스레드 간에 상태를 공유해야 하는 경우 잠금과 같은 동기화 메커니즘을 사용하여 스레드의 액세스를 관리하도록 주의해야 합니다. 이 내용은 이 가이드에서 다루지 않습니다. 일반적으로 가능한 한 스레드 간에 변경 가능한 상태를 공유하지 않아야 합니다.

기본 스레드와 통신

이전 단계에서는 네트워크 요청 응답을 무시했습니다. 화면에 결과를 표시하려면 LoginViewModel에서 결과를 알아야 합니다. 콜백을 사용하면 됩니다.

makeLoginRequest() 함수는 값을 비동기식으로 반환할 수 있도록 콜백을 매개변수로 사용해야 합니다. 결과를 포함한 콜백은 네트워크 요청이 완료되거나 실패할 때마다 호출됩니다. Kotlin에서는 고차 함수를 사용할 수 있습니다. 그러나 자바에서는 동일한 기능을 포함하기 위해 새 콜백 인터페이스를 만들어야 합니다.

interface RepositoryCallback<T> {
    void onComplete(Result<T> result);
}

public class LoginRepository {
    ...
    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    callback.onComplete(result);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    callback.onComplete(errorResult);
                }
            }
        });
    }
  ...
}

ViewModel은 이제 콜백을 구현해야 합니다. 그리고 다음과 같이 결과에 따라 서로 다른 로직을 실행할 수 있습니다.

public class LoginViewModel {
    ...
    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody, new RepositoryCallback<LoginResponse>() {
            @Override
            public void onComplete(Result<LoginResponse> result) {
                if (result instanceof Result.Success) {
                    // Happy path
                } else {
                    // Show error in UI
                }
            }
        });
    }
}

이 예에서 콜백은 백그라운드 스레드인 호출 스레드에서 실행됩니다. 즉, 기본 스레드로 다시 전환할 때까지 UI 레이어를 직접 수정하거나 UI 레이어와 통신할 수 없습니다.

핸들러 사용

Handler를 사용하여 다른 스레드에서 실행할 작업을 대기열에 추가할 수 있습니다. 작업을 실행할 스레드를 지정하려면 스레드의 루퍼를 사용하여 Handler를 구성합니다. Looper는 연결된 스레드의 메시지 루프를 실행하는 객체입니다. Handler를 만든 후에는 post(Runnable) 메서드를 사용하여 상응하는 스레드에서 코드 블록을 실행할 수 있습니다.

Looper에는 기본 스레드의 Looper를 검색하는 도우미 함수 getMainLooper()가 포함되어 있습니다. 이 Looper를 사용하여 Handler를 만드는 방법으로 기본 스레드에서 코드를 실행할 수 있습니다. 이는 꽤 자주 실행할 수 있는 작업이므로 ExecutorService를 저장한 것과 동일한 위치에 Handler 인스턴스를 저장할 수도 있습니다.

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
    Handler mainThreadHandler = HandlerCompat.createAsync(Looper.getMainLooper());
}

핸들러를 저장소에 삽입하는 것이 좋은 방법입니다. 더 많은 유연성을 제공하기 때문입니다. 예를 들어, 나중에 다른 Handler를 전달하여 별도의 스레드에서 작업을 예약해야 할 수 있습니다. 항상 동일한 스레드로 다시 통신한다면 다음 예와 같이 Handler를 저장소 생성자에 전달할 수 있습니다.

public class LoginRepository {
    ...
    private final Handler resultHandler;

    public LoginRepository(LoginResponseParser responseParser, Executor executor,
            Handler resultHandler) {
        this.responseParser = responseParser;
        this.executor = executor;
        this.resultHandler = resultHandler;
    }

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
    ...
}

또는 더 많은 유연성을 원하는 경우 각 함수에 Handler를 전달할 수 있습니다.

public class LoginRepository {
    ...

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler,
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback, resultHandler);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback, resultHandler);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
}

이 예에서는 저장소의 makeLoginRequest 호출에 전달된 콜백이 기본 스레드에서 실행됩니다. 즉, 콜백에서 직접 UI를 수정하거나 LiveData.setValue()를 사용하여 UI와 통신할 수 있습니다.

스레드 풀 구성

이전 코드 예에 표시된 대로 사전 정의된 설정이 적용된 Executor 도우미 함수 중 하나를 사용하여 스레드 풀을 만들 수 있습니다. 또는 스레드 풀의 세부정보를 맞춤설정하려면 ThreadPoolExecutor를 직접 사용하여 인스턴스를 만들면 됩니다. 다음 세부정보를 구성할 수 있습니다.

  • 초기 및 최대 풀 크기
  • 연결 유지 시간 및 시간 단위. 연결 유지 시간은 스레드가 종료되기 전에 유휴 상태로 유지될 수 있는 최대 기간입니다.
  • Runnable 작업을 보유하는 입력 큐. 이 큐는 BlockingQueue 인터페이스를 구현해야 합니다. 앱의 요구사항에 맞도록 사용 가능한 큐 구현 중에서 선택할 수 있습니다. 자세한 내용은 ThreadPoolExecutor의 클래스 개요를 참고하세요.

다음은 총 프로세서 코어 수, 1초의 연결 유지 시간 및 입력 큐를 기반으로 스레드 풀 크기를 지정하는 예입니다.

public class MyApplication extends Application {
    /*
     * Gets the number of available cores
     * (not always the same as the maximum number of cores)
     */
    private static int NUMBER_OF_CORES = Runtime.getRuntime().availableProcessors();

    // Instantiates the queue of Runnables as a LinkedBlockingQueue
    private final BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<Runnable>();

    // Sets the amount of time an idle thread waits before terminating
    private static final int KEEP_ALIVE_TIME = 1;
    // Sets the Time Unit to seconds
    private static final TimeUnit KEEP_ALIVE_TIME_UNIT = TimeUnit.SECONDS;

    // Creates a thread pool manager
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            NUMBER_OF_CORES,       // Initial pool size
            NUMBER_OF_CORES,       // Max pool size
            KEEP_ALIVE_TIME,
            KEEP_ALIVE_TIME_UNIT,
            workQueue
    );
    ...
}