시뮬레이터에선 되던 앱이 실기기에서 터진 이유

April 9, 2026

웹 개발자의 Flutter 입문기
  1. 1. 시뮬레이터에선 되던 앱이 실기기에서 터진 이유

웹 개발자의 Flutter 입문기. 웹에서 당연하던 것들이 모바일에서는 다릅니다.

이번 글에서는 시뮬레이터에서 실기기로 넘어가면서 만난 첫 번째 벽, localhost 네트워크 문제를 해결한 과정을 공유할게요.

어느 날 실기기에서 Connection refused

Flutter로 사이드 프로젝트를 만들고 있어요. 백엔드는 Express + Prisma로 Mac에서 로컬 서버를 돌리고, Flutter 앱은 localhost:3100으로 API를 호출하는 구조입니다.

iOS 시뮬레이터에서 개발하는 동안은 아무 문제가 없었어요. 로그인, 방 생성, 타임라인 조회까지 전부 잘 됐습니다.
그러다 카메라 기능을 붙여야 할 차례가 왔는데, 카메라는 시뮬레이터에서 테스트할 수 없어서 실제 iPhone을 연결해야 했어요.

별 문제 없을 줄 알았습니다. 그런데 실기기에서 앱을 실행하고 로그인 버튼을 누르자마자 이런 에러가 떴어요.

Connection refused (OS Error: Connection refused, errno = 61)
address = localhost, port = 50035

localhost:3100으로 설정했는데 포트가 50035? 처음엔 포트 충돌인 줄 알았어요. 서버를 재시작해보고, 포트를 바꿔보고, 방화벽 설정도 확인해봤습니다. 전부 소용없었어요.

그래서 에러 메시지를 다시 들여다봤습니다. 포트 50035는 요청 측 임시 포트였고, 진짜 문제는 address = localhost였어요.

시뮬레이터와 실기기의 localhost는 다른 곳을 가리킨다

웹 개발할 때는 localhost가 항상 “내 컴퓨터”예요. 브라우저와 서버가 같은 머신에서 돌아가니까요. 시뮬레이터도 Mac 위에서 돌아가는 프로세스라 마찬가지였고요.

하지만 제가 놓치고 있던 건, 실제 iPhone은 독립된 기기라는 점이에요. iPhone에서 localhost는 iPhone 자기 자신을 가리킵니다. Mac에서 돌고 있는 Express 서버와는 전혀 관계가 없어요.

환경localhost가 가리키는 곳Mac 서버 접근
iOS 시뮬레이터Mac (호스트 머신)
Android 에뮬레이터에뮬레이터 자체❌ (10.0.2.2로 우회)
실제 iPhone / Android기기 자체

이걸 이해하고 나니 시뮬레이터에서 왜 한 번도 문제가 안 됐는지도 바로 납득이 갔습니다. Docker를 써봤다면 host.docker.internal이 떠오를 수도 있어요. 실행 환경이 호스트와 분리되면, localhost는 더 이상 “내 컴퓨터”가 아니에요.

그래서 해결 방향은 명확했어요. 실기기에서는 localhost 대신 Mac의 실제 IP 주소(예: 192.168.0.10)로 요청을 보내면 됩니다. 같은 Wi-Fi에 연결되어 있으면 기기에서 Mac에 도달할 수 있거든요.

그런데 단순히 localhost를 IP로 바꾸면 되는 걸까요?

device_info_plus로 자동 감지

가장 빠른 방법은 api_constants.dart에서 localhost를 Mac IP로 하드코딩하는 거예요. 5초면 끝나지만, 시뮬레이터로 돌아가면 다시 바꿔야 하고, Wi-Fi가 바뀌면 또 바꿔야 해요. 이런 불편은 금방 쌓입니다.

그래서 device_info_plus라는 Flutter 패키지를 써봤어요. 현재 기기가 시뮬레이터인지 실기기인지를 런타임에 감지해주는 패키지입니다.

// api_constants.dart

import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';

const String devHostIp = '192.168.0.10'; // Wi-Fi 바뀌면 여기만 수정

Future<String> getDevHost() async {
  final deviceInfo = DeviceInfoPlugin();

  if (Platform.isIOS) {
    final iosInfo = await deviceInfo.iosInfo;
    return iosInfo.isPhysicalDevice ? devHostIp : 'localhost';
  }

  if (Platform.isAndroid) {
    final androidInfo = await deviceInfo.androidInfo;
    return androidInfo.isPhysicalDevice ? devHostIp : '10.0.2.2';
  }

  return 'localhost';
}

isPhysicalDevicetrue면 실기기니까 Mac IP로, false면 시뮬레이터니까 localhost로 보내는 거예요. 시뮬레이터 ↔ 실기기를 오가도 코드를 건드릴 필요가 없어졌습니다.

그런데 이 함수 하나를 추가했을 뿐인데, 생각보다 손이 많이 갔어요.

호스트 주소 하나 바꿨을 뿐인데

getDevHost()async 함수예요. 이걸 호출하는 쪽도 async가 되고, 그 위도 async가 돼야 합니다. 문제는 DioClient가 동기 생성자로 URL을 받고 있었다는 거예요.

Dart에서는 생성자를 async로 만들 수 없어요. 그래서 기존의 DioClient(String baseUrl) 생성자를 private으로 바꾸고, async factory 메서드를 만들어야 했습니다.

class DioClient {
  DioClient._(String baseUrl) {
    _dio = Dio(BaseOptions(baseUrl: baseUrl));
  }

  static Future<DioClient> create() async {
    final host = await getDevHost();
    return DioClient._('http://$host:3100/api/v1');
  }
}

// DI 등록도 await가 필요해짐
final dioClient = await DioClient.create();

DioClient 하나 바뀌었을 뿐인데, DI 설정에서 await가 필요해지고, 앱 초기화 흐름까지 async가 전파됐어요. 동기 → 비동기 전환이 한 함수에서 끝나지 않고 호출 체인을 따라 올라간다는 걸 직접 체감한 순간이었습니다.

웹에서도 동기 함수에 API 호출 하나를 추가하면 async/await가 연쇄적으로 퍼지긴 하지만, Flutter에서는 생성자 자체를 async로 바꿀 수 없어서 factory 패턴까지 도입해야 했어요. 호스트 주소 하나 바꾸려던 것치고는 손이 꽤 많이 갔습니다.

이렇게 바꾸고 나서 실기기에서도 정상적으로 API가 호출됐어요. 시뮬레이터로 돌아가도 코드를 건드릴 필요 없이 그대로 동작했고요.

정리하기

  • 시뮬레이터의 localhost는 Mac이지만, 실기기의 localhost는 기기 자신이에요. 웹에서는 의식하지 않았던 차이가 모바일에서는 첫 번째 벽이 돼요.
  • 하드코딩 대신 device_info_plus로 자동 감지하면, 시뮬레이터 ↔ 실기기 전환 비용이 0이 돼요. 다만 Wi-Fi가 바뀌면 devHostIp는 여전히 수동 수정이 필요해요.
  • 동기 함수를 비동기로 바꾸면 호출 체인 전체에 async가 전파돼요. Flutter에서는 생성자를 async로 만들 수 없어서 factory 패턴까지 필요했습니다.

다음 편에서는 localhost를 해결한 뒤에도 이어진 실기기 테스트 삽질을 이야기할게요.

On this page