Flutter 튜토리얼 29편: HTTP 요청과 REST API

요약#

핵심 요지#

  • 문제 정의: 대부분의 앱은 서버에서 데이터를 가져오거나 보내야 한다. HTTP 통신 방법을 모르면 앱을 만들 수 없다.
  • 핵심 주장: http 패키지와 FutureBuilder를 조합하면 서버 통신을 선언적으로 처리할 수 있다.
  • 주요 근거: Flutter 공식 권장 패턴으로, 비동기 데이터를 UI에 자연스럽게 연결한다.
  • 실무 기준: GET, POST, PUT, DELETE 요청과 에러 처리, JSON 파싱을 모두 다룬다.
  • 한계: 인터셉터나 캐싱이 필요하면 Dio 같은 고급 패키지를 고려해야 한다.

문서가 설명하는 범위#

  • http 패키지 설치와 기본 사용법
  • GET 요청으로 데이터 가져오기
  • JSON을 Dart 객체로 변환하기
  • FutureBuilder로 비동기 데이터 표시하기
  • POST 요청으로 데이터 보내기
  • 에러 처리와 로딩 상태 관리

읽는 시간: 15분 | 난이도: 초급~중급


참고 자료#


문제 상황#

앱을 만들다 보면 서버에서 데이터를 가져와야 합니다. 사용자 정보, 상품 목록, 뉴스 피드… 대부분의 데이터는 서버에 있습니다.

서버 통신이 필요한 상황#

상황 1: 뉴스 앱에서 최신 기사 목록을 가져온다
상황 2: 쇼핑 앱에서 상품 정보를 가져온다
상황 3: SNS 앱에서 새 게시물을 서버에 저장한다
상황 4: 로그인 후 사용자 프로필을 가져온다

문제는 다음과 같습니다.

  • 네트워크 요청은 비동기1로 처리해야 한다.
  • 서버 응답은 보통 JSON 형식2이다.
  • 네트워크 오류나 서버 에러를 처리해야 한다.
  • 로딩 중에는 사용자에게 피드백을 줘야 한다.

이 모든 걸 직접 구현하면 복잡해집니다. 다행히 Flutter는 이를 쉽게 해결할 수 있는 도구를 제공합니다.


해결 방법#

http 패키지로 서버와 통신하고, FutureBuilder로 결과를 화면에 표시합니다. 데이터 흐름은 다음과 같습니다.

graph LR A[HTTP 요청] --> B[서버 응답] B --> C[JSON 파싱] C --> D[Dart 객체] D --> E[FutureBuilder] E --> F[화면 표시]

챕터 1: http 패키지 설정하기#

Why#

NOTE

Flutter에서 HTTP 요청을 보내려면 별도 패키지가 필요합니다. http 패키지는 Flutter 팀이 만든 공식 패키지입니다.

  • 간단하고 직관적인 API
  • GET, POST, PUT, DELETE 모두 지원
  • 대부분의 앱에 충분한 기능

복잡한 기능(인터셉터, 캐싱)이 필요하면 Dio를 고려하세요. 하지만 처음 배울 때는 http가 가장 좋습니다.

What#

NOTE

http 패키지를 설치하고 임포트합니다.

pubspec.yaml
dependencies:
http: ^1.2.0
import '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#

NOTE

GET 요청은 서버에서 데이터를 읽어오는 요청입니다. 가장 많이 사용하는 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;
@override
void initState() {
super.initState();
futureAlbum = fetchAlbum(); // 딱 한 번만 호출
}
@override
Widget 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#

NOTE

HTTP 요청은 비동기입니다. 결과가 올 때까지 화면에 뭘 보여줄지 정해야 합니다.

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#

WARNING

snapshot.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#

NOTE

POST 요청은 서버에 데이터를 보내는 요청입니다. 새로운 리소스를 생성할 때 사용합니다.

  • 회원가입 정보 전송
  • 새 게시물 작성
  • 주문 생성

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: Created
return Album.fromJson(jsonDecode(response.body));
} else {
throw Exception('앨범 생성 실패');
}
}

핵심 포인트:

  • headers: 콘텐츠 타입 지정 (JSON임을 알림)
  • body: 보낼 데이터 (JSON 문자열로 변환)
  • jsonEncode(): Map을 JSON 문자열로 변환
  • 상태 코드 201은 “생성 성공”을 의미

How#

TIP

POST 요청을 UI와 연결하는 전체 예제입니다.

class CreateAlbumScreen extends StatefulWidget {
@override
_CreateAlbumScreenState createState() => _CreateAlbumScreenState();
}
class _CreateAlbumScreenState extends State<CreateAlbumScreen> {
final TextEditingController _controller = TextEditingController();
Future<Album>? _futureAlbum;
@override
Widget 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#

WARNING

Content-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#

NOTE

JSON을 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#

WARNING

null 처리: 서버가 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#

NOTE

try-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 패키지의 한계:

기능httpDio
기본 HTTP 요청OO
인터셉터XO
요청 취소XO
파일 업로드 진행률XO
자동 재시도XO
캐싱XO

대안 패키지:

  • Dio: 인터셉터, 요청 취소, FormData 등 고급 기능
  • Retrofit: 타입 안전한 API 클라이언트 (코드 생성)
  • Chopper: Retrofit과 유사, Dart 친화적

다음 튜토리얼에서 실제 CRUD 작업을 구현하며 이 개념들을 활용합니다.


Footnotes#

  1. 비동기(Asynchronous): 작업이 완료될 때까지 기다리지 않고 다음 코드를 실행하는 방식. 네트워크 요청은 시간이 걸리므로 비동기로 처리해야 앱이 멈추지 않습니다.

  2. JSON(JavaScript Object Notation): 데이터를 주고받는 표준 형식. {"key": "value"} 형태로, 사람이 읽기 쉽고 대부분의 언어에서 파싱할 수 있습니다.

  3. 선언적 UI(Declarative UI): “어떻게 변경할지”가 아니라 “무엇을 보여줄지”를 선언하는 방식. 상태가 바뀌면 Flutter가 알아서 UI를 업데이트합니다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 29편: HTTP 요청과 REST API
https://moodturnpost.net/posts/flutter/flutter-http-rest-api/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차