28. 플러터 : 서버/클라이언트 연동 (3)

이전 예제에서는 클라이언트에 해당하는 앱은 플러터로 구현하고
서버는 만들어져 있는 것을 가져다 사용했다.
이제 우리만의 서버를 직접 만들어서 연동하도록 하자.

이번 예제는 직접 만든 서버로부터 다양한 형태의 데이터를 수신한다.
데이터의 종류에는 문자열, 사진, 배열이 있고
스마트폰 앱에서 해당 버튼을 누르면 연결된 데이터만 보여주는 방식이다.

서버는 파이썬의 플라스크(flask)로 구현했다.
아쉽지만 여기는 파이썬하고는 상관 없기 때문에 이 부분은 간단하게 넘어가기로 한다.
그래도 우재는 할 수 있잖아?
아빠한테 다 배웠으니까..

로컬에서 서버를 구동했더니 로컬 호스트(127.0.0.1)를 안드로이드 에뮬레이터에서 인식을 못한다.
할 수 없이 파이썬애니웨어(pythonanywhere.com)의 도움을 받는다.
가입하면 무료로 웹 서버를 하나 구동할 수 있다. 지금처럼.
(현재 글을 작성할 때는 로컬 호스트에 대해서만 검증했고 로컬에서 동작하지 않았다.
수업을 준비하는 과정에서 다시 살펴봤고 사설 IP 주소로 동작하는 것을 확인했다.
그러나, 파이썬 애니웨어에 대해 알아두는 것도 나쁘지 않기 때문에 관련 내용을 유지한다.
사설 IP 주소를 사용하는 코드는 다음 번 글에서 확인할 수 있다.)


from flask import Flask
import json

app = Flask(__name__)


# 루트 인터페이스. 서버 주소(127.0.0.1:5000)의 루트 폴더.
@app.route('/')
def index():
return 'home. nothing.'


# 루트 아래의 string 폴더에 접근할 때 보여줄 내용
@app.route('/string')
def send_string():
return '취미 : 서핑, 스노보드, 배드민턴'


# 사진은 앱에서 파일에 직접 접근하기 때문에 이름만 알려주면 된다.
@app.route('/image')
def send_image():
return 'book.jpg'


# 배열이나 사전 등의 프로그래밍 언어 객체는 문자열로 변환해서 전달해야 하고
# json 또는 xml 등을 사용하게 된다. 여기서는 json 사용.
@app.route('/array')
def send_array():
items = [
{'id': 12, 'content': '세상은 호락호락하지 않다. 괜찮다. 나도 호락호락하지 않다.'},
{'id': 45, 'content': '공부를 많이 하면 공부가 늘고 걱정을 많이 하면 걱정이 는다.'},
{'id': 78, 'content': '참아야 한다고 배워 힘든 걸 참고 행복도 참게 되었다.'},
]
return json.dumps(items)


# 파이썬애니웨어(www.pythonanywhere.com)에서 구동할 때는
# 아래 코드를 사용해선 안 된다. 로컬에서 구동할 때만 사용한다.
# if __name__ == '__main__':
# app.run(debug=True)


파이썬애니웨어의 사용법은 해당 사이트에 가서 확인하자.

어려울 것이 전혀 없다.
프로젝트를 구성한 후에 static 폴더를 만들어서 'book.jpg' 파일을 하나 넣어둬야 아래 코드를 구동할 수 있다.

파이썬애니웨어 구성은 아래와 같은데.. 볼 건 없다.
왼쪽에 static과 templates 폴더가 있는데..
이번 예제에서는 templates 폴더는 사용하지 않는다.



이번에 만들 앱의 스크린샷이다.
화면이 4가지라서 캡쳐해서 깨끗하게 잘라내는 것도 힘들다.
첫 번째 사진은 서버로부터 아무 것도 가져오지 않았을 때를 가리킨다.



코드가 너무 길다.
150줄 정도 나왔는데.. 너무 많은 걸 보여주려고 한 것일까?

상수를 정의하기 위해 enum 클래스를 사용헀다. 
enum을 사용하지 않으면 당장은 편하지만 향후 불편할 수밖에 없다.
그리고 다트 문법도 익혀야 하는 관계로, 공부 차원에서 사용했다.

역시나 우재는 아래 코드 없이 화면만 보고 만들어 볼 수 있겠지?
파이썬애니웨어는 우재도 사용하고 있고.. 아빠보다 더 잘 하니까.
시간은 걸리겠지만..
고생하고 안 될 때만 살짝 보고. 아빠도 이번 코드는 진짜 오래 걸렸어.
아빠보다 빨리 하는 건 아니겠지?

참, 스냅샷의 connectionState 속성 사용하는 거 중요하니까.. 자세히 보고.
서버 구성은 클라이언트와 상관 없기 때문에 클라이언트 부분의 코드는 똑같을 수밖에 없다.
다만 서버쪽에서 어떤 방식으로 데이터를 전달하는지만 명확하게 하면 끝.
여기서는 사진을 제외하고는 모두 문자열로 처리.
수신 이후에 원하는 형태로 변환해서 사용하는 방식을 사용했다.


import 'dart:async';
import 'dart:convert'; // json 관련

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;


void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

// 수신 데이터의 종류를 상수로 설정
enum DataKind { NONE, STRING, IMAGE, ARRAY }

// 3가지 형태의 문자열 수신 : 단순 문자열, json 문자열, 이미지 주소 문자열.
Future<String> fetchString(DataKind kind) async {
if(kind == DataKind.NONE)
return '';

print(kind.toString());
print(kind.toString().split('.')[1]);
// 첫 번째 항목은 NONE. 비워두긴 했지만 루트(/)를 가리킨다는 뜻은 아니고, 사용 안함을 의미한다.
final details = ['', 'string', 'image', 'array'];
final urlPath = 'http://applekoong.pythonanywhere.com/' + details[kind.index];

// 아래처럼 구하면 details 배열과 동기화시키지 않아도 된다.
// 코드는 좀 복잡해지지만 업그레이드에서 유리할 수 있다.
// DataKind.IMAGE 상수를 toString 함수에 전달하면 'DataKind.IMAGE'가 나온다.
// final detail = kind.toString().split('.')[1].toLowerCase()
// final urlPath = 'http://applekoong.pythonanywhere.com/' + detail;

final response = await http.get(urlPath);

if (response.statusCode == 200)
return response.body;

throw Exception('데이터 수신 실패!');
}

class MyApp extends StatefulWidget {
@override
State createState() {
return MyAppState();
}
}

class MyAppState extends State<MyApp> {
DataKind mKind = DataKind.NONE;

Widget makeChild(String str) {
switch (mKind) {
case DataKind.NONE:
return Placeholder(); // 아무 것도 수신하지 않았을 때 사용
case DataKind.STRING:
return Text(str, style: TextStyle(fontSize: 25),);
case DataKind.IMAGE:
return Image.network('http://applekoong.pythonanywhere.com/static/' + str);
default:
// str : 사전을 담고있는 배열 형태의 json 문자열.
final items = json.decode(str);
final textStyle = TextStyle(fontSize: 21, fontWeight: FontWeight.bold);

List<Container> widgets = [];
for(var item in items) {
widgets.add(
// 여백을 통해 위젯을 보기좋게 배치하기 위해 Container 사용
Container(
child: Column(
children: <Widget>[
Text(item['id'].toString(), style: textStyle),
Text(item['content'], style: textStyle, softWrap: true),
],
),
padding: EdgeInsets.all(20.0),
),
);
}
return Column(children: widgets);
}
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
FutureBuilder(
future: fetchString(mKind),
builder: (context, snapshot) {
// 이전 예제에서는 hasData를 사용했는데, 이전 호출의 결과가 남아있을 수 있다.
// builder에 전달되는 함수는 무조건 2회 호출된다. 처음에는 waiting으로, 두 번째에는 done으로.
// done이라면 완료되었다는 뜻이고 에러가 발생했을 수도 있다.
if (snapshot.connectionState == ConnectionState.done) {
// 나머지 공간 전체를 사용하는데, 전체 공간을 사용하지 않는 위젯이라면 가운데에 배치.
return Expanded(
child: Center(
child: makeChild(snapshot.data),
),
);
}
else if (snapshot.hasError) {
// 에러 메시지는 굳이 화면 가운데에 놓을 필요가 없다.
return Text('${snapshot.error}');
}

// 인디케이터가 다른 위젯들처럼 화면 가운데에 위치시킨다.
return Expanded(
child: Center(
child: CircularProgressIndicator(),
),
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
RaisedButton(
child: Text('수신 안함'),
onPressed: () {
setState(() {
mKind = DataKind.NONE;
});
},
),
RaisedButton(
child: Text('문자열'),
// 정직하게 함수를 두 번 호출하는 코드
onPressed: () {
setState(() {
mKind = DataKind.STRING;
});
},
),
RaisedButton(
child: Text('사진'),
// 한 번은 정직하게, 한 번은 => 문법 사용
onPressed: () {
setState(() => mKind = DataKind.IMAGE);
},
),
RaisedButton(
child: Text('배열'),
// 두 번 모두 => 문법 사용. 간단하지만 가독성에서는 어렵다.
onPressed: () => setState(() => mKind = DataKind.ARRAY),
),
],
),
],
);
}
}