Flutter 튜토리얼 29편: HTTP 요청과 REST API
요약
핵심 요지
- 문제 정의: 대부분의 앱은 서버에서 데이터를 가져오거나 보내야 한다. HTTP 통신 방법을 모르면 앱을 만들 수 없다.
- 핵심 주장:
http패키지와FutureBuilder를 조합하면 서버 통신을 선언적으로 처리할 수 있다. - 주요 근거: Flutter 공식 권장 패턴으로, 비동기 데이터를 UI에 자연스럽게 연결한다.
- 실무 기준: GET, POST, PUT, DELETE 요청과 에러 처리, JSON 파싱을 모두 다룬다.
- 한계: 인터셉터나 캐싱이 필요하면 Dio 같은 고급 패키지를 고려해야 한다.
문서가 설명하는 범위
- http 패키지 설치와 기본 사용법
- GET 요청으로 데이터 가져오기
- JSON을 Dart 객체로 변환하기
- FutureBuilder로 비동기 데이터 표시하기
- POST 요청으로 데이터 보내기
- 에러 처리와 로딩 상태 관리
읽는 시간: 15분 | 난이도: 초급~중급
참고 자료
- Fetch data from the internet - 공식 네트워킹 가이드
- http package - 공식 HTTP 패키지
- JSON and serialization - JSON 직렬화 가이드
- Send data to the internet - 데이터 전송 가이드
문제 상황
앱을 만들다 보면 서버에서 데이터를 가져와야 합니다. 사용자 정보, 상품 목록, 뉴스 피드… 대부분의 데이터는 서버에 있습니다.
서버 통신이 필요한 상황
상황 1: 뉴스 앱에서 최신 기사 목록을 가져온다상황 2: 쇼핑 앱에서 상품 정보를 가져온다상황 3: SNS 앱에서 새 게시물을 서버에 저장한다상황 4: 로그인 후 사용자 프로필을 가져온다문제는 다음과 같습니다.
이 모든 걸 직접 구현하면 복잡해집니다. 다행히 Flutter는 이를 쉽게 해결할 수 있는 도구를 제공합니다.
해결 방법
http 패키지로 서버와 통신하고, FutureBuilder로 결과를 화면에 표시합니다.
데이터 흐름은 다음과 같습니다.
챕터 1: http 패키지 설정하기
Why
NOTEFlutter에서 HTTP 요청을 보내려면 별도 패키지가 필요합니다.
http패키지는 Flutter 팀이 만든 공식 패키지입니다.
- 간단하고 직관적인 API
- GET, POST, PUT, DELETE 모두 지원
- 대부분의 앱에 충분한 기능
복잡한 기능(인터셉터, 캐싱)이 필요하면 Dio를 고려하세요. 하지만 처음 배울 때는
http가 가장 좋습니다.
What
NOTE
http패키지를 설치하고 임포트합니다.pubspec.yaml dependencies:http: ^1.2.0import 'package:http/http.dart' as http;import 'dart:convert'; // JSON 파싱용
as http는 패키지에 별명을 붙입니다.http.get(),http.post()처럼 명확하게 사용할 수 있습니다.
How
TIP터미널에서 패키지를 설치합니다.
Terminal window flutter pub add http설치 후 앱을 다시 실행하세요. 핫 리로드로는 새 패키지가 적용되지 않습니다.
Android 설정: 인터넷 권한이 기본으로 활성화되어 있습니다. iOS 설정: HTTP(비암호화) 통신이 필요하면 Info.plist를 수정해야 합니다. 대부분의 API는 HTTPS를 사용하므로 추가 설정이 필요 없습니다.
Watch out
WARNING패키지 버전 확인: pub.dev에서 최신 버전을 확인하세요. 네트워크 권한: 에뮬레이터에서는 잘 되는데 실기기에서 안 되면 권한 문제입니다. HTTPS 필수: iOS는 기본적으로 HTTP 통신을 차단합니다. 테스트용 로컬 서버라면 Info.plist에 예외를 추가해야 합니다.
챕터 2: GET 요청으로 데이터 가져오기
Why
NOTEGET 요청은 서버에서 데이터를 읽어오는 요청입니다. 가장 많이 사용하는 HTTP 메서드입니다.
- 상품 목록 가져오기
- 사용자 프로필 조회
- 검색 결과 받기
GET 요청에는 본문(body)이 없습니다. 필요한 정보는 URL에 포함시킵니다.
What
NOTE
http.get()으로 서버에 요청을 보내고 응답을 받습니다.Future<Album> fetchAlbum() async {// 1. 서버에 GET 요청 보내기final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),);// 2. 응답 상태 코드 확인if (response.statusCode == 200) {// 성공: JSON을 Dart 객체로 변환return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>,);} else {// 실패: 에러 던지기throw Exception('앨범을 불러오지 못했습니다');}}핵심 포인트:
await: 응답이 올 때까지 기다린다response.statusCode: HTTP 상태 코드 (200은 성공)response.body: 응답 본문 (JSON 문자열)jsonDecode(): JSON 문자열을 Map으로 변환
How
TIP전체 예제를 살펴봅시다.
1단계: 데이터 모델 만들기
class Album {final int userId;final int id;final String title;const Album({required this.userId,required this.id,required this.title,});// JSON → Dart 객체factory Album.fromJson(Map<String, dynamic> json) {return Album(userId: json['userId'] as int,id: json['id'] as int,title: json['title'] as String,);}}2단계: HTTP 요청 함수 만들기
Future<Album> fetchAlbum() async {final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),);if (response.statusCode == 200) {return Album.fromJson(jsonDecode(response.body));} else {throw Exception('앨범 로딩 실패');}}3단계: 위젯에서 호출하기
class _MyAppState extends State<MyApp> {late Future<Album> futureAlbum;@overridevoid initState() {super.initState();futureAlbum = fetchAlbum(); // 딱 한 번만 호출}@overrideWidget build(BuildContext context) {return FutureBuilder<Album>(future: futureAlbum,builder: (context, snapshot) {if (snapshot.hasData) {return Text(snapshot.data!.title);} else if (snapshot.hasError) {return Text('에러: ${snapshot.error}');}return CircularProgressIndicator();},);}}
initState에서 호출하는 이유:build에서 호출하면 리빌드될 때마다 요청이 반복됩니다.
Watch out
WARNING흔한 실수 1:
build메서드에서fetchAlbum()호출// 잘못된 코드Widget build(BuildContext context) {return FutureBuilder<Album>(future: fetchAlbum(), // 매번 새로 호출됨!builder: ...);}이러면 화면이 리빌드될 때마다 서버에 요청합니다. 반드시
initState에서 Future를 저장해두세요.흔한 실수 2: 상태 코드 무시
// 위험한 코드final response = await http.get(url);return Album.fromJson(jsonDecode(response.body)); // 에러여도 파싱 시도서버가 에러를 반환해도 파싱을 시도하면 앱이 죽습니다. 항상
statusCode를 확인하세요.
챕터 3: FutureBuilder로 화면 구성하기
Why
NOTEHTTP 요청은 비동기입니다. 결과가 올 때까지 화면에 뭘 보여줄지 정해야 합니다.
FutureBuilder는 Future의 상태에 따라 다른 위젯을 보여줍니다.
- 대기 중: 로딩 스피너
- 완료: 데이터 표시
- 에러: 에러 메시지
이게 Flutter의 선언적 UI3 패턴입니다.
What
NOTE
FutureBuilder는 세 가지 상태를 처리합니다.FutureBuilder<Album>(future: futureAlbum,builder: (context, snapshot) {// 1. 데이터가 있으면if (snapshot.hasData) {return Text(snapshot.data!.title);}// 2. 에러가 있으면if (snapshot.hasError) {return Text('에러: ${snapshot.error}');}// 3. 아직 기다리는 중이면return CircularProgressIndicator();},);snapshot의 주요 속성:
hasData: 데이터가 있는지hasError: 에러가 발생했는지data: 실제 데이터 (null일 수 있음)error: 에러 객체connectionState: 연결 상태
How
TIP더 세밀한 상태 관리가 필요하면
connectionState를 사용합니다.FutureBuilder<Album>(future: futureAlbum,builder: (context, snapshot) {switch (snapshot.connectionState) {case ConnectionState.none:return Text('요청 전');case ConnectionState.waiting:return Column(mainAxisAlignment: MainAxisAlignment.center,children: [CircularProgressIndicator(),SizedBox(height: 16),Text('데이터를 불러오는 중...'),],);case ConnectionState.done:if (snapshot.hasError) {return Column(mainAxisAlignment: MainAxisAlignment.center,children: [Icon(Icons.error, color: Colors.red, size: 48),SizedBox(height: 16),Text('오류가 발생했습니다'),TextButton(onPressed: () {setState(() {futureAlbum = fetchAlbum(); // 재시도});},child: Text('다시 시도'),),],);}return Text(snapshot.data!.title);default:return SizedBox.shrink();}},);에러 상태에서 “다시 시도” 버튼을 제공하면 사용자 경험이 좋아집니다.
Watch out
WARNINGsnapshot.data 사용 시 주의:
// 위험: data가 null일 수 있음Text(snapshot.data!.title);// 안전: null 체크 후 사용if (snapshot.hasData) {Text(snapshot.data!.title);}
hasData가 true여도 타입 시스템은 모릅니다.!연산자로 null이 아님을 알려주거나, null 체크를 하세요.initial data 활용: 캐시된 데이터가 있으면
initialData로 넘길 수 있습니다.FutureBuilder<Album>(future: futureAlbum,initialData: cachedAlbum, // 로딩 중에도 이전 데이터 표시builder: ...);
챕터 4: POST 요청으로 데이터 보내기
Why
NOTEPOST 요청은 서버에 데이터를 보내는 요청입니다. 새로운 리소스를 생성할 때 사용합니다.
- 회원가입 정보 전송
- 새 게시물 작성
- 주문 생성
GET과 달리 요청 본문(body)에 데이터를 담습니다.
What
NOTE
http.post()로 데이터를 서버에 보냅니다.Future<Album> createAlbum(String title) async {final response = await http.post(Uri.parse('https://jsonplaceholder.typicode.com/albums'),headers: <String, String>{'Content-Type': 'application/json; charset=UTF-8',},body: jsonEncode(<String, String>{'title': title,}),);if (response.statusCode == 201) { // 201: Createdreturn Album.fromJson(jsonDecode(response.body));} else {throw Exception('앨범 생성 실패');}}핵심 포인트:
headers: 콘텐츠 타입 지정 (JSON임을 알림)body: 보낼 데이터 (JSON 문자열로 변환)jsonEncode(): Map을 JSON 문자열로 변환- 상태 코드 201은 “생성 성공”을 의미
How
TIPPOST 요청을 UI와 연결하는 전체 예제입니다.
class CreateAlbumScreen extends StatefulWidget {@override_CreateAlbumScreenState createState() => _CreateAlbumScreenState();}class _CreateAlbumScreenState extends State<CreateAlbumScreen> {final TextEditingController _controller = TextEditingController();Future<Album>? _futureAlbum;@overrideWidget build(BuildContext context) {return Column(children: [TextField(controller: _controller,decoration: InputDecoration(labelText: '앨범 제목',),),ElevatedButton(onPressed: () {setState(() {_futureAlbum = createAlbum(_controller.text);});},child: Text('생성'),),// 결과 표시if (_futureAlbum != null)FutureBuilder<Album>(future: _futureAlbum,builder: (context, snapshot) {if (snapshot.hasData) {return Text('생성됨: ${snapshot.data!.title}');} else if (snapshot.hasError) {return Text('에러: ${snapshot.error}');}return CircularProgressIndicator();},),],);}}버튼을 누르면 POST 요청이 발생하고, 결과가 화면에 표시됩니다.
Watch out
WARNINGContent-Type 헤더 필수: 헤더 없이 보내면 서버가 JSON을 인식 못 합니다.
// 잘못된 코드await http.post(url, body: jsonEncode(data)); // 헤더 없음// 올바른 코드await http.post(url,headers: {'Content-Type': 'application/json; charset=UTF-8'},body: jsonEncode(data),);상태 코드 주의: POST 성공은 보통 201(Created)입니다. 서버마다 다를 수 있으니 API 문서를 확인하세요.
중복 요청 방지: 버튼을 빠르게 연타하면 요청이 여러 번 갑니다. 로딩 중에는 버튼을 비활성화하세요.
챕터 5: 데이터 모델 설계하기
Why
NOTE서버 응답(JSON)을 그대로 사용하면 문제가 많습니다.
- 타입 안전성이 없다 (
json['title']이 String인지 모름)- 오타를 컴파일러가 잡지 못한다
- 자동완성이 안 된다
Dart 클래스로 변환하면 이 문제가 해결됩니다. 이를 역직렬화(deserialization)라고 합니다.
What
NOTEJSON을 Dart 객체로 변환하는 클래스를 만듭니다.
class Album {final int userId;final int id;final String title;const Album({required this.userId,required this.id,required this.title,});// JSON → Dart 객체 (역직렬화)factory Album.fromJson(Map<String, dynamic> json) {return Album(userId: json['userId'] as int,id: json['id'] as int,title: json['title'] as String,);}// Dart 객체 → JSON (직렬화)Map<String, dynamic> toJson() {return {'userId': userId,'id': id,'title': title,};}}fromJson: 서버에서 받은 데이터를 객체로 변환 toJson: 객체를 서버에 보낼 JSON으로 변환
How
TIP목록 데이터를 파싱하는 방법입니다.
// 서버 응답: [{"id": 1, "title": "..."}, {"id": 2, "title": "..."}]Future<List<Album>> fetchAlbums() async {final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/albums'),);if (response.statusCode == 200) {// JSON 배열을 List<Album>으로 변환final List<dynamic> jsonList = jsonDecode(response.body);return jsonList.map((json) => Album.fromJson(json)).toList();} else {throw Exception('앨범 목록 로딩 실패');}}중첩된 JSON 처리:
// 서버 응답: {"user": {"name": "John", "email": "..."}}class Response {final User user;factory Response.fromJson(Map<String, dynamic> json) {return Response(user: User.fromJson(json['user']), // 중첩 객체 파싱);}}
Watch out
WARNINGnull 처리: 서버가 null을 보낼 수 있습니다.
// 위험: null이면 에러title: json['title'] as String,// 안전: 기본값 제공title: json['title'] as String? ?? '제목 없음',타입 캐스팅 주의: JSON 숫자가 int인지 double인지 확인하세요.
// 서버가 1.0을 보내면 실패id: json['id'] as int,// 안전: num으로 받아서 변환id: (json['id'] as num).toInt(),코드 생성 도구: 모델이 많아지면
json_serializable패키지를 고려하세요. 반복적인 fromJson/toJson 코드를 자동 생성해줍니다.
챕터 6: 에러 처리 패턴
Why
NOTE네트워크 요청은 실패할 수 있습니다.
- 인터넷 연결 끊김
- 서버 점검 중
- 타임아웃
- 잘못된 요청
이런 상황에 앱이 죽으면 안 됩니다. 사용자에게 적절한 피드백을 줘야 합니다.
What
NOTEtry-catch로 에러를 잡고 적절히 처리합니다.
Future<Album> fetchAlbum() async {try {final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),).timeout(Duration(seconds: 10)); // 타임아웃 설정if (response.statusCode == 200) {return Album.fromJson(jsonDecode(response.body));} else if (response.statusCode == 404) {throw NotFoundException('앨범을 찾을 수 없습니다');} else if (response.statusCode >= 500) {throw ServerException('서버에 문제가 발생했습니다');} else {throw Exception('알 수 없는 오류: ${response.statusCode}');}} on SocketException {throw NetworkException('인터넷 연결을 확인하세요');} on TimeoutException {throw NetworkException('서버 응답이 너무 느립니다');} on FormatException {throw DataException('데이터 형식이 올바르지 않습니다');}}커스텀 예외 클래스를 만들면 에러 종류를 구분할 수 있습니다.
How
TIP예외 클래스를 정의하고 UI에서 활용합니다.
// 커스텀 예외 정의class NetworkException implements Exception {final String message;NetworkException(this.message);}class ServerException implements Exception {final String message;ServerException(this.message);}// UI에서 에러 타입별 처리FutureBuilder<Album>(future: futureAlbum,builder: (context, snapshot) {if (snapshot.hasError) {final error = snapshot.error;if (error is NetworkException) {return Column(children: [Icon(Icons.wifi_off, size: 48),Text(error.message),ElevatedButton(onPressed: _retry,child: Text('다시 시도'),),],);}if (error is ServerException) {return Column(children: [Icon(Icons.cloud_off, size: 48),Text(error.message),],);}return Text('오류: $error');}// ...},);에러 타입에 따라 다른 UI를 보여주면 사용자가 상황을 이해하기 쉽습니다.
Watch out
WARNING모든 예외를 catch하지 마세요:
// 나쁜 패턴: 모든 에러 무시try {return await fetchData();} catch (e) {return null; // 문제가 뭔지 알 수 없음}예외를 삼키면 디버깅이 어려워집니다. 최소한 로그는 남기세요.
사용자 친화적 메시지: 기술적인 에러 메시지를 그대로 보여주지 마세요.
// 나쁨: "SocketException: Connection refused"// 좋음: "인터넷 연결을 확인하세요"
한계
이 튜토리얼에서 다룬 http 패키지는 기본적인 HTTP 통신에 적합합니다.
다음과 같은 고급 기능이 필요하면 다른 도구를 고려하세요.
http 패키지의 한계:
| 기능 | http | Dio |
|---|---|---|
| 기본 HTTP 요청 | O | O |
| 인터셉터 | X | O |
| 요청 취소 | X | O |
| 파일 업로드 진행률 | X | O |
| 자동 재시도 | X | O |
| 캐싱 | X | O |
대안 패키지:
- Dio: 인터셉터, 요청 취소, FormData 등 고급 기능
- Retrofit: 타입 안전한 API 클라이언트 (코드 생성)
- Chopper: Retrofit과 유사, Dart 친화적
다음 튜토리얼에서 실제 CRUD 작업을 구현하며 이 개념들을 활용합니다.
Footnotes
-
비동기(Asynchronous): 작업이 완료될 때까지 기다리지 않고 다음 코드를 실행하는 방식. 네트워크 요청은 시간이 걸리므로 비동기로 처리해야 앱이 멈추지 않습니다. ↩
-
JSON(JavaScript Object Notation): 데이터를 주고받는 표준 형식.
{"key": "value"}형태로, 사람이 읽기 쉽고 대부분의 언어에서 파싱할 수 있습니다. ↩ -
선언적 UI(Declarative UI): “어떻게 변경할지”가 아니라 “무엇을 보여줄지”를 선언하는 방식. 상태가 바뀌면 Flutter가 알아서 UI를 업데이트합니다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!