이 튜토리얼 시리즈의 두 번째 파트에 오신 것을 환영합니다. 1편에서는 Upstash, Serverless Framework, Redis를 활용해 REST API를 구축하는 방법을 살펴보았습니다.
이번 편에서는 Flutter를 사용해 모바일 애플리케이션을 만들고, 앞서 구축한 REST API 엔드포인트를 실제로 호출해 보겠습니다.
그럼 시작해 볼까요? 🙃
사전 준비 사항
먼저 컴퓨터에 Flutter가 설치되어 있고 정상적으로 실행되고 있어야 합니다.
- Flutter
IDE에서 새로운 Flutter 프로젝트를 생성하고 원하는 이름을 지정하세요.
의존성 추가하기
Flutter 프로젝트 루트 디렉터리에 있는 pubspec.yaml 파일을 열고 dev_dependencies 아래에 다음 의존성들을 추가합니다.
timeago: ^3.1.0
shared_preferences: ^2.0.6
http: ^0.13.4
완성된 모습은 다음과 같습니다.
dev_dependencies:
flutter_test:
sdk: flutter
timeago: ^3.1.0
shared_preferences: ^2.0.6
http: ^0.13.4
각 라이브러리의 역할은 다음과 같습니다.
- timeago: Unix 타임스탬프(예: 1636824843)를 '1분 전', '5분 전'처럼 사람이 읽기 쉬운 형태로 변환해 줍니다.
- shared_preferences: 사용자 계정을 생성한 후 userId와 기타 세부 정보를 기기에 저장하여 추적할 때 사용합니다.
- http: HTTP 요청을 보낼 때 사용합니다.
자, 이제 본격적으로 시작해 보겠습니다.
사용자 생성 (Create User)
가장 먼저 만들 화면은 '사용자 생성 화면'으로, 사용자 생성 엔드포인트를 호출합니다.
완성된 화면은 아래와 같습니다.

토끼 사진은 신경 쓰지 마세요. 그냥 이미지 뷰의 플레이스홀더일 뿐입니다.
lib 폴더 안에 account라는 폴더를 만들고, 그 안에 create_profile_screen.dart라는 새 파일을 생성합니다.
최종적인 lib 폴더 구조는 다음과 같습니다.
새 사용자를 생성하려면 다음 정보가 필요합니다.
- 프로필 사진 URL
- 이름(first name)
- 성(last name)
- 사용자명(username)
- 엔드포인트
코드를 살펴보겠습니다.
static const String CREATE_USER_PROFILE_URL = "https://5vafvrk8kj.execute-api.us-east-1.amazonaws.com/dev/user";
bool _loading = false;
Future<void> createUserProfile() async {
setState(() {
_loading = true;
});
print(usernameController.text);
print(firstNameController.text);
print(lastNameController.text);
print(profilePicUrl);
await http.post(Uri.parse(CREATE_USER_PROFILE_URL),
body: convert.jsonEncode({'username': usernameController.text,
"firstName": firstNameController.text, "lastName": lastNameController.text,
"profilePic": profilePicUrl})).then((response) async {
var jsonResponse =
convert.jsonDecode(response.body) as Map<String, dynamic>;
setState(() {
_loading = false;
});
if(response.statusCode == 400){
ScaffoldMessenger.of(context).showSnackBar(SnackBar(padding:EdgeInsets.all(10),backgroundColor: Colors.red,content: Text(jsonResponse['message'])));
}else if(response.statusCode == 200) {
print('user id is :' +jsonResponse['userId']);
await saveUserId(jsonResponse['userId']);
Navigator.push(context, MaterialPageRoute(builder: (context){
return HomeScreen();
}));
}
});
}
Future는 Dart에서 비동기 작업을 처리하는 핵심 클래스입니다. Future 객체는 미래의 어느 시점에 사용 가능하게 될 값 또는 오류를 나타냅니다.
http.Response 클래스에는 성공적인 HTTP 호출로부터 받은 데이터가 담겨 있습니다.
위 코드는 http post 메서드를 사용해 '사용자 생성 엔드포인트'에 POST 요청을 보내고 응답을 기다립니다.
응답 상태 코드가 200이면 요청이 성공한 것이므로, 생성된 UserId를 shared preferences에 저장한 후 홈 화면으로 이동합니다.
이 화면의 전체 소스 코드는 여기서 확인할 수 있습니다: Create Profile Screen.
게시물 작성 (Create a Post)
우리가 만든 엔드포인트 중 하나는 사용자가 게시물을 작성할 수 있게 해 줍니다. 완성된 화면은 다음과 같습니다.

게시물을 작성하려면 사용자에게 다음 요소들이 필요합니다.
- userId
- 텍스트(text)
- imageUrl
여기서는 데모 목적상 미리 준비된 imageUrl을 사용한다는 점을 기억하세요. 실제 앱에서는 사용자가 직접 이미지를 선택하고, 서버에 업로드한 후, 이미지 URL을 받아와서 게시물을 생성하도록 구현해야 합니다.
CreatePost 메서드는 CreateUser 메서드와 구조가 매우 유사합니다.
Future<void> createPost(String userId) async {
await http
.post(Uri.parse(CREATE_USER_POST_URL),
body: convert.jsonEncode({
'userId': userId,
"postText": postTextController.text,
"postImage": _postPicUrl[i]
}))
.then((response) async {
var jsonResponse =
convert.jsonDecode(response.body) as Map<String, dynamic>;
setState(() {
_loading = false;
});
if (response.statusCode == 400) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
padding: EdgeInsets.all(10),
backgroundColor: Colors.red,
content: Text(jsonResponse['message'])));
} else if (response.statusCode == 200) {
print('post id is :' + jsonResponse['id']);
Navigator.of(context).pop();
}
});
}
전체 게시물 목록 조회 (List All Posts)
앱의 홈 화면에는 생성된 모든 게시물 목록이 표시됩니다.
대략 다음과 같은 형태입니다.

모든 게시물을 문제없이 불러오려면 먼저 개별 게시물을 나타내는 커스텀 Dart 객체를 만들어야 합니다.
class Post {
String? postText;
String? userId;
String? createdOn;
String? id;
String? postImage;
PostAdmin? postAdmin;
Post(
{this.postText,
this.userId,
this.createdOn,
this.id,
this.postImage,
this.postAdmin});
Post.fromJson(Map<String, dynamic> json) {
postText = json['postText'];
userId = json['userId'];
createdOn = json['createdOn'];
id = json['id'];
postImage = json['postImage'];
postAdmin = json['postAdmin'] != null
? PostAdmin.fromJson(json['postAdmin'])
: null;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['postText'] = this.postText;
data['userId'] = this.userId;
data['createdOn'] = this.createdOn;
data['id'] = this.id;
data['postImage'] = this.postImage;
if (this.postAdmin != null) {
data['postAdmin'] = this.postAdmin!.toJson();
}
return data;
}
}
class PostAdmin {
String? timestamp;
String? userId;
String? username;
String? firstName;
String? lastName;
String? profilePic;
PostAdmin(
{this.timestamp,
this.userId,
this.username,
this.firstName,
this.lastName,
this.profilePic});
PostAdmin.fromJson(Map<String, dynamic> json) {
timestamp = json['timestamp'];
userId = json['userId'];
username = json['username'];
firstName = json['firstName'];
lastName = json['lastName'];
profilePic = json['profilePic'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['timestamp'] = this.timestamp;
data['userId'] = this.userId;
data['username'] = this.username;
data['firstName'] = this.firstName;
data['lastName'] = this.lastName;
data['profilePic'] = this.profilePic;
return data;
}
}
그다음, http.Response를 이 커스텀 Dart 객체로 변환합니다.
List<Post> parsePosts(String responseBody) {
final parsed = jsonDecode(responseBody).cast<Map<String, dynamic>>();
return parsed.map<Post>((json) => Post.fromJson(json)).toList();
}
Future<List<Post>> fetchPosts(http.Client client) async {
final response = await client
.get(Uri.parse(GET_POSTS));
return compute(parsePosts,response.body);
}
fetchPosts 메서드의 반환 타입은 Future<List<Post>>입니다.
만약 성능이 낮은 기기에서 fetchPosts() 함수를 실행하면, JSON을 파싱하고 변환하는 동안 앱이 잠깐 멈추는 현상을 경험할 수 있습니다. 이것이 바로 제이크(jank)이며, 우리는 이를 제거해야 합니다.
제이크는 compute 함수를 사용해 파싱과 변환 작업을 백그라운드로 옮기면 해결할 수 있습니다.
compute(parsePosts, response.body);
compute() 함수는 비용이 큰 연산을 백그라운드 isolate에서 실행하고 그 결과를 반환합니다.
FutureBuilder로 비동기 데이터 렌더링하기
홈 화면 파일에서는 FutureBuilder 위젯을 사용해 데이터베이스의 모든 게시물을 리스트 형태로 비동기적으로 가져옵니다.
FutureBuilder에는 두 가지 매개변수를 제공해야 합니다.
- future: 사용하려는 Future 객체. 여기서는 fetchPosts() 함수가 반환하는 future입니다.
builder: Future의 상태(로딩 중, 성공, 오류)에 따라 무엇을 렌더링할지 Flutter에 알려주는 빌더 함수입니다.
참고로 snapshot.hasData는 스냅샷에 null이 아닌 데이터 값이 포함되어 있을 때만 true를 반환합니다.
fetchPosts는 null이 아닌 값만 반환하도록 설계했으므로, '404 Not Found' 같은 서버 응답이 오더라도 예외(exception)를 던져야 합니다. 예외가 발생하면 snapshot.hasError가 true로 설정되어, 이를 통해 오류 메시지를 표시할 수 있습니다.
그 외의 경우에는 로딩 스피너가 표시됩니다.
Expanded(child: FutureBuilder<List<Post>>(
future: _posts,
builder: (context, snapshot) {
if (snapshot.hasData) {
List<Post>? posts = snapshot.data;
if(posts != null){
return ListView.builder(itemBuilder: (context,index){
return Card(
child: Container(
padding: EdgeInsets.all(10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(1000),
child: Image.network(
posts[index].postAdmin!.profilePic!,
fit: BoxFit.cover,
height: 40,
width: 40,
),
),
Expanded(
child: Container(
padding: EdgeInsets.only(left: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(posts[index].postAdmin!.username!,style: TextStyle(fontWeight: FontWeight.bold,fontSize: 16),),
Text(posts[index].postText!),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
posts[index].postImage!,
fit: BoxFit.cover,
height: 150,
width: size.width,
),
),
],
),
),
)
],
),
),
);
},itemCount: posts.length,);
}
} else if (snapshot.hasError) {
return Text("${snapshot.error}");
}
// By default, show a loading spinner.
return Container(
height: 40,
width: 40,
child: Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).colorScheme.secondary))));
},
))
initState 메서드에서 fetchPosts를 호출합니다.
late Future<List<Post>> _posts;
@override
void initState() {
// TODO: implement initState
super.initState();
_posts = fetchPosts(http.Client());
}
build 메서드가 아니라 initState에서 fetchPosts를 호출하는 이유는, Flutter가 뷰의 어떤 부분이라도 변경해야 할 때마다 build() 메서드를 호출하기 때문입니다. 생각보다 훨씬 자주 호출됩니다. fetch 호출을 build() 메서드 안에 두면 불필요한 API 호출이 계속 발생해 앱 속도가 느려집니다.
전체 소스 코드를 자유롭게 살펴보세요.
아직 인터페이스를 만들어야 할 엔드포인트가 몇 개 더 남아 있지만, 좋은 튜토리얼에는 연습 과제가 있어야 하지 않겠습니까? 😂
마무리
이 시리즈에서는 Upstash로 서버리스 REST API를 구축하고, 이를 모바일 애플리케이션에서 호출하는 방법을 살펴보았습니다.
여러분이 Upstash로 무엇을 만들어 내는지, 또는 이 튜토리얼을 자신의 용도에 맞게 어떻게 확장하는지 궁금합니다.
이 글이 도움이 되었다면 SNS에 공유해 주세요.
궁금한 점이 있나요? 댓글로 남겨 주세요.
오류를 발견하셨다면 말씀해 주세요. 댓글을 남겨 주시면 최대한 빠르게 확인하겠습니다.
즐거운 코딩 되세요 ✌🏿
참고 자료
- Upstash Docs
- Redis
- Flutter
- Fetching data from the internet (인터넷에서 데이터 가져오기)