Fuwari Banner
지니제스트Tech Archive
모바일7분 소요

안드로이드 Cleartext 차단과 로컬 API 연결 실패 해결

•
ggeniezst

안드로이드 로컬 개발 환경에서 발생하는 Cleartext HTTP 차단과 Connection refused 에러 원인을 분석하고, network_security_config와 adb reverse를 통한 안전한 해결 절차를 다룹니다.

Sponsored

개발 PC에서 스프링 부트, 노드(Node.js), 혹은 패스트API(FastAPI) 백엔드 서버를 http://localhost:8080 포트로 띄워두고 안드로이드 에뮬레이터나 실기기에서 API를 호출하면 요청이 즉시 실패하는 문제를 흔히 마주합니다. 포스트맨(Postman)이나 PC 웹 브라우저에서는 200 OK 응답이 정상적으로 수신되는데, 안드로이드 앱의 OkHttp나 Retrofit 클라이언트는 네트워크 연결조차 맺지 못하고 예외를 발생시킵니다. 이 현상은 에뮬레이터 고유의 가상 라우터 네트워크 구조에 대한 이해 부족과 안드로이드 9부터 기본 활성화된 평문(Cleartext) HTTP 차단 정책이 맞물려 발생합니다.


로컬 개발 환경 통신 장애와 시스템 에러 로그

로컬 API 연동 과정에서 발생하는 오류는 크게 두 가지 형태로 나타납니다. 첫 번째는 호스트 PC의 루프백 주소를 그대로 모바일 환경에 적용했을 때 발생하는 연결 거부(Connection refused) 현상이고, 두 번째는 호스트 IP를 제대로 지정했음에도 운영체제 보안 정책에 의해 요청 자체가 차단되는 현상입니다.

앱에서 http://localhost:8080 또는 http://127.0.0.1:8080으로 직접 HTTP 요청을 보낼 경우 터미널 Logcat에는 다음과 같은 소켓 연결 실패 로그가 기록됩니다:

TEXT
java.net.ConnectException: Failed to connect to /127.0.0.1:8080
    at okhttp3.internal.connection.RealConnection.connectSocket(RealConnection.kt:297)
    at okhttp3.internal.connection.RealConnection.connect(RealConnection.kt:207)
    at okhttp3.internal.connection.ExchangeFinder.findConnection(ExchangeFinder.kt:226)
    at okhttp3.internal.connection.ExchangeFinder.findHealthyConnection(ExchangeFinder.kt:106)
Caused by: android.system.ErrnoException: isConnected failed: ECONNREFUSED (Connection refused)

이 오류를 피하고자 호스트 PC의 로컬 네트워크 IP(예: 192.168.0.15:8080)나 에뮬레이터 전용 게이트웨이 주소(10.0.2.2:8080)로 엔드포인트를 변경하면 이번에는 또 다른 예외가 발생합니다:

TEXT
java.io.IOException: Cleartext HTTP traffic to 10.0.2.2 not permitted
    at okhttp3.internal.connection.RealConnection.connect(RealConnection.kt:207)
    at okhttp3.internal.connection.ExchangeFinder.findConnection(ExchangeFinder.kt:226)
    at okhttp3.internal.connection.ExchangeFinder.findHealthyConnection(ExchangeFinder.kt:106)
    at okhttp3.internal.connection.ExchangeFinder.find(ExchangeFinder.kt:74)

이 에러는 네트워크 연결 타임아웃이 아니라 애플리케이션 프레임워크 레벨에서 소켓을 열기 전에 즉시 차단했음을 의미합니다. 정확한 장애 분석을 위해 터미널에서 Logcat 필터를 적용하여 시스템 런타임과 네트워크 보안 프레임워크의 동작을 모니터링해야 합니다.

BASH
# Logcat에서 네트워크 보안 정책 위반 및 OkHttp 예외 로그 필터링
adb logcat -v time \
  OkHttp:E \
  AndroidRuntime:E \
  System.err:W \
  *:S

QEMU 가상 라우터와 안드로이드 네트워크 보안 아키텍처

두 가지 에러가 연쇄적으로 발생하는 근본 원인은 안드로이드 에뮬레이터의 네트워크 가상화 계층과 안드로이드 OS의 전역 네트워크 보안 구성(Network Security Configuration)에 있습니다.

안드로이드 공식 에뮬레이터는 호스트 PC의 네트워크 인터페이스와 브릿지(Bridge) 방식으로 직접 연결되지 않습니다. 에뮬레이터 프로세스는 QEMU 가상화 엔진 내부의 가상 라우터(NAT 모드) 뒤에 완전히 격리된 사설 가상 서브넷(10.0.2.0/24)을 할당받습니다.

가상 네트워크 IP 역할 및 매핑 대상
10.0.2.1 에뮬레이터 가상 라우터 게이트웨이 주소
10.0.2.2 개발 호스트 PC의 루프백(127.0.0.1) 전용 별칭
10.0.2.3 호스트 PC의 DNS 서버 주소
10.0.2.15 에뮬레이터 기기 자체에 할당된 사설 IP 인터페이스

개발자가 모바일 코드에서 127.0.0.1이나 localhost를 지정하면, 이는 호스트 PC를 가리키는 것이 아니라 에뮬레이터 가상 기기 자체의 내부 루프백 인터페이스(lo)를 향하게 됩니다. 에뮬레이터 안드로이드 OS 내부에는 8080 포트를 리스닝하는 서버 데몬이 없으므로 커널 수준에서 즉각 ECONNREFUSED를 반환하는 것입니다. 호스트 PC의 로컬 포트에 접근하려면 반드시 10.0.2.2 인터페이스를 거쳐야 합니다.

그러나 IP를 10.0.2.2로 수정하더라도 안드로이드 9(Pie, API Level 28)부터 도입된 보안 표준에 부딪힙니다. 안드로이드 플랫폼은 TargetSdkVersion >= 28인 모든 애플리케이션에 대해 cleartextTrafficPermitted 기본값을 false로 강제합니다.

모바일 런타임의 NetworkSecurityPolicy.isCleartextTrafficPermitted(hostname) 검증 로직은 소켓 통신을 시도하는 대상 프로토콜이 http://와 같은 평문인지 검사합니다. HTTPS 암호화 계층(TLS)이 적용되지 않은 트래픽이 감지되면 OS 차원에서 데이터 전송 패킷을 폐기하고 즉시 IOException을 던집니다.

일부 개발자들은 빠른 문제 해결을 위해 AndroidManifest.xml의 <application> 태그에 android:usesCleartextTraffic="true" 플래그를 선언하곤 합니다. 하지만 로컬 개발 통신 문제를 해결하기 위해 프로덕션 전체의 암호화 검증을 무력화하는 것은 치명적인 보안 결함입니다. 이 플래그가 릴리스 빌드에 포함되면 앱의 모든 외부 통신이 중간자 공격(MITM)과 패킷 도청에 무방비로 노출되며, 구글 플레이 스토어 데이터 보안 정책 심사에서도 거절 사유가 됩니다.


디버그 전용 네트워크 보안 구성 파일 작성

가장 안전하고 표준적인 해결책은 안드로이드 공식 기능인 네트워크 보안 구성(Network Security Configuration) XML 파일을 작성하여, 개발 및 디버그 빌드에서만 지정된 로컬 주소에 한해 평문 통신을 허용하도록 격리하는 것입니다.

먼저 프로젝트의 리소스 디렉토리(app/src/main/res/xml/)에 network_security_config.xml 파일을 생성합니다.

XML
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <!-- 기본 정책: 모든 도메인에 대해 평문 HTTP 통신 엄격 차단 -->
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>

    <!-- 디버그 빌드 환경에만 적용되는 예외 오버라이드 -->
    <debug-overrides>
        <trust-anchors>
            <!-- 디버그 시 개발자 PC의 자체 서명 인증서나 사용자 인증서 허용 -->
            <certificates src="user" />
            <certificates src="system" />
        </trust-anchors>
    </debug-overrides>

    <!-- 로컬 개발 호스트 전용 평문 HTTP 화이트리스트 -->
    <domain-config cleartextTrafficPermitted="true">
        <!-- 에뮬레이터 호스트 게이트웨이 -->
        <domain includeSubdomains="true">10.0.2.2</domain>
        <!-- 로컬호스트 주소 (adb reverse 터널링 시 사용) -->
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">127.0.0.1</domain>
        <!-- 사내 개발망 또는 로컬 서브넷 IP (필요시 지정) -->
        <domain includeSubdomains="true">192.168.0.0/16</domain>
    </domain-config>
</network-security-config>

생성한 설정 파일을 시스템이 인식하도록 app/src/main/AndroidManifest.xml의 <application> 블록에 연결합니다.

XML
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.localapidemo">

    <!-- 인터넷 권한 선언 필수 -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.LocalApiDemo"
        android:networkSecurityConfig="@xml/network_security_config">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

이렇게 설정하면 일반 릴리스 APK는 <base-config cleartextTrafficPermitted="false">에 따라 모든 평문 트래픽을 차단하면서, 개발자가 명시한 10.0.2.2와 localhost 대역에서만 평문 통신이 허용됩니다.


adb reverse를 활용한 실기기 로컬 포트 포워딩

에뮬레이터가 아닌 USB 케이블로 연결된 실제 스마트폰 기기에서 로컬 API를 테스트할 때는 또 다른 장벽이 있습니다. 실제 기기는 에뮬레이터의 QEMU 가상 서브넷(10.0.2.2)을 사용할 수 없으며, 호스트 PC와 동일한 Wi-Fi 네트워크에 접속하더라도 공유기의 AP 격리(AP Isolation) 보안 기능 때문에 PC의 사설 IP(192.168.x.x)로의 TCP 패킷 전달이 막히는 경우가 많습니다.

이 문제를 가장 깔끔하게 해결하는 도구가 바로 Android Debug Bridge의 adb reverse 소켓 터널링 기능입니다. 이 명령어는 모바일 기기 내부의 특정 TCP 포트를 호스트 PC의 TCP 포트로 역방향 전달(Reverse Port Forwarding)해 줍니다.

BASH
# USB 디버깅으로 연결된 대상 기기 목록 확인
adb devices

# 모바일 기기의 8080 포트를 호스트 PC의 8080 포트로 역방향 바인딩
adb reverse tcp:8080 tcp:8080

# 현재 등록된 리버스 포워딩 소켓 목록 조회
adb reverse --list

# 모바일 기기 쉘에서 호스트 PC의 백엔드 헬스체크 엔드포인트 직접 호출
adb shell curl -I http://127.0.0.1:8080/health

adb reverse tcp:8080 tcp:8080을 실행하고 나면, 모바일 기기 내부에서 http://127.0.0.1:8080이나 http://localhost:8080으로 전송되는 모든 TCP 패킷이 USB 데이터 케이블의 ADB 데몬 채널을 통해 호스트 PC의 8080 포트로 직결됩니다.

따라서 앱 소스코드에서 에뮬레이터용 10.0.2.2와 실기기용 사설 IP를 조건문으로 분기할 필요 없이, 모든 개발 환경에서 엔드포인트를 http://localhost:8080으로 단일화할 수 있습니다.


설정 적용 프로세스와 소켓 연결 검증

설정 파일 작성과 포트 포워딩을 마쳤다면, 실제 소켓이 정상적으로 개방되고 통신이 통과하는지 단계별로 검증해야 합니다.

가장 먼저 확인해야 할 사항은 호스트 PC에서 구동 중인 백엔드 서버의 바인딩 인터페이스 주소입니다. 백엔드 설정 파일(예: application.yml, .env, vite.config.ts)에서 서버가 127.0.0.1에만 바인딩되어 있다면 에뮬레이터의 10.0.2.2 패킷을 수신하지 못할 수 있습니다. 로컬 인터페이스 바인딩 상태를 터미널에서 점검합니다.

BASH
# Linux/macOS 호스트에서 8080 포트 리스닝 상태 확인
ss -tulpn | grep 8080 || lsof -i :8080

# 출력 예시:
# TCP *:8080 (LISTEN) 또는 0.0.0.0:8080 이어야 외부/가상 라우터 접근 가능

이어서 앱을 클린 빌드하여 수정된 리소스와 매니페스트를 타깃 기기에 설치합니다.

BASH
# 이전 빌드 캐시를 제거하고 디버그 APK 빌드 및 설치
./gradlew clean installDebug

# 실시간 네트워크 트래픽 및 HTTP 요청 상태 코드 점검
adb logcat -s OkHttp

앱 실행 후 API 호출 버튼을 눌렀을 때 Logcat에 --> GET http://localhost:8080/api/v1/users 요청과 함께 <-- 200 OK 응답 헤더 및 JSON 본문이 출력된다면 모든 계층의 네트워크 설정이 정상적으로 완결된 것입니다.


실무 환경에서 자주 발생하는 사이드 이펙트 FAQ

로컬 네트워크 설정과 포트 포워딩을 실무 프로젝트에 적용할 때 엔지니어들이 가장 빈번하게 마주치는 기술적 의문과 부작용 대응 방안입니다.

usesCleartextTraffic 플래그를 매니페스트에 직접 넣으면 안 되나요?

<application android:usesCleartextTraffic="true"> 속성은 프로젝트 내의 모든 외부 통신(서드파티 광고 SDK, 결제 모듈, 분석 트래커 등)에 대해 HTTP 암호화 강제를 해제합니다. 이 상태로 프로덕션 APK를 릴리스할 경우, 공공 와이파이 환경에서 사용자의 세션 토큰이나 민감 정보가 탈취되는 중간자 공격에 노출됩니다. 또한 구글 플레이 콘솔 업로드 시 보안 취약점 경고가 발생하며 최악의 경우 앱 배포가 반려될 수 있습니다. 반드시 network_security_config.xml을 통해 로컬 개발 도메인만 선별적으로 격리해야 합니다.

React Native나 Flutter 개발 시 번들러 화면이 백지로 나오는 이유는 무엇인가요?

리액트 네이티브(Metro 번들러, 기본 포트 8081)나 플러터(Dart VM Service)는 개발 중에 코드 변경 사항을 실시간 반영하기 위해 HTTP 통신뿐만 아니라 웹소켓(ws://) 프로토콜을 사용합니다. 웹소켓 역시 비암호화 연결일 경우 안드로이드의 Cleartext 차단 정책에 걸려 번들링 스크립트를 내려받지 못하고 앱 화면이 백지로 멈춥니다. Metro 번들러를 사용할 때도 network_security_config.xml에 localhost와 10.0.2.2 도메인을 등록하고, 터미널에서 adb reverse tcp:8081 tcp:8081을 사전에 실행해주어야 정상 동작합니다.

사내 사설 인증서를 사용하는 로컬 HTTPS 환경은 어떻게 설정하나요?

로컬 개발 서버에 자체 서명 인증서(Self-signed Certificate)나 사내 사설 CA 인증서를 적용한 경우 SSLHandshakeException: CertPathValidatorException 에러가 발생합니다. 안드로이드 7 이상은 사용자 설치 인증서를 기본 신뢰하지 않기 때문입니다. 이 때는 사설 인증서 파일(local_ca.crt)을 app/src/main/res/raw/ 폴더에 복사하고, network_security_config.xml의 <debug-overrides> 내부에 <certificates src="@raw/local_ca" />를 선언하면 디버그 환경에 한해 손쉽게 SSL 검증을 통과시킬 수 있습니다.

adb reverse 연결이 케이블을 다시 꽂을 때마다 끊어집니다

adb reverse로 생성된 소켓 터널은 기기가 USB 포트에서 분리되거나 ADB 서버가 재시작되면 즉시 초기화됩니다. 매번 수동으로 터미널 명령을 치는 번거로움을 방지하려면 앱의 build.gradle에 디버그 설치 직후 리버스 포워딩을 자동 실행하는 Gradle 태스크를 등록해 두는 것이 효율적입니다.

BASH
# 안드로이드 Gradle 빌드 후 adb reverse 자동 실행 스크립트 예시 (build.gradle)
# tasks.whenTaskAdded { task ->
#     if (task.name == 'installDebug') {
#         task.doLast {
#             exec {
#                 commandLine 'adb', 'reverse', 'tcp:8080', 'tcp:8080'
#             }
#         }
#     }
# }

혹은 프론트엔드 프로젝트의 package.json 스크립트 실행 구문 앞단에 adb reverse tcp:8080 tcp:8080 && react-native run-android와 같이 체이닝해 두면 연결 단절로 인한 런타임 오류를 사전에 차단할 수 있습니다.

Sponsored