Android Binder IPC는 어떻게 동작하는가: 트랜잭션, Parcel, 그리고 스레드 풀
Android Binder IPC는 어떻게 동작하는가: 트랜잭션, Parcel, 그리고 스레드 풀
모든 안드로이드 개발자는 자신도 모르는 사이에 프로세스 간 통신(inter-process communication, IPC)에 기대어 코드를 짭니다. getSystemService(Context.ACTIVITY_SERVICE)를 호출하면 여러분의 앱과는 다른 프로세스인 system_server 안에 살아 있는 매니저와 대화하게 됩니다. AIDL 인터페이스로 bindService()를 호출하면, 어딘가 다른 곳에서 실행되는 객체의 메서드를 부르기 시작합니다. Activity를 띄우거나, ContentProvider에 질의하거나, 작업을 스케줄링할 때마다 매번 프로세스 경계를 건너고 있는 셈입니다. 평소에는 평범한 메서드 호출처럼 느껴지지만, 어느 날 큰 Bitmap을 원격 메서드에 넘기고 TransactionTooLargeException을 마주하는 순간 그 환상은 깨집니다. 표면만 보면 평범한 자바(Java)입니다. 더 깊은 질문은, 두 프로세스가 메모리를 공유하지도 않고 서로의 객체를 볼 수도 없는데, 한 객체에 대한 메서드 호출이 어떻게 완전히 별개의 프로세스에 도달하고, 그곳에서 실행되며, 결과를 되돌려 받는가입니다.
이 글에서는 Binder IPC 뒤에 숨은 내부 메커니즘을 깊이 있게 파고듭니다. 격리된 주소 공간 때문에 단순한 원격 호출이 왜 불가능한지, IInterface와 IBinder 위에 세워진 proxy/stub 패턴이 그 간극을 어떻게 잇는지, transact() 호출이 어떻게 커널(kernel)을 거쳐 Binder.execTransact()로 들어가 결국 onTransact()에 도달하는지, Parcel이 어떻게 인자들을 커널이 복사할 수 있는 바이트 버퍼로 평탄화하는지, 단일 복사 설계가 왜 모든 트랜잭션을 대략 1MB로 묶어 두는지, binder 스레드 풀이 들어오는 호출을 어떻게 분배하는지, 커널이 어떻게 진짜 getCallingUid()를 알려 주는지와 clearCallingIdentity()가 왜 존재하는지, 그리고 원격 프로세스가 죽었을 때 linkToDeath가 어떻게 그 사실을 알리는지까지 차례대로 살펴봅니다.
근본적인 문제: 한 프로세스의 포인터는 다른 프로세스에서는 아무 의미가 없다
동작했으면 좋겠다 싶은 호출에서 출발해 보겠습니다. 자체 프로세스에서 실행되는 음악 서비스가 있고, 여러분이 그 서비스에 대한 참조처럼 보이는 무언가를 들고 있다고 상상해 보세요.
IMusicService music = ...;
music.play("track-42");
같은 프로세스 안에서라면 사소한 일입니다. 변수 music은 힙(heap) 위에 놓인 객체의 주소를 들고 있고, JVM은 그 주소에 있는 메서드로 점프합니다. 이제 그 객체가 다른 프로세스에 살고 있다고 가정해 보세요. 모든 안드로이드 프로세스는 자신만의 가상 주소 공간(virtual address space)에서 실행됩니다. 가상 주소 공간이란 커널이 강제하는, 주소에서 물리 메모리로 향하는 사적인 지도입니다. music 참조에 담긴 숫자는 여러분의 주소 공간 안 어떤 위치를 가리키지만, 정확히 똑같은 그 숫자가 서비스의 주소 공간에서는 전혀 관계없는 메모리, 혹은 아예 아무것도 가리키지 않습니다. 결국 객체 참조를 경계 너머로 그냥 넘길 수는 없습니다. 참조는 자신을 만들어 낸 프로세스 안에서만 의미를 가지기 때문입니다.
그래서 호출 자체가 데이터로 변해야 합니다. 메서드의 정체, 문자열 "track-42", 그리고 결국 반환값까지 모두 바이트로 변환되어, 두 프로세스가 모두 접근할 수 있는 무언가에 건네지고, 반대편에서 다시 조립되어야 합니다. 두 주소 공간을 모두 들여다볼 수 있는 유일한 컴포넌트는 커널입니다.
가장 먼저 떠올릴 만한 후보는 범용 도구들입니다. 공유 메모리(shared memory)에서는 두 프로세스가 동일한 물리 페이지를 매핑할 수 있지만, 그 결과로 얻는 것은 메서드 호출이라는 개념도, 반환값도, 정체성도, 보안도 없는 평평한 바이트 영역에 불과합니다. 소켓이나 파이프는 바이트 스트림을 제공하지만, 메시지의 경계를 만들고, 요청과 응답을 짝지으며, 반대편이 어느 프로세스인지를 신뢰할 수 있는 방법까지 모두 직접 책임져야 합니다. 어느 것도 객체 지향 원격 호출에 필요한 두 가지 속성을 동시에 갖추지 못합니다. 하나는 원격 객체의 안정된 정체성으로, 동일한 객체가 여기저기 전달되어도 같은 객체로 인식되어야 합니다. 다른 하나는 위조할 수 없는 호출자 정체성으로, 서비스가 동작 전에 누가 요청했는지를 검증할 수 있어야 합니다.
Binder는 바로 그 두 속성을 제공하는 커널 드라이버(kernel driver)이자 유저스페이스(userspace) 프레임워크입니다. 메서드 호출을 트랜잭션으로 바꾸고, 그 트랜잭션을 커널을 거쳐 단 한 번만 복사하며, 호출자의 신뢰할 수 있는 UID를 전달합니다. 그리고 binder 객체는 프로세스 경계를 건너도 정체성을 유지하는 토큰처럼 주고받을 수 있습니다. 이 글의 나머지는 그 기계가 어떻게 만들어져 있는지에 관한 이야기입니다.
proxy와 stub 패턴: IInterface와 asBinder
이 다리는 두 개의 작은 타입에서 시작합니다. IBinder는 원격 호출이 가능한 객체, 즉 트랜잭션을 받을 수 있는 대상이고, IInterface는 원격으로 호출할 수 있는 모든 인터페이스의 기반입니다. IInterface를 들여다보면 정확히 하나의 메서드만 선언되어 있습니다.
public interface IInterface {
public IBinder asBinder();
}
이 단 하나의 메서드가 전체 설계의 경첩 역할을 합니다. 원격 호출 가능한 인터페이스를 구현하는 모든 객체는, 그 아래에 깔린 IBinder를 꺼내어 반환할 수 있어야 합니다. 이 점이 중요한 이유는 다음과 같습니다. 동일한 인터페이스 타입 IMusicService라 하더라도 런타임에는 양쪽 진영에 따라 완전히 다른 구현 두 가지가 존재하며, asBinder()는 그중 어느 쪽을 들고 있든 일관되게 그 binder에 도달하는 방법이기 때문입니다.
서버 측에서는 실제 구현이 IBinder를 구현하는 Binder를 상속받습니다. 이 객체에 asBinder()를 물으면 자기 자신을 반환합니다. 클라이언트 측에서는 실제 객체를 결코 직접 들고 있지 않습니다. 여러분이 손에 쥔 것은 프록시(proxy)로, IMusicService를 구현하지만 재생 로직은 전혀 담고 있지 않은 객체입니다. 이 프록시의 asBinder()는 BinderProxy를 반환합니다. BinderProxy는 원격 객체를 가리키는 네이티브 참조에 대한 자바 핸들입니다. 같은 발상이 Retrofit 인터페이스 뒤에도 깔려 있습니다. Retrofit에서도 호출 대상 객체가 자동 생성되어 모든 메서드가 어딘가로 보내질 요청으로 바뀌는데, 다만 여기서는 그 요청이 HTTP가 아니라 커널을 거쳐 간다는 점이 다릅니다.
두 쪽 모두 직접 손으로 작성하는 일은 거의 없습니다. AIDL 인터페이스를 선언하면 빌드 도구가 양쪽 모두를 자동으로 생성합니다. 음악 서비스 인터페이스를 예로 들어 보겠습니다.
interface IMusicService {
void play(String trackId);
int currentPositionMs();
}
이 정의로부터 AIDL은 서버 측에는 추상 클래스인 Stub을, 클라이언트 측에는 Proxy 클래스를 생성하며, 둘 다 생성된 IMusicService 안에 중첩됩니다. Stub은 Binder를 상속받고 IMusicService를 구현합니다. 여러분은 Stub을 상속하여 play와 currentPositionMs를 구현하시면 됩니다. Proxy 역시 IMusicService를 구현하지만, 각 메서드는 자신의 인자들을 포장하여 어딘가로 전송합니다. 두 쪽은 동일한 프로세스 안에서 결코 마주치지 않습니다. 한쪽은 서비스가 등록하는 객체이고, 다른 한쪽은 클라이언트가 전달받는 객체입니다.
AIDL은 여러분이 어느 쪽을 들고 있는지 판별하는 asInterface 헬퍼도 함께 생성합니다.
public static IMusicService asInterface(IBinder obj) {
if (obj == null) return null;
IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
if (iin != null && iin instanceof IMusicService) {
return (IMusicService) iin; // 로컬: 실제 Stub
}
return new IMusicService.Stub.Proxy(obj); // 원격: 프록시로 감싼다
}
판정의 핵심은 queryLocalInterface에 있습니다. 실제 Binder는 자신이 어느 인터페이스에 부착되었는지를 기억하고 있다가 여기서 그 인터페이스를 반환합니다. 따라서 객체가 여러분의 프로세스 안에 살고 있다면 실제 구현이 그대로 돌아와, IPC는 통째로 건너뛰게 됩니다. 반면 BinderProxy는 언제나 null을 반환하므로, 원격 객체는 Proxy 분기로 떨어집니다. 같은 프로세스 안의 서비스에 바인딩하면 직접적인 메서드 호출이 되고, 프로세스 경계를 넘어 바인딩하면 트랜잭션을 거치게 되지만, 여러분이 작성한 코드는 양쪽 모두 동일해 보이는 이유가 바로 여기에 있습니다.
트랜잭션: transact, 커널, 그리고 onTransact
모든 원격 호출은 IBinder의 단 하나의 메서드로 깔때기처럼 모입니다. 선언을 살펴보겠습니다.