Flutter Android 앱에서 API 연결이 안 될 때: 권한·localhost·HTTPS 점검

핵심 요약
Flutter Android 앱의 API 요청이 실패할 때 인터넷 권한, 에뮬레이터 localhost, 실기기 네트워크, HTTP cleartext 정책, TLS 인증서와 타임아웃을 순서대로 점검합니다.

검수 범위
Flutter로 만든 Android 네이티브 앱이 HTTP API에 연결하는 상황을 대상으로 합니다. Flutter Web의 CORS, iOS ATS, 서버가 반환하는 비즈니스 오류는 검증 방식이 달라 필요한 구간에서 구분해 설명합니다.

Flutter 화면에서 로딩 표시만 계속되거나 ClientException, SocketException이 보이면 JSON 파싱 코드부터 고치기 쉽습니다. 그러나 서버 응답이 오기 전 단계에서 끊긴 요청은 모델 클래스나 jsonDecode를 수정해도 달라지지 않습니다. 먼저 요청 URL, 기기에서 서버까지의 경로, Android 네트워크 정책, TLS, HTTP 응답 순서로 범위를 줄여야 합니다.

오류 문구로 첫 점검 위치 찾기

증상 또는 오류 가능성이 높은 구간 첫 확인 항목
Failed host lookup 호스트명·DNS·기기 네트워크 URL 오타와 기기에서 같은 호스트를 찾을 수 있는지
Connection refused IP·포트·서버 listen 주소 API 프로세스가 해당 포트에서 외부 연결을 받는지
Connection timed out 라우팅·방화벽·서버 지연 요청 도착 로그와 실패까지 걸린 시간
CLEARTEXT communication not permitted Android의 평문 HTTP 정책 API URL이 http://인지와 앱의 보안 설정
HandshakeException 또는 CERTIFICATE_VERIFY_FAILED TLS 인증서·호스트명·체인 인증서 유효기간, 도메인 일치, 중간 인증서
HTTP 401·403·404·500 API 인증·경로·서버 처리 상태 코드, 응답 본문, 서버 요청 ID

HTTP 상태 코드가 왔다는 것은 DNS, TCP 연결과 대개 TLS 협상까지 통과했다는 뜻입니다. 이 경우 인터넷 권한을 반복해서 바꾸기보다 요청 경로, 인증 헤더와 서버 로그를 확인합니다. 반대로 상태 코드 없이 소켓 예외가 발생했다면 서버 응답을 해석하는 코드보다 연결 계층이 우선입니다.

1단계: 실제로 실행된 URL과 빌드 환경 확인

디버그와 릴리스에서 서로 다른 base URL을 쓰면 코드에서 본 주소와 설치된 앱이 호출한 주소가 다를 수 있습니다. 요청을 보낼 때 토큰이나 개인정보를 제외하고 scheme, host, port, path, 빌드 환경과 소요 시간을 기록합니다.

final uri = Uri.parse('$apiBaseUrl/health');
debugPrint('API target: ${uri.scheme}://${uri.host}:${uri.port}${uri.path}');

apiBaseUrl 끝의 슬래시와 path 앞의 슬래시가 겹치거나 빠지지 않았는지, 개발용 주소가 릴리스 빌드에 남지 않았는지 확인합니다. 서버의 /health처럼 인증 없이 최소 응답만 주는 경로가 있다면 로그인 API보다 먼저 연결 시험에 사용합니다.

2단계: Android 인터넷 권한 확인

Flutter의 공식 네트워킹 안내처럼 Android 앱은 android/app/src/main/AndroidManifest.xml에 인터넷 권한을 선언해야 합니다. <application> 내부가 아니라 <manifest> 바로 아래에 둡니다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="example">
        ...
    </application>
</manifest>

INTERNET은 설치 후 사용자가 팝업에서 허용하는 런타임 권한이 아닙니다. 설정 화면에서 권한 버튼을 찾기보다 최종 APK 또는 App Bundle에 합쳐진 manifest를 확인합니다. 빌드 variant별 manifest가 있다면 디버그에는 있고 릴리스에는 없는지도 점검합니다.

3단계: 에뮬레이터의 localhost를 구분하기

Android Emulator 안의 127.0.0.1localhost는 개발 PC가 아니라 에뮬레이터 자신을 가리킵니다. PC에서 localhost:8080으로 잘 열리는 개발 서버라도 앱에서 같은 주소를 쓰면 연결이 거절될 수 있습니다. 표준 Android Emulator에서는 호스트 PC의 loopback으로 연결할 때 특수 주소 10.0.2.2를 사용합니다.

// Android Emulator에서 PC의 localhost:8080에 접근
const apiBaseUrl = 'http://10.0.2.2:8080';

주소를 바꿨는데도 거절된다면 API 서버가 127.0.0.1에서만 듣는지, 해당 포트가 실제로 열려 있는지, PC 방화벽이 에뮬레이터 네트워크를 막는지 확인합니다. 공식 Android 문서도 에뮬레이터 통신이 개발 장비의 방화벽이나 네트워크 환경에 의해 차단될 수 있다고 설명합니다.

4단계: 실기기에서는 PC의 LAN 주소로 시험

실제 Android 기기에는 10.0.2.2 규칙이 적용되지 않습니다. 휴대전화와 개발 PC를 같은 Wi-Fi에 연결하고 PC의 사설 LAN 주소와 API 포트를 사용합니다. 서버도 loopback 전용이 아니라 필요한 네트워크 인터페이스에서 연결을 받아야 합니다.

  1. PC에서 API가 정상 응답하는지 확인합니다.
  2. 휴대전화 브라우저에서 같은 LAN 주소와 포트의 health URL을 엽니다.
  3. 열리지 않으면 앱 코드보다 Wi-Fi 격리, VPN, 서버 bind 주소와 OS 방화벽을 확인합니다.
  4. 브라우저에서는 열리고 앱만 실패하면 Android 정책, TLS와 앱의 URL 구성을 확인합니다.

공용 Wi-Fi는 기기끼리 통신을 차단할 수 있고 회사 VPN은 사설 주소 경로를 바꿀 수 있습니다. PC의 브라우저에서 성공했다는 사실만으로 휴대전화에서도 같은 네트워크 경로가 열린 것은 아닙니다.

5단계: HTTP cleartext 차단은 개발과 운영을 나눠 처리

Android 9(API 28) 이상을 대상으로 하는 앱은 기본적으로 평문 HTTP 통신을 허용하지 않습니다. 운영 API는 HTTPS로 제공하는 것이 원칙입니다. 단순히 오류를 없애려고 릴리스 앱 전체에 android:usesCleartextTraffic="true"를 적용하면 인증 정보와 응답이 암호화되지 않은 연결로 나갈 가능성이 커집니다.

통제된 개발 호스트만 예외가 필요하다면 Android의 Network Security Configuration으로 허용 범위를 좁히고, 개발 variant에만 포함되도록 관리합니다.

<!-- AndroidManifest.xml -->
<application
    android:networkSecurityConfig="@xml/network_security_config">
    ...
</application>
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">dev-api.example.internal</domain>
    </domain-config>
</network-security-config>

예시 도메인은 실제 개발 환경에 맞게 바꾸고 운영 빌드에는 포함하지 않습니다. Android는 API 수준과 Network Security Configuration 사용 여부에 따라 usesCleartextTraffic 처리 방식이 달라질 수 있으므로 전역 속성 하나에 의존하지 말고 공식 보안 설정 문서를 기준으로 확인합니다.

6단계: TLS 오류에서 검증을 끄지 않기

HandshakeException은 HTTPS 연결의 인증서 검증이나 협상 단계에서 실패했음을 나타냅니다. 서버 인증서의 유효기간, 요청한 호스트명과 인증서 이름의 일치, 중간 인증서 체인, 기기 날짜와 시간을 확인합니다. 개발 서버가 사설 인증기관을 쓴다면 통제된 디버그 환경에서만 신뢰 anchor를 추가하고 운영에서는 공개적으로 신뢰되는 인증서와 완전한 체인을 사용합니다.

badCertificateCallback에서 항상 true를 반환하거나 인증서 검증을 전역으로 끄는 방식은 해결책으로 사용하지 않습니다. 연결은 되는 것처럼 보여도 중간자 공격을 차단할 수 없고, 임시 코드가 릴리스에 남을 위험이 있습니다.

7단계: 타임아웃과 소켓 오류를 분리해 기록

요청에 명시적인 제한 시간을 두고 네트워크 연결, TLS, 시간 초과와 HTTP 오류를 서로 다른 분류로 기록하면 사용자 안내와 재시도 정책을 구분할 수 있습니다. 다음 예시는 dart:io를 쓰므로 이 글의 범위인 Android 네이티브 앱에 해당합니다.

import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;

Future<http.Response> loadHealth(Uri uri) async {
  try {
    final response = await http
        .get(uri)
        .timeout(const Duration(seconds: 10));

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw HttpException('HTTP ${response.statusCode}', uri: uri);
    }
    return response;
  } on SocketException catch (error) {
    throw Exception('네트워크 연결 실패: ${error.osError?.errorCode}');
  } on HandshakeException {
    throw Exception('TLS 인증서 또는 보안 연결 실패');
  } on TimeoutException {
    throw Exception('API 응답 시간 초과');
  }
}

로그에는 host, port, 경과 시간, HTTP 상태, 예외 종류와 서버 요청 ID를 남기되 Authorization 헤더, 쿠키, refresh token과 개인정보가 포함된 응답 본문은 남기지 않습니다. SocketException은 운영체제 수준 오류 정보를 포함할 수 있어 error code가 원인 분류에 도움이 됩니다.

8단계: 재시도 전에 요청 성격 확인

GET 상태 조회처럼 멱등성이 보장된 요청과 결제·등록 POST는 같은 방식으로 자동 재시도하면 안 됩니다. 연결이 끊겨도 서버에서는 처리가 끝났을 수 있기 때문입니다. 변경 요청에는 멱등 키나 서버의 중복 방지 기준을 두고, 지수형 대기와 최대 횟수를 정한 뒤 재시도합니다.

  • DNS 오류: 네트워크가 회복된 뒤 제한적으로 재시도
  • 연결 거절: 주소·포트 설정 오류일 수 있으므로 즉시 반복하지 않음
  • 시간 초과: 서버 처리 완료 여부를 조회한 뒤 재시도
  • 401: refresh token 흐름을 한 번만 수행하고 원 요청 재실행
  • 400·403·404: 입력·권한·경로를 고치기 전 자동 재시도하지 않음
  • 500·503: 요청 성격과 Retry-After를 확인해 제한적으로 재시도

Flutter Web의 CORS와 혼동하지 않기

Android 네이티브 앱의 HTTP 클라이언트는 브라우저의 동일 출처 정책으로 동작하지 않으므로 일반적인 CORS 오류 해결 대상이 아닙니다. 같은 Flutter 코드라도 Web 빌드에서만 실패한다면 브라우저 개발자 도구에서 preflight와 서버의 CORS 응답 헤더를 확인합니다. Android 앱에서 실패한다면 CORS 헤더를 무작정 추가하기보다 이 글의 권한, 주소, cleartext와 TLS 순서를 먼저 봅니다.

디버그에서는 되고 릴리스에서만 실패할 때

확인 항목 판단 기준
base URL 릴리스 flavor가 운영 HTTPS 주소를 주입했는지
merged manifest INTERNET 권한과 보안 설정이 최종 산출물에 있는지
개발 예외 debug 전용 cleartext·사설 CA 설정에 의존하지 않는지
인증서 운영 도메인과 인증서 이름·체인이 일치하는지
재현 실기기에서 flutter run --release로 같은 오류가 나는지

수정 후 재현 테스트

테스트 기대 결과
에뮬레이터 → PC 개발 API 10.0.2.2와 지정 포트로 health 응답
실기기 → PC 개발 API 같은 Wi-Fi의 LAN 주소로 응답
잘못된 호스트명 DNS 오류로 분류되고 민감정보 없이 안내
닫힌 포트 연결 거절로 분류되고 무한 재시도 없음
유효하지 않은 인증서 TLS 오류로 차단되고 검증 우회 없음
HTTP 개발 주소 허용한 debug 환경에서만 연결
401·404·500 응답 네트워크 오류와 분리해 상태별 처리
응답 지연 정해진 시간에 timeout 처리되고 중복 요청 방지

가장 짧은 진단 순서

  1. 앱이 실제로 호출한 scheme, host, port와 빌드 환경을 확인합니다.
  2. 최종 Android manifest의 INTERNET 권한을 확인합니다.
  3. 에뮬레이터라면 localhost 대신 10.0.2.2, 실기기라면 PC의 LAN 주소로 시험합니다.
  4. HTTP라면 cleartext 차단 여부를 확인하고 운영 주소는 HTTPS로 전환합니다.
  5. HTTPS라면 인증서 이름, 유효기간과 중간 인증서 체인을 확인합니다.
  6. 상태 코드가 왔다면 인증·경로·서버 처리 문제로 범위를 옮깁니다.
  7. 디버그와 릴리스 빌드에서 같은 테스트 표를 다시 실행합니다.

이 순서를 지키면 앱 코드, 에뮬레이터 주소, Android 보안 정책과 서버 오류를 한꺼번에 바꾸지 않고 원인이 있는 계층만 수정할 수 있습니다.


검증 기준과 참고 자료

이 글은 2026-09-12에 Flutter, Android, Dart의 공식 문서를 기준으로 인터넷 권한, 에뮬레이터 주소, Network Security Configuration과 네트워크 예외의 의미를 검수했습니다. Android 버전, 빌드 설정과 사내 네트워크 정책에 따라 실제 오류 문구와 적용 범위는 달라질 수 있습니다.

관련 글