본문 바로가기
Android

[Android AOA 시리즈 - 5편] AOA 디바이스 모드 & ParcelFileDescriptor 이해

by s_hoonee 2026. 5. 11.
반응형

안녕하세요! 디지털 전환(DX)과 배리어프리 환경 구축에 진심인 FlutterNeverDie 개발자입니다.

지난 4편에서는 안드로이드가 '호스트(Host)' 역할을 할 때 UsbManager와 bulkTransfer를 이용해 통신하는 방법을 알아보았습니다.

그런데 만약, 안드로이드 기기 2대를 USB로 연결해서 통신해야 한다면 어떨까요? 한 대는 호스트(Host)가 되고, 나머지 한 대는 디바이스(Device, 즉 액세서리) 역할을 해야 합니다. 이번 5편에서는 액세서리 역할을 수행하는 AoaDevice.kt 클래스를 분석하며, 호스트와 어떻게 다르게 구현되는지 살펴보겠습니다.


1. Host 모드 vs Accessory 모드 구현 차이

가장 큰 차이점은 '통신을 다루는 객체'가 다르다는 것입니다.

구분 Host (안드로이드가 주체) Accessory (안드로이드가 객체)
대상 객체 UsbDevice UsbAccessory
통신 객체 UsbDeviceConnection ParcelFileDescriptor
데이터 송수신 bulkTransfer() 함수 사용 FileInputStream / FileOutputStream 사용
주도권 엔드포인트(IN/OUT)를 직접 찾아서 제어 호스트가 열어준 파일 스트림(Stream)에 읽고 쓰기만 함

액세서리 모드에서는 안드로이드가 스스로를 마치 하나의 파일(File)처럼 취급합니다. 그래서 복잡한 엔드포인트 탐색 없이 파일 입출력 스트림만 열면 통신이 가능합니다. 코드가 훨씬 단순해집니다!


2. 연결 및 스트림 열기 (ParcelFileDescriptor)

AoaDevice.kt의 연결 로직을 보겠습니다. UsbManager를 통해 UsbAccessory 객체를 받고, 파일 디스크립터를 엽니다.

fun connect(accessory: UsbAccessory): Boolean {
    try {
        // 1. 액세서리에 대한 파일 디스크립터(통로) 열기
        fileDescriptor = usbManager.openAccessory(accessory)
        
        if (fileDescriptor != null) {
            val fd = fileDescriptor!!.fileDescriptor
            
            // 2. 파일 디스크립터에서 읽기/쓰기 스트림 생성
            inputStream = FileInputStream(fd)
            outputStream = FileOutputStream(fd)
            
            isConnected = true
            Log.d("AoaDevice", "Accessory connected")
            
            // 3. 백그라운드 수신 스레드 시작
            startListening()
            return true
        }
    } catch (e: Exception) {
        Log.e("AoaDevice", "Error connecting to accessory", e)
    }
    return false
}

호스트 구현 때처럼 claimInterface나 getEndpoint 같은 복잡한 과정이 없습니다. openAccessory() 한 방으로 ParcelFileDescriptor를 얻고, 이를 이용해 표준 자바 I/O 스트림(FileInputStream, FileOutputStream)을 열면 끝입니다.


3. 데이터 수신: 파일 읽듯이 읽기

데이터 수신 로직도 우리가 흔히 아는 파일 읽기 방식과 동일합니다.

private fun startListening() {
    listeningThread = Thread {
        // 16KB 버퍼 준비
        val buffer = ByteArray(16384)

        while (isConnected && !Thread.currentThread().isInterrupted) {
            try {
                // 스트림에서 데이터 읽기 (블로킹 함수)
                val bytesRead = inputStream?.read(buffer) ?: -1

                if (bytesRead > 0) {
                    val receivedData = String(buffer, 0, bytesRead, Charsets.UTF_8)
                    Log.d("AoaDevice", "Received: $receivedData")
                    
                    onDataReceived?.invoke(receivedData)
                }
            } catch (e: IOException) {
                // USB가 뽑히거나 연결이 끊기면 예외 발생
                Log.e("AoaDevice", "Error reading data", e)
                disconnect()
                break
            }
        }
    }
    listeningThread?.start()
}

호스트 모드에서는 bulkTransfer의 반환값이 음수일 때 에러 카운트를 셌지만, 스트림 방식에서는 연결이 끊기면 곧바로 IOException이 던져지므로(Throw) catch 블록에서 깔끔하게 연결 해제(disconnect()) 처리를 할 수 있습니다.


4. 데이터 송신: 파일 쓰듯이 쓰기

데이터 송신은 더 쉽습니다. FileOutputStream에 바이트를 밀어 넣으면 됩니다.

fun sendData(data: String): Boolean {
    if (!isConnected || outputStream == null) {
        Log.e("AoaDevice", "Cannot send data: Not connected")
        return false
    }

    return try {
        // 문자열을 바이트로 변환하여 스트림에 쓰기
        val bytes = data.toByteArray(Charsets.UTF_8)
        outputStream?.write(bytes)
        
        // 즉시 전송을 보장하기 위해 flush() 호출
        outputStream?.flush()
        
        Log.d("AoaDevice", "Sent: " + data)
        true
    } catch (e: IOException) {
        Log.e("AoaDevice", "Error sending data", e)
        false
    }
}

여기서 가장 중요한 꿀팁은 flush()를 호출하는 것입니다. 스트림 방식은 버퍼가 꽉 차지 않으면 데이터를 보내지 않고 쥐고 있는 경우가 있습니다. flush()를 명시적으로 호출해야 우리가 보낸 명령어가 지연 없이 즉각적으로 호스트로 날아갑니다.


마무리 및 다음 편 예고

이번 5편에서는 안드로이드가 액세서리 역할을 할 때 ParcelFileDescriptor를 이용해 파일 입출력처럼 쉽게 통신하는 방법을 알아보았습니다. 이제 호스트(Host)와 디바이스(Device) 양쪽의 물리적인 통신 파이프라인이 모두 완성되었습니다!

파이프라인이 뚫렸으니, 이제 "어떤 형식으로 대화를 나눌 것인가?"를 정해야 합니다. 즉, 프로토콜 설계가 필요합니다.

다음 6편에서는 키오스크 결제, 영수증 출력, 배리어프리 키패드 입력 등을 처리하기 위해 JSON 기반의 커스텀 통신 프로토콜을 어떻게 설계하는지 실제 android-sample 코드를 바탕으로 자세히 다루어 보겠습니다.