아티클 목록으로 가기

Activity 생명주기 내부 구조: 프레임워크가 onCreate/onResume/onDestroy를 호출하는 원리

skydovesJaewoong Eum (skydoves)||20분 소요

Activity 생명주기 내부 구조: 프레임워크가 onCreate/onResume/onDestroy를 호출하는 원리

모든 안드로이드 개발자는 onCreate(), onResume(), onDestroy()를 오버라이드해 본 경험이 있을 것입니다. 초기화 로직을 작성하고, 리스너를 등록하고, 리소스를 정리하면서 프레임워크가 적절한 시점에, 올바른 순서로 이러한 메서드를 호출해 줄 것이라고 당연하게 신뢰하며 개발합니다. 그렇다면 실제로 이 콜백들을 호출하는 주체는 무엇일까요? 생명주기는 스스로 실행되지 않습니다. 안드로이드 프레임워크 내부 깊숙한 곳에서, 정교한 트랜잭션 시스템이 시스템 서버 측에서 명령을 직렬화하고, Binder IPC 경계를 넘어 전송한 뒤, 앱 프로세스에 있는 상태 머신(state machine)이 목표 상태에 도달하기 위해 필요한 중간 전환의 정확한 순서를 계산합니다. Activity.onResume()이라는 단순해 보이는 호출 뒤에는, 이 호출이 안정적으로 이루어지도록 보장하기 위한 완전한 내부 아키텍처가 존재합니다.

이 글에서는 모든 Activity 생명주기 콜백을 구동하는 내부 메커니즘을 깊이 있게 살펴봅니다. 시스템 서버의 ClientTransaction에서 출발하여, TransactionExecutor의 상태 머신을 거쳐, ActivityThread의 perform 메서드로 진입하고, Instrumentation의 디스패치 계층을 지나, Activity를 화면에 보이게 만드는 윈도우 관리 코드까지의 전체 경로를 추적합니다. 그 과정에서 프레임워크가 중간 생명주기 상태를 어떻게 계산하는지, 유효하지 않은 전환을 어떻게 방어하는지, 그리고 이러한 계층화된 아키텍처가 왜 존재하는지 확인하실 수 있습니다.

이 글은 Activity 생명주기 콜백의 사용법을 다루는 가이드가 아닙니다. 생명주기 콜백을 가능하게 만드는 내부 트랜잭션 및 상태 머신 아키텍처에 대한 탐구입니다. 안드로이드 프레임워크의 내부 동작 원리에 관심이 있으시다면, 이 글을 통해 평소에 당연하게 사용하던 생명주기 콜백이 얼마나 정교한 시스템 위에서 동작하는지 이해하실 수 있을 것입니다.

근본적인 문제: 프로세스 경계를 넘어 생명주기를 조율하기

생명주기 콜백에 대해 떠올릴 때, 단순한 구조를 상상할 수도 있습니다. 시스템 서버가 Activity를 resume해야 한다고 결정하면 onResume()을 호출하는 식이겠죠. 실제로 그렇게 단순하다면 좋겠지만, 현실은 다릅니다. 단순한 멘탈 모델을 먼저 살펴보겠습니다.

// 개념적 설명 - 이렇게 동작할 것이라고 상상할 수 있지만, 실제로는 다릅니다
activityInstance.onResume();

실제 내부 구현은 훨씬 복잡합니다. 시스템 서버(자체 프로세스에서 실행)는 앱의 Activity(앱 프로세스에서 실행)에 대해 직접 메서드를 호출할 수 없습니다. 호출은 반드시 Binder IPC 경계를 넘어야 합니다. 하지만 이는 문제의 시작에 불과합니다. 만약 Activity가 현재 ON_STOP 상태에 있고 ON_RESUME에 도달해야 한다면 어떨까요? 프레임워크는 바로 직접 점프할 수 없습니다. 먼저 ON_RESTART를 거친 뒤, ON_START를 거치고, 그 다음에야 비로소 ON_RESUME으로 전환해야 합니다. 이러한 중간 콜백이 순서대로 호출되어야 하는 이유는, 개발자가 작성한 코드가 onResume() 이전에 onStart()가 이미 실행되었을 것이라고 전제할 수 있기 때문입니다.

더 나아가, 시스템 서버는 여러 명령(결과 전달 후 resume)을 하나의 트랜잭션으로 묶어서 전송해야 할 수도 있습니다. resume 명령이 전송 중인 동안 Activity가 파괴되는 엣지 케이스도 처리해야 합니다. 그리고 Activity가 resume되고 나면, 프레임워크는 DecorViewWindowManager에 추가하여 실제로 화면에 보이도록 만들어야 합니다.

이것이 바로 근본적인 문제입니다. 생명주기 콜백은 단순한 메서드 호출이 아닙니다. 두 프로세스에 걸쳐 동작하며, 임의의 상태 점프를 처리하고, 윈도우 가시성을 관리하며, 항상 결정론적인 콜백 순서를 보장해야 하는 분산 상태 머신의 출력 결과입니다.

ActivityClientRecord: 클라이언트 측에서 생명주기 상태 추적하기

프레임워크는 앱 프로세스 내에서 각 Activity의 현재 생명주기 상태를 추적할 방법이 필요합니다. 이 역할을 담당하는 것이 바로 ActivityClientRecord이며, ActivityThread의 정적 내부 클래스(static inner class)로서 각 Activity 인스턴스에 대한 클라이언트 측 장부 기록(bookkeeping record) 역할을 합니다.

ActivityClientRecord의 구조를 살펴보겠습니다.

// android.app.ActivityThread.ActivityClientRecord
public static final class ActivityClientRecord {
    public IBinder token;
    Activity activity;
    Window window;

    @LifecycleState
    private int mLifecycleState = PRE_ON_CREATE;

    boolean paused;
    boolean stopped;
    // ...
}

주요 구조는 다음과 같습니다.

  1. **token**은 시스템 서버와 앱 프로세스 경계를 넘어 해당 Activity를 고유하게 식별하는 Binder 토큰입니다. 모든 생명주기 명령은 이 토큰을 통해 Activity를 참조합니다.
  2. **mLifecycleState**는 현재 생명주기 상태를 정수 상수로 추적합니다. 초기값은 PRE_ON_CREATE이며, 이는 Activity가 아직 생성되지 않았음을 의미합니다.
  3. **paused**와 **stopped**는 이전 API와의 하위 호환성(backward compatibility)을 위해 유지되는 레거시 boolean 플래그입니다. 실제 권위 있는 상태 추적기는 mLifecycleState입니다.

setState() 메서드가 모든 상태를 동기화된 상태로 유지합니다.

// android.app.ActivityThread.ActivityClientRecord
public void setState(@LifecycleState int newLifecycleState) {
    mLifecycleState = newLifecycleState;
    switch (mLifecycleState) {
        case ON_CREATE:
            paused = true;
            stopped = true;
            break;
        case ON_RESUME:
            paused = false;
            stopped = false;
            break;
        case ON_PAUSE:
            paused = true;
            stopped = false;
            break;
        case ON_STOP:
            paused = true;
            stopped = true;
            break;
    }
}

여기서 중요한 점이 있습니다. Activity가 생명주기 상태를 하나씩 거칠 때마다, 콜백이 완료된 직후 setState()가 호출됩니다. 다음 섹션에서 살펴볼 TransactionExecutorgetLifecycleState()를 읽어 Activity의 현재 위치를 파악한 뒤, 다음 목표 상태까지의 경로를 계산합니다. 만약 이 장부 기록이 실제 상태와 어긋난다면, 상태 머신이 잘못된 전환 순서를 생성하게 될 것입니다.

ClientTransaction: 생명주기 명령을 IPC용으로 묶어 전달하기

시스템 서버는 앱의 Activity에 직접 메서드를 호출할 수 없습니다. 대신 ClientTransaction이라는 Parcelable 컨테이너를 구성하여 하나 이상의 생명주기 명령을 앱 프로세스에 전달합니다.

ClientTransaction의 구조를 살펴보겠습니다.

// android.app.servertransaction.ClientTransaction
public class ClientTransaction implements Parcelable {

    @NonNull
    private final List<ClientTransactionItem> mTransactionItems = new ArrayList<>();

    @Nullable
    private final IApplicationThread mClient;

    public void addTransactionItem(@NonNull ClientTransactionItem item) {
        mTransactionItems.add(item);
    }

    public void schedule() throws RemoteException {
        mClient.scheduleTransaction(this);
    }
}

핵심 요소는 다음과 같습니다.

  1. **mTransactionItems**는 ClientTransactionItem 인스턴스의 순서가 있는 리스트입니다. 각 항목은 생명주기 상태 요청("RESUMED로 이동")이거나, 생명주기와 무관한 콜백("Activity 결과 전달") 중 하나를 나타냅니다.
  2. **mClient**는 IApplicationThread 참조로, 대상 앱의 메인 스레드로 연결되는 Binder 프록시입니다. 이 필드는 서버 측에서만 존재하며 파셀링(parceling) 이후에는 null이 됩니다.
  3. **schedule\(\)**은 mClient.scheduleTransaction(this)를 호출하여 전체 트랜잭션을 IPC 경계를 넘어 전송합니다.

여기서 핵심적인 관찰 사항이 있습니다. 하나의 ClientTransaction에 서로 다른 목적의 여러 항목을 담을 수 있다는 점입니다. 가령, 시스템 서버가 NewIntentItemResumeActivityItem을 하나의 트랜잭션에 묶어서 전송할 수 있습니다. 이러한 배치 처리(batching)는 IPC 왕복 횟수를 줄여 줍니다. 각 Binder 호출은 페이로드 크기와 관계없이 오버헤드가 발생하기 때문입니다.

트랜잭션이 앱 측에 도착하면, ClientTransactionHandler.scheduleTransaction()이 이를 수신합니다.

// android.app.ClientTransactionHandler
void scheduleTransaction(ClientTransaction transaction) {
    transaction.preExecute(this);
    sendMessage(ActivityThread.H.EXECUTE_TRANSACTION, transaction);
}

preExecute() 호출은 각 항목이 스케줄링 전 사전 작업(프로세스 상태 업데이트 등)을 수행할 수 있도록 합니다. 그런 다음 트랜잭션은 메인 스레드의 Handler에 메시지로 전달됩니다. 이를 통해 모든 생명주기 전환이 메인 스레드에서 실행되도록 보장하며, 이는 안드로이드 프레임워크의 근본적인 보장 사항 중 하나입니다.

TransactionExecutor: 트랜잭션 실행 엔진

메인 스레드에서 EXECUTE_TRANSACTION 메시지가 처리되면, TransactionExecutor.execute()가 동작을 시작합니다. 이 클래스는 트랜잭션 항목을 해석하고 Activity를 올바른 생명주기 상태 순서대로 구동하는 핵심 엔진입니다.

execute() 메서드를 살펴보겠습니다.

// android.app.servertransaction.TransactionExecutor
public void execute(@NonNull ClientTransaction transaction) {
    Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "clientTransactionExecuted");
    try {
        executeTransactionItems(transaction);
    } catch (Exception e) {
        Slog.e(TAG, "Failed to execute the transaction: "
                + transactionToString(transaction, mTransactionHandler));
        throw e;
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);
    }
    mPendingActions.clear();
}

executeTransactionItems() 메서드는 트랜잭션 내의 모든 항목을 순회하면서 타입에 따라 분기 처리합니다.

// android.app.servertransaction.TransactionExecutor
public void executeTransactionItems(@NonNull ClientTransaction transaction) {
    final List<ClientTransactionItem> items = transaction.getTransactionItems();
    final int size = items.size();
    for (int i = 0; i < size; i++) {
        final ClientTransactionItem item = items.get(i);
        if (item.isActivityLifecycleItem()) {
            executeLifecycleItem(transaction, (ActivityLifecycleItem) item);
        } else {
            executeNonLifecycleItem(transaction, item,
                    shouldExcludeLastLifecycleState(items, i));
        }
    }
}

핵심적인 차이점은 무엇일까요? 생명주기 항목과 비생명주기 항목은 서로 다른 실행 경로를 따릅니다. 생명주기 항목의 경우, executeLifecycleItem()은 먼저 Activity를 목표 상태의 한 단계 이전 상태까지 순환시킨 뒤, 마지막 전환을 생명주기 항목 고유의 매개변수와 함께 실행합니다.

// android.app.servertransaction.TransactionExecutor
private void executeLifecycleItem(@NonNull ClientTransaction transaction,
        @NonNull ActivityLifecycleItem lifecycleItem) {
    final IBinder token = lifecycleItem.getActivityToken();
    final ActivityClientRecord r = mTransactionHandler.getActivityClient(token);

    if (r == null) {
        // 존재하지 않는 클라이언트 레코드에 대한 요청은 무시
        return;
    }

    // 최종 요청 상태의 바로 직전 상태까지 순환
    cycleToPath(r, lifecycleItem.getTargetState(), true /* excludeLastState */, transaction);

    // 적절한 매개변수를 사용하여 최종 전환 실행
    lifecycleItem.execute(mTransactionHandler, mPendingActions);
    lifecycleItem.postExecute(mTransactionHandler, mPendingActions);
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기