-
Android 테더링 구현 핵심 정리(USB·WiFi 기기 간 데이터 이동)-고슴도치 군단카테고리 없음 2026. 4. 27. 19:20

테더링 개요
Android 기기에서 테더링은 크게 USB 테더링과 WiFi 테더링(소프트 AP) 두 가지로 나뉨.
USB 테더링은 케이블로 연결된 PC나 다른 기기에 인터넷을 공유하는 방식이며, WiFi 테더링은 소프트 AP를 열어 무선으로 여러 기기가 접속할 수 있게 하는 방식임.
일반 앱과 시스템 앱이 접근할 수 있는 API 범위가 다르므로, 사용 목적에 맞는 방식을 선택하는 것이 중요함.
WiFi 테더링 구현
Android 8.0(API 26) 이상에서는
WifiManager.startLocalOnlyHotspot()을 사용해 인터넷 공유 없이 기기 간 P2P 통신용 핫스팟을 열 수 있음.이 방식은 시스템 권한 없이도 동작하며, SSID와 비밀번호는 콜백으로 자동 발급됨.
아래는 기본적인 LocalOnlyHotspot 구현 예시임.
class HotspotManager(private val context: Context) { private val wifiManager: WifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager private var reservation: WifiManager.LocalOnlyHotspotReservation? = null fun startLocalOnlyHotspot(onStarted: (ssid: String, password: String) -> Unit) { wifiManager.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() { override fun onStarted(r: WifiManager.LocalOnlyHotspotReservation) { reservation = r val config = r.softApConfiguration val ssid = config.ssid ?: "Unknown" val password = config.passphrase ?: "" onStarted(ssid, password) } override fun onStopped() { reservation = null } override fun onFailed(reason: Int) { Log.e("Hotspot", "Failed: $reason") } }, Handler(Looper.getMainLooper())) } fun stopHotspot() { reservation?.close() reservation = null } }인터넷까지 공유하는 전체 핫스팟 제어는
TETHER_PRIVILEGED권한이 필요하며, 이는 시스템 서명 앱에서만 허용됨.
USB 테더링 구현
USB 테더링은 공개 API가 없어
ConnectivityManager의 숨겨진 메서드를 Reflection으로 접근하는 방식이 사용됨.기기마다 인터페이스 이름(
rndis,usb)이 다를 수 있으므로, 테더링 활성 여부 확인 시 다양한 접두사를 고려해야 함.class UsbTetheringManager(private val context: Context) { fun isUsbTetheringEnabled(): Boolean { return try { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val method = cm.javaClass.getMethod("getTetheredIfaces") val ifaces = method.invoke(cm) as Array<*> ifaces.any { it.toString().startsWith("rndis") || it.toString().startsWith("usb") } } catch (e: Exception) { false } } fun setUsbTethering(enable: Boolean): Boolean { return try { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val method = cm.javaClass.getDeclaredMethod("setUsbTethering", Boolean::class.java) method.isAccessible = true val result = method.invoke(cm, enable) as Int result == ConnectivityManager.TETHER_ERROR_NO_ERROR } catch (e: Exception) { Log.e("USB Tethering", "Error: ${e.message}") false } } fun openTetheringSettings() { val intent = Intent(android.provider.Settings.ACTION_WIRELESS_SETTINGS) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(intent) } }Reflection 방식은 일부 기기에서 동작하지 않을 수 있으며, 이 경우 사용자가 직접 설정 화면에서 활성화하도록 유도하는 방식을 병행하는 것이 권장됨.
WiFi Direct 기기 탐색 및 연결
WiFi Direct는 인터넷 없이 두 기기를 직접 연결하는 P2P 방식임.
WifiP2pManager를 이용해 주변 기기를 탐색하고 연결 요청을 보낼 수 있으며, 연결 후 Group Owner의 IP 주소를 파일 서버 주소로 활용함.class WifiDirectTransfer(private val context: Context) : WifiP2pManager.PeerListListener { private val manager: WifiP2pManager = context.getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager private val channel: WifiP2pManager.Channel = manager.initialize(context, Looper.getMainLooper(), null) fun discoverPeers() { manager.discoverPeers(channel, object : WifiP2pManager.ActionListener { override fun onSuccess() { Log.d("P2P", "Discovery started") } override fun onFailure(reason: Int) { Log.e("P2P", "Discovery failed: $reason") } }) } override fun onPeersAvailable(peerList: WifiP2pDeviceList) { peerList.deviceList.forEach { device -> Log.d("P2P", "Found: ${device.deviceName} - ${device.deviceAddress}") } } fun connectToDevice(device: WifiP2pDevice) { val config = WifiP2pConfig().apply { deviceAddress = device.deviceAddress wps.setup = WpsInfo.PBC } manager.connect(channel, config, object : WifiP2pManager.ActionListener { override fun onSuccess() { Log.d("P2P", "Connection initiated") } override fun onFailure(reason: Int) { Log.e("P2P", "Connect failed: $reason") } }) } fun requestConnectionInfo(callback: (WifiP2pInfo) -> Unit) { manager.requestConnectionInfo(channel) { info -> callback(info) } } }위치 권한(
ACCESS_FINE_LOCATION)이 없으면 기기 탐색이 동작하지 않으므로, 런타임 권한 요청을 반드시 포함해야 함.
소켓 기반 파일 전송
핫스팟 또는 WiFi Direct로 연결된 후에는 소켓 서버/클라이언트로 실제 파일을 전송할 수 있음.
서버 측에서는
ServerSocket을 열어 파일명과 바이트 데이터를 수신하고, 클라이언트 측에서는 서버 IP에 연결해 파일을 전송함.// 서버 (핫스팟 Host) class FileTransferServer(private val port: Int = 8888) { private var serverSocket: ServerSocket? = null fun startServer(onFileReceived: (fileName: String, bytes: ByteArray) -> Unit) { Thread { serverSocket = ServerSocket(port) while (true) { try { val client = serverSocket!!.accept() Thread { client.use { val dis = DataInputStream(it.getInputStream()) val nameLen = dis.readInt() val nameBytes = ByteArray(nameLen).also { b -> dis.readFully(b) } val fileName = String(nameBytes, Charsets.UTF_8) val fileSize = dis.readLong() val buffer = ByteArray(fileSize.toInt()).also { b -> dis.readFully(b) } onFileReceived(fileName, buffer) } }.start() } catch (e: Exception) { if (serverSocket?.isClosed == true) break } } }.start() } fun stop() { serverSocket?.close() } } // 클라이언트 (접속자) class FileTransferClient(private val serverIp: String, private val port: Int = 8888) { fun sendFile(fileName: String, fileBytes: ByteArray, onProgress: (Int) -> Unit = {}) { Thread { Socket(serverIp, port).use { socket -> val dos = DataOutputStream(socket.getOutputStream()) val nameBytes = fileName.toByteArray(Charsets.UTF_8) dos.writeInt(nameBytes.size) dos.write(nameBytes) dos.writeLong(fileBytes.size.toLong()) var offset = 0 val chunk = 4096 while (offset < fileBytes.size) { val len = minOf(chunk, fileBytes.size - offset) dos.write(fileBytes, offset, len) offset += len onProgress(offset * 100 / fileBytes.size) } dos.flush() } }.start() } }전송 진행률은
onProgress콜백으로 UI에 반영할 수 있으며, 대용량 파일의 경우 스트리밍 방식으로 처리하는 것을 권장함.
NanoHTTPD HTTP 파일 서버
NanoHTTPD를 사용하면 HTTP 기반 파일 서버를 앱 내에서 띄울 수 있어, 브라우저에서도 파일을 업로드·다운로드할 수 있음.
build.gradle에implementation 'org.nanohttpd:nanohttpd:2.3.1'을 추가한 후 아래와 같이 구현함.class MobileFileServer(private val rootDir: File, port: Int = 8080) : NanoHTTPD(port) { override fun serve(session: IHTTPSession): Response { val uri = session.uri return when { uri == "/list" -> { val json = rootDir.listFiles() ?.joinToString(",", "[", "]") { "\"${it.name}\"" } ?: "[]" newFixedLengthResponse(Response.Status.OK, "application/json", json) } uri.startsWith("/download/") -> { val file = File(rootDir, uri.removePrefix("/download/")) if (file.exists()) newFixedLengthResponse( Response.Status.OK, "application/octet-stream", file.inputStream(), file.length() ).also { it.addHeader("Content-Disposition", "attachment; filename=\"${file.name}\"") } else newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found") } uri == "/upload" && session.method == Method.POST -> { val files = mutableMapOf<String, String>() session.parseBody(files) val tmpPath = files["file"] val fileName = session.parameters["filename"]?.firstOrNull() ?: "upload_${System.currentTimeMillis()}" if (tmpPath != null) File(tmpPath).copyTo(File(rootDir, fileName), overwrite = true) newFixedLengthResponse("OK") } else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "404") } } }핫스팟 게이트웨이 기본 IP는
192.168.43.1이며, 동적으로 확인하려면WifiManager.dhcpInfo.gateway를 파싱해서 사용함.
권한 및 방식 비교
각 기능별로 필요한 권한과 제한 사항을 정리하면 아래와 같음.
기능 필요 권한 제한 WiFi 상태 확인 ACCESS_WIFI_STATE없음 LocalOnlyHotspot CHANGE_WIFI_STATEAPI 26+ 전체 핫스팟 제어 TETHER_PRIVILEGED시스템 앱 전용 USB 테더링 토글 CHANGE_NETWORK_STATE+ Reflection기기마다 다름 WiFi Direct ACCESS_FINE_LOCATION위치 권한 필수 일반 앱은
startLocalOnlyHotspot()범위 내에서만 핫스팟을 제어할 수 있으며, 인터넷 공유가 필요하면 사용자가 직접 설정에서 활성화해야 함.USB 테더링은 Reflection 방식이 일부 기기에서만 동작하므로, 제품 앱보다는 사내 도구 수준에서 사용하는 것이 적합함.
마무리
Android 테더링 구현은 일반 앱·시스템 앱 여부에 따라 접근 가능한 API가 크게 달라짐.
기기 간 파일 이동이 목적이라면 WiFi Direct + 소켓 서버 조합이 가장 안정적이며, 브라우저 접근까지 원하면 NanoHTTPD HTTP 서버 방식을 추가로 고려할 수 있음.
권한 범위와 기기 호환성을 충분히 검토한 뒤 구현 방식을 선택하는 것을 권장함.