같은 이미지 주소가 브라우저에서는 열리는데 Flutter 화면에는 빈칸이나 오류만 보인다면, 먼저 서버 응답을 받았는지, 이미지로 해석했는지, 그릴 공간이 있는지를 나눠 보세요. 주소 끝이 .png라는 사실이나 HTTP 200만으로 이미지 표시 성공을 판정할 수 없습니다.
이 글은 Image.network로 HTTP(S) 이미지를 표시하는 경우를 다룹니다. 앱 에셋 경로와 pubspec.yaml 등록 문제는 범위 밖입니다. Android 연결 권한·localhost·TLS의 기본 점검은 기존 API 연결 가이드에 맡기고, 여기서는 이미지 응답 이후의 디코딩과 화면 표시, Web에서만 다른 조건을 구분합니다.
1. 빈칸을 보고 추측하기 전에 상태를 구분하세요
실패한 URL 하나, 실행 플랫폼, 오류 유형, 재현 시점을 먼저 고정합니다. 같은 화면에서 이미 확인한 공개 이미지와 문제 이미지를 번갈아 표시하면 위젯 배치와 특정 서버 문제를 분리하기 쉽습니다. 인증이 필요한 URL은 브라우저 로그인 상태와 앱의 요청 조건이 같은지도 확인해야 합니다.
| 관찰한 현상 | 먼저 볼 증거 | 다음 조치 |
|---|---|---|
| 상태 코드 없이 연결 실패 | DNS·소켓·TLS 오류와 실제 요청 대상 | Android 권한, 기기 경로, 인증서부터 확인 |
| 401·403 또는 로그인 화면으로 이동 | 최종 응답 상태·리다이렉트·인증 계약 | 만료된 서명 URL이나 누락된 인증 조건 확인 |
| 404 | 대소문자를 포함한 경로와 서버 파일 존재 | 정적 파일 경로·배포 결과 확인 |
| 200인데 이미지 해석 오류 | Content-Type과 실제 응답 형식·바이트 길이 | HTML 로그인 페이지·JSON 오류·손상 파일 여부 확인 |
| 오류 없이 특정 배치에서만 안 보임 | Inspector의 실제 크기, clip·opacity·겹침 | 고정 크기 영역으로 옮겨 배치 문제 분리 |
| Android에서는 되고 Web에서만 실패 | 브라우저 Console·Network와 이미지 서버 CORS | 앱 origin과 서버 허용 정책·로드 전략 확인 |
| 새 이미지를 올렸는데 이전 그림이 보임 | 이미지 URL·앱 캐시·브라우저/CDN 캐시 | 버전 URL과 캐시 갱신 계약 확인 |
오류 유형은 출발점이지 확정 원인이 아닙니다. 대체 문구가 표시됐다는 것은 실패 분기가 작동했다는 뜻이며, 원본 이미지의 문제가 해결됐다는 뜻은 아닙니다.
2. HTTP 성공과 이미지 파일 성공을 따로 확인하세요
- 앱이 실제로 요청한 호스트·경로와 최종 리다이렉트 대상을 확인합니다. 서명 토큰이 들어간 전체 URL은 공개 로그에 남기지 않습니다.
- 상태 코드와 Content-Type을 확인하고, 권한이 있는 진단 환경에서 응답이 실제 이미지인지 봅니다. 200 응답의 본문이 로그인 HTML이나 JSON 오류라면 이미지 디코더가 고칠 수 없습니다.
- 파일이 비어 있거나 잘렸는지, 사용 중인 플랫폼에서 해석 가능한 형식인지 확인합니다. 서버의 Content-Type 표기만 바꿔도 원본 바이트는 변하지 않습니다.
- 인증 이미지는 로그인 브라우저에서 열렸다는 사실만으로 앱에서도 같은 세션이 사용된다고 가정하지 않습니다. 만료 시각·필요한 헤더·플랫폼 지원 조건을 서버 계약과 대조합니다.
서버가 제공하는 이미지 전용 URL과 게시글·공유 페이지 URL을 혼동하지 마세요. 응답 원문이나 쿠키를 다른 사람에게 전달할 필요 없이, 비밀값을 지운 상태 코드·형식·길이·요청 ID만으로 먼저 원인 범위를 좁힐 수 있습니다.
3. 로딩·실패·표시 영역을 분리한 설명용 예제
아래는 lib/main.dart에 넣을 수 있도록 구성한 설명용 코드입니다. 공개 URL은 Flutter 공식 이미지 예제에 실린 GIF를 사용했습니다. 이 글에서는 Flutter SDK로 빌드하거나 기기에서 실행하지 않았으며, 이 주소가 모든 네트워크에서 항상 성공한다고 가정하지 않습니다.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ImageCheckPage()));
class ImageCheckPage extends StatelessWidget {
const ImageCheckPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('이미지 상태 분리')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SizedBox(
width: double.infinity,
height: 220,
child: Image.network(
'https://docs.flutter.dev/assets/images/dash/dash-fainting.gif',
fit: BoxFit.contain,
semanticLabel: 'Flutter Dash 애니메이션 예제',
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
final total = progress.expectedTotalBytes;
final double? value = total != null && total > 0
? (progress.cumulativeBytesLoaded / total)
.clamp(0.0, 1.0).toDouble()
: null;
return Center(
child: CircularProgressIndicator(value: value),
);
},
errorBuilder: (context, error, stackTrace) {
// URL, 토큰, 응답 본문은 이 로그에 넣지 않습니다.
debugPrint('image load error type=${error.runtimeType}');
return const Center(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('이미지를 불러오지 못했습니다.'),
),
);
},
),
),
const SizedBox(height: 12),
const Text('실패 문구가 보이면 응답과 오류 유형을 확인하세요.'),
],
),
);
}
}
세로 ListView가 가로 크기를 제한하고 SizedBox가 높이 220을 확보하므로, 로딩 표시와 오류 문구도 같은 영역을 사용합니다. 다른 화면으로 옮길 때 부모의 가로 제약이 사라지면 double.infinity를 그대로 복사하지 말고 부모가 줄 수 있는 유한한 크기부터 정하세요. Image.network API도 크기 지정 또는 단단한 레이아웃 제약을 권합니다. BoxFit은 주어진 공간 안에서 그리는 방식을 정하며, 크기가 0인 영역을 늘려 주지 않습니다.
총 바이트 수가 알려지지 않았을 때는 진행률 숫자를 만들지 않고 indeterminate 표시를 사용합니다. loadingBuilder는 로딩 중 여러 번 실행될 수 있으므로 그 안에서 요청·setState·자동 재시도를 시작하지 마세요. 호출 횟수는 서버 요청 횟수가 아닙니다. errorBuilder에는 오류 유형 로그와 대체 표시만 두었습니다. 유형만으로 부족하면 별도 진단 도구에서 민감정보를 제거한 상세 오류를 확인하세요.
4. Web에서만 실패하면 브라우저 정책을 확인하세요
브라우저 탭에서 이미지가 열린다는 사실과 Flutter Web이 그 이미지를 가져와 화면에 그릴 수 있다는 사실은 다릅니다. Flutter Web 이미지 문서가 설명하는 것처럼 렌더링 경로에 따라 이미지 바이트 접근과 CORS 허용이 필요합니다. Console의 오류와 Network의 실제 이미지 요청을 함께 읽고, 앱의 scheme·host·port로 구성된 origin을 서버 설정과 대조하세요.
통제하는 이미지 서버나 CDN에서 필요한 앱 origin을 허용하거나 같은 origin으로 제공하는 방법을 먼저 검토합니다. CORS는 서버 응답 정책이므로 앱 요청에 허용 헤더를 임의로 붙이는 것으로 해결되지 않습니다. Android 네이티브의 연결 오류를 전부 CORS라고 부르거나, 브라우저 보안을 끄고 운영 해결로 삼지 마세요.
HTML 요소로 표시하는 별도 전략도 있지만 SDK와 선택한 로드 전략의 조건을 확인해야 합니다. 확인한 Image.network API에서는 webHtmlElementStrategy의 기본값이 never이며, HTML 요소 경로에는 헤더 사용·효과·캡처 등의 제약이 있습니다. 자동으로 우회될 것이라고 가정하거나 예제에 옵션 하나를 무조건 추가하기보다, 프로젝트 SDK의 API와 인증 요구가 맞는지 확인하세요.
5. 이미지 갱신과 캐시를 구분하세요
NetworkImage 문서는 서버 캐시 헤더와 별개로 이미지가 캐시된다고 설명합니다. 서버 파일을 덮어쓴 뒤 같은 URL을 계속 쓰는 경우, 화면 재빌드나 새 위젯 key만으로 새 바이트를 받았다고 판단하면 안 됩니다. 앱 이미지 캐시, 브라우저 캐시, CDN 캐시를 따로 확인하고 실제 요청·응답과 비교하세요.
자주 바뀌는 아바타라면 서버가 내용 버전에 따라 새 URL을 주는 방식처럼 갱신 계약을 정하는 편이 명확합니다. 매 build마다 현재 시각을 붙이면 캐시 이점이 사라지고 서명된 URL이 깨질 수 있습니다. 비공개 이미지는 사용자 변경·로그아웃 때의 캐시 수명도 함께 설계하세요. 화면 크기용 width·height와 디코딩 메모리용 cacheWidth·cacheHeight도 구분해야 하며, 후자는 Web에서 같은 방식으로 적용된다고 가정하지 않습니다.
6. 적용 후에는 이 조건을 각각 확인하세요
다음 표는 독자가 자기 앱에서 수행할 검증 계획입니다. 아래 결과를 이 사이트가 실제 기기에서 관찰했다는 의미는 아닙니다. 실패 응답은 소유하거나 시험 권한이 있는 서버에서 준비하고, 공개 서버에 부하를 주는 반복 요청은 피하세요.
| 준비할 조건 | 확인할 동작 | 남길 기록 |
|---|---|---|
| 정상 이미지 | 이미지가 지정 영역 안에 표시되고 레이아웃이 유지됨 | 플랫폼·SDK·표시 크기·최종 응답 |
| 존재하지 않는 경로 | 실패 문구가 보이고 자동 요청이 반복되지 않음 | 상태 코드·오류 유형·실제 요청 수 |
| 200이지만 HTML 또는 JSON인 응답 | HTTP 성공과 이미지 해석 실패를 구분함 | 민감정보 없는 Content-Type·응답 형식 |
| 느린 응답 또는 길이를 모르는 응답 | 알려진 길이일 때만 수치 진행률을 사용함 | 로딩 표시와 완료·실패 전환 |
| 작은 화면과 다른 부모 배치 | 그릴 영역이 유지되고 잘림·겹침을 구분함 | Inspector에서 확인한 실제 가로·세로 |
| 동일 서버의 이미지를 Web으로 표시 | 앱 origin·CORS·로드 전략과 인증이 맞음 | Console 오류·서버 허용 origin·SDK |
| 동일 URL의 파일을 변경 | 정한 버전·캐시 갱신 정책대로 새 내용이 보임 | 앱/브라우저/CDN 중 확인한 캐시 계층 |
로딩 콜백 횟수나 오류 문구가 사라졌다는 사실만으로 통과 처리하지 마세요. 원본 이미지 표시, 실패 안내, 화면 크기와 요청 수를 각각 확인해야 같은 문제가 돌아왔을 때 비교할 수 있습니다.
확인 기준과 공식 자료
2026년 10월 22일 아래 공식 문서를 직접 읽고 API·플랫폼 조건을 대조했습니다. 판단표와 상태 분리 예제는 이를 바탕으로 작성한 설명 자료입니다. Flutter 빌드·자동 테스트·실기기 실행 결과는 포함하지 않으며 프로젝트 SDK와 서버 조건에서 검증이 필요합니다.
- Image.network 생성자와 플랫폼별 주의점
- Image.errorBuilder: 실패 시 대체 위젯
- Image.loadingBuilder: 진행 표시와 갱신 비용
- Flutter Web 이미지와 CORS
- NetworkImage와 이미지 캐시
- Flutter 인터넷 이미지 표시 예제