정보

    • 업무명     : [Flutter] 쉽게 배우는 플러터 앱 개발 실무 07강
    • 작성자     : 이상호
    • 작성일     : 2025.02.01
    • 설   명      :
    • 수정이력 :

     

     공유 파일

    • 작업 파일
     

    20250201_first_flutter.zip

     

    drive.google.com

     

     

    20250201_flutter_server.zip

     

    drive.google.com

     

     

     

    ngrok.exe

     

    drive.google.com

     

     서버/DBMS 연동 (Spring Boot)

    서버연동(http통신)

    학습목표

    • 인터넷 통신의 표준으로 자리잡은 http와 https를 이해합니다.
    • Request-Response 통신 구조를 활용하여 서버와 클라이언트간 통신하는 방법을 알아 봅니다.
      서버 사이드인 백 앤드는 Spring Boot로, 클라이언트 사이드인 프론트 앤드는 Flutter로 상호 연동하여 개발해 봅니다.
    • 로그인 화면을 구현하며 컨트롤러를 활용한 사용자 대화형 앱을 만들어 봅니다.
    • Back End 기능으로 Flutter Server에 데이터베이스 서비스 기능을 개발하는 방법을 이해합니다.
    • Front End 기능으로 Flutter App에 Back End 서비스와 연동된 화면을 개발하는 방법을 이해합니다.

     

    Instructor Note

    • 서버 연동부터는 프로그램의 복잡도가 급격히 상승하여 코딩을 하면서 설명하기 어려움. → 노션을 보며 설명한 후 소스 코드를 복사해 넣은 후 코드를 보며 설명할 것.

     

    http란? / https란?

    • http는 Hyper Text Transfer Protocol의 약자로 전송 규약(Transfer Protocol,통신규약)입니다.
    • 이게 무슨 뜻일까요? 다시 점진적으로 생각을 확장하며 이해해 봅시다.
    • Text는 프로그래머의 입장에서는 문자열(String)입니다. 아래 화면과 같이 Text는 메모장 프로그램으로 잘 열립니다.

     

    • Hyper Text는 아래 화면과 같이 인터넷에 가서 볼 수 있는 사이트에 클릭하면 이동하게 만들어져 있는 Text들이 Hyper Text인데 이미지를 클릭해도 어딘가로 이동합니다. 따라서 Hyper는 어디론가 이동해 간다라는 의미를 가졌다고 보면 됩니다. Hyper Text의 위상이 크게 점프(Hyper Jump)한 이유가 여기에 있는데 인간의 두뇌의 생각의 흐름이 Text와 같이 순차적이지 않고 Hyper Text와 같이 어디론가 Hyper Jump해 가기 때문입니다. Hyper Text는 인간의 생각을 표현하기에 적합한 Text입니다.

     

    • Hyper Text는 주로 HTML(Hyper Text Markup Language)로 만들어 집니다. 언어는 언어인데 로직을 구현해 주는 일반 프로그래밍 언어와는 다르게 무언가를 표시(Markup)해 주기 때문에 붙은 이름입니다. 그래서 XML도 Markup 언어이고 Markdown도 Markup 언어입니다. HTML은 아래 화면과 같이 생겼었지요. Hyper는 주로 a 태그로 표현되는데 매우 중요한 개념에 매우 간단한 문법이네요.

     

    • 그래서 이렇게 중요한 Hyper Text를 보내기(Transfer) 위한 방법(Protocol)을 개발하여 사용하기 시작해서 지금은 보내고 받는 기능이 모두 포함된 인터넷 통신의 표준으로 자리매김하였습니다.
    • https는 이렇게 중요한 Hyper Text가 암호화되지 않은 일반 Text로 오고 가는 것에 대한 보안에 위협을 느껴 암호화된 Text로 주고 받도록 진화시킨 후 http의 뒤에 s(Security) 붙여서 http와 구분합니다.
    • 매우 다행인 것은 http와 https의 사용이 매우 쉽다는 것입니다. http://www.naver.com 혹은 https://www.naver.com 같이 브라우저에 입력하면 Hyper Text를 보내 주니까요. 하지만 보내준 Hyper Text를 처리하는 것에는 적지 않은 노력이 들어 갑니다. 그래서 크롤링을 배울 때 매우 어려웠지요? 이런 사유로 HTML과 같은 형식 외에도 XML이나 json과 같은 형식을 고안해 내게 되었습니다.
    • 또 매우 다행인 것은 위의 http와 https에 대한 설명이 마음에 와 닿지 않더라도, 프로그래머 관점에서 http와 https는 지금까지 배워서 사용했던 라이브러리나 패키지와 다르지 않다는 것입니다.

     

    request → response

    pubspec.yaml 설정

    • http 패키지를 가져다 사용해 봅시다. http 패키지는 Flutter에서 기본으로 제공하지 않기 때문에 pubspec.yaml 파일에서 설정하는 작업을 해 주어야 합니다.
    • pubspec.yaml 파일에서 아래 화면과 같은 위치를 찾아 Flutter가 기본적으로 사용할 패키지로 지정해 놓은 (이것이 Dependency의 의미입니다. 지정된 패키지에 종속되어 현재 프로젝트를 코딩하게 되니까요.)
    • 아래 화면과 같은 위치에 http: ^1.1.0을 입력합니다.

     

    • 1.x.x 버전 중에서 가능한 최신 버전을 사용한다는 의미로, 주버전이 1인 한, 마이너 버전과 패치 버전에서 업데이트가 가능함을 나타냅니다.

     

    • pubspec.yaml 파일을 수정한 다음에는 Pub get 메뉴를 눌러 Process finished with exit code 0 메세지나 나오는 것을 확인하여야 합니다. 너무나 중요하여 반복하여 설명합니다. 앞단에서 뭔가가 흐트러지면 뒤에서는 이해하기 어려운 오류들을 겪게 됩니다. 프로그래머들은 앞단계에서의 로그를 늘 주의 깊게 관찰하고 가능하다면 늘 반환값이 0인지를 늘 확인하여야 합니다.

     

    request → response

    • 기본적인 http 통신은 아래 화면과 같이 클라이언트에서 요청(request)을 보내면 서버에서 응답(response)를 보내는 방식으로 동작합니다. 브라우저는 Text로 받은 HTML, CSS 및 JavaScript 등을 브라우저의 화면에 해석하여 보여 줍니다. 이런 작업을 Rendering(표현,연출,연주)이라고 합니다. 용어에는 크게 신경쓰지 맙시다.

     

    • 그러면 http 패키지를 이용하여 “https://www.naver.com”에서 아래 화면과 같은 데이터를 받아 와 봅시다. 브라우저가 Rendering하는 단계가 빠지기 때문에 Text 형태의 HTML, CSS 및 JavaScript가 넘어올 것입니다.

     

    • 먼저 http_listview_page.dart 파일을 만들고 아래 코드와 같이 입력합시다.
    // http_listview_page.dart 파일 코드
    import 'package:flutter/material.dart';
    
    // stful 코드조각으로 추가된 HttpListviewPage 클래스 코드
    class HttpListviewPage extends StatefulWidget {
      const HttpListviewPage({super.key});
    
      @override
      State<HttpListviewPage> createState() => _HttpListviewPageState();
    }
    
    class _HttpListviewPageState extends State<HttpListviewPage> {
      String textHTML = "서버로 부터 HTML을 가져오기 전";
    
      @override
      Widget build(BuildContext context) {
        return Center(
          child: Text(
            textHTML,
            style: TextStyle(fontSize: 30),
          ),
        );
      }
    }

     

    • 화면 Widget이 하나 추가되었으니 main.dart 파일에도 아래와 같은 코드가 추가되어야 합니다.
    // 생성된 HttpListViewPage 클래스를 보여주도록 수정한 main.dart 파일 코드
    ... 생략 ...
    class _MyHomePageState extends State<MyHomePage> {
      var pages = [ HttpListviewPage(),      // 첫번째 페이지로 추가 
                    ImageListViewPage(),
                    SimpleListViewPage(),
                    MyThirdPage(),
                  ];
    ... 중략 ...
            items: [
              BottomNavigationBarItem(           // 첫번째 네베게이션 항목으로 추가
                icon: Icon(Icons.person),
                label: "User List View",
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.photo_library),
                label: "Image List View",
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.list),
                label: "Simple List View",
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.looks_3),
                label: "3rd Page",
              ),
            ],
          ),
        );
      }
    }

     

    • 그리고 실행해 보면 오른쪽 화면과 같이 나타납니다. 이제 http 패키지를 사용해 네이버 웹사이트에서 HTML을 불러와 봅시다. Stateful Widget을 사용하면 화면이 아니라 상태 변수들이 변하기 때문에 Hot Reload를 눌러도 반영되지 않는 경우가 많습니다. 그럴 때에는 재시작 버튼을 누르거나 프로그램의 실행을 멈추고 다시 시작하여야 합니다.

     

    • 아래 코드에 대한 자세한 설명은 나중에 합니다. 일단은 객체 생성시 초기화를 위하여 단한번 수행되는 @override void initState() 메서드에서 requestResponseServer(); 메소드를 호출하고 그 메소드 안에 var response = await http.get(Uri.parse("https://www.naver.com")); 문장이 있어서 Naver 사이트에 Request하여 받아 온 Response를 변수에 저장하는 로직의 흐름만 이해합시다.
    // http 패키지로 Naver에서 HTML을 가져오는 기능을 추가한 http_listview_page.dart 파일 코드
    import 'package:flutter/material.dart';
    
    import 'package:http/http.dart' as http; // http 패키지를 import하여 http라는 별명 부여
                                             // 패키지의 메소드를 http.get()과 같이 호출하게됨
    
    class HttpListviewPage extends StatefulWidget {
      const HttpListviewPage({super.key});
    
      @override
      State<HttpListviewPage> createState() => _HttpListviewPageState();
    }
    
    class _HttpListviewPageState extends State<HttpListviewPage> {
      String textHTML = "서버로 부터 HTML을 가져오기 전";
    
      requestResponseServer() async {   // Server에 Request를 보내 Response를 받는 함수 추가
        // http.get()으로 Request하여 Response를 reponse 변수에 저장
        var response = await http.get(Uri.parse("https://www.naver.com"));
        // 여기에 Return Code를 확인하는 로직이 들어가야 하나 핵심만 설명하기도 벅차 생략합니다.
        // 실제 업무 환경에서는 반드시 정상 수행되었는지 Return Code 확인을 하여야 합니다.
        setState(() {              // Widget에 상태의 변화를 통보하여 build() 메소드 다시 실행
          textHTML = response.body;// Reponse로 받아온 HTML 문장을 textHTML에 저장
        });
      }
    
      @override
      void initState() {              // 객체가 생성될 때 딱 한번만 수행되는 메소드
        // TODO: implement initState  // @override할 때 자동으로 추가된 코드로
        super.initState();            // 이 문장은 부모 클래스의 초기화를 먼저 수행한 후
        // Child인 자신의 초기화를 진행하라는 의미임
        requestResponseServer();       // 최초 함수 호출을 위하여 추가된 코드
      }
    
      @override
      Widget build(BuildContext context) {
        return Center(
          child: Text(
            textHTML,
            style: TextStyle(fontSize: 30),
          ),
        );
      }
    }

     

    • 위의 코드에서는 교육 목적상 코드를 단순하게 만들기 위하여 var response = await http.get(Uri.parse("https://www.naver.com")); 문장 뒤에 Return Code와 Return Message를 처리하는 것을 생략하고 있습니다. 그러나 실전 환경에서는 이 두가지를 매우 주의깊게 코드로 처리하여야 합니다. 앞으로 DB 연결 등의 처리에서도 Return Code와 Return Message의 처리를 가능한 단순하게 처리할텐데 예외처리(Exception Handling)와 함께 전문 프로그래머가 반드시 지켜야할 기본이자 미덕이라는 것을 알고 있어야 하겠습니다.
    • 이제 실행을 해 보는데 이번에는 매우 주의깊게 화면이 변하는 것을 관찰하여야 합니다. 실행을 하면 왼쪽 화면이 먼저 나타나고 조금 있다가 오른쪽 화면이 나타납니다. 왜 그럴까요?

     

     

    비동기 처리

    • 일반적으로 프로그램의 코드는 동기식으로 진행됩니다. 앞의 문장이 끝나야 뒤의 문장이 시작되는 것입니다. 앞의 문장이 끝나지 않으면 뒤의 문장이 시작되지 않습니다. 서로 시작과 끝이 딱딱 맞아 있어서 동기식(Synchronous)이라고 부릅니다. Synchronized Swimming처럼 앞뒤의 코드들의 수행 순서가 서로 딱딱 맞는 것입니다. 지금까지 배운 코드들은 모두 동기식으로 동작했습니다. 코드에 특별한 표시가 없으면 동기식으로 동작합니다.
    • 그런데 이번에는 http 통신을 이용해서 원격지에 있는 서버에서 데이터를 가져 옵니다. 시간이 많이 걸리겠지요. 이럴 때 코드가 동기식으로 동작한다면 어떻게 될까요? 데이터를 모두 가져올 때까지 클라이언트에서는 아무런 코드도 실행되지 않겠지요. 그랬다면 서버에서 데이터를 가져올 때까지 오른쪽의 화면을 볼 수 없었을 것입니다. 앞의 코드의 수행이 끝날 때까지 기다려야 하니까요. 그래서 Flutter는 http.get() 메소드를 비동기식으로 만들어 놓았습니다. 코드가 서로 시작 시점과 종료 시점이 딱딱 맞지 않고 독립적으로 돌아가기 때문에 비동기식(Asynchronous)이라고 부릅니다.

     

    • 비동기식의 원리와 개념과 의미를 알았으니 이번에는 문법을 알아 봅시다. 비동기식의 문법은 아래 코드와 같이 비동기 호출을 하는 함수의 ( )와 { 사이에 async를 추가하고 비동기식으로 호출되는 문장의 앞에 await를 추가하는 것입니다. 즉, async는 함수에서 함수안에 비동기 호출이 있다고 알려 주는 역할을 하고 await는 비동기 호출이 종료될 때까지 기다렸다가 뒤의 문장들을 수행하라는 의미가 됩니다. 코드에 async~await 쌍의 특별한 표시가 있을때에만 비동기식으로 동작합니다.
    // http_listview_page.dart 파일에서 비동기식으로 호출하는 문장만 발췌한 코드
    ...생략...
      requestResponseServer() async {
        var response = await http.get(Uri.parse("https://www.naver.com")); 
    ... 생략 ...

     

    • HttpListviewPage 클래스 코드의 흐름을 그림으로 그려보면 아래와 같습니다.

     

    • 비동기 호출 기술을 활용하여 조금 더 의미있는 코드를 추가해 봅시다. 네이버에서 HTML을 받아 오기 전에 화면에 "서버로 부터 HTML을 가져오기 전"이라는 문자열을 보여 주는 것보다 데이터를 가져오는 작업이 수행 중이라는 표시가 되면 더 좋을 것 같습니다. 백문이 불여 일견. 코드해서 실행화면을 봅시다.
    • 아래 코드는 “조건 ? 참일 때 수행할 문장 : 거짓일 때 수행할 문장”의 구조인 삼항 연산자를 알면 쉽게 이해가 됩니다. Flutter의 return 문장에서는 삼항 연산자와 같은 간단한 표현식을 사용할 수 있습니다.
    // 로딩표시하는 CircularProgressIndicator 기능을 추가한 http_listview_page.dart 파일 코드
    import 'package:flutter/material.dart';
    
    import 'package:http/http.dart' as http;
    
    class HttpListviewPage extends StatefulWidget {
      const HttpListviewPage({super.key});
    
      @override
      State<HttpListviewPage> createState() => _HttpListviewPageState();
    }
    
    class _HttpListviewPageState extends State<HttpListviewPage> {
      String textHTML = "서버로 부터 HTML을 가져오기 전";
    
      requestResponseServer() async {
        var response = await http.get(Uri.parse("https://www.naver.com"));
    
        setState(() {
          textHTML = response.body;
        });
      }
    
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        requestResponseServer();
      }
    
      @override
      Widget build(BuildContext context) {
        return Center(
          child: textHTML == "서버로 부터 HTML을 가져오기 전" // http.get() 완료 전이면
            ? CircularProgressIndicator()                    // 로딩 표시
            : Text(
                textHTML,
                style: TextStyle(fontSize: 30),
              ),
        );
      }
    }

     

    •  오른쪽의 실행화면을 봅시다. 비동기식의 이점이 명확하게 보이지 않나요? 동기식이면 화면이 멈추었을 것인데 비동기식이니 네이버에서 HTML을 가져오는 동안 Progress Circle을 보여 줄 수가 있습니다.

     

     

    • Flutter에서 비동기(Asynchronous) 프로그래밍을 지원하는 요소들은 네트워크 통신, 파일 I/O, 데이터베이스 작업 등 시간 소요가 발생하는 작업을 처리할 때 지원되는 것처럼 비동기 프로그램의 경우에도 객체 지향 프로그래밍의 경우와 같이 여러분 스스로 비동기로 동작하는 기능을 만들기 보다 비동기로 제공되는 기능을 사용하는 경우가 더 많습니다. 다른 사람들이 만들어 놓은 객체를 호출하여 사용하는 것만큼이나 aync~await 키워드의 쌍으로 비동기 기능을 사용하는 것은 단순합니다.

     

    웹서버 구축 후 request → response (HTML)

    • 이번에는 위에서 했던 request → response 형식의 일반적인 http 통신을 우리가 직접 웹서버를 구축하여 진행하겠습니다. 앞 과정에서 배웠던 Spring Boot 환경으로 구축하겠습니다.
    • 먼저 Spring Boot 프로젝트를 만들기 위하여 Spring Initializr 사이트로 이동합시다. 그리고 아래와 같은 정보를 입력하여 프로젝트를 만듭니다. 무언가를 선택할 때에는 항상 이유가 있어야 합니다. 제가 선택한 이유도 일리가 있으니 한번 읽어 보세요.
      • Project : Maven - 여러분이 겪었던 한글 문제가 사라질 것입니다.
      • Language : Java - 우리가 이 언어를 배웠고 Kotlin으로 전환되는 추세로 보이나 아직도 대세는 Java라고 생각합니다.
      • Spring Boot : 3.3.4 - Spring Initializr가 초기값으로 제공해 주는 가장 안정적인 버전을 선택합니다.
      • Artifact/Name : flutter_server - Flutter를 공부하기 위한 Server Side Application을 구축합니다. Flutter은 Client Side Application입니다.
      • Java : 17 - 현재 가장 안정적이라고 알려진 버전을 선택합니다.
      • Dependencies : Spring Boot DevTools, Lombok, Spring Web, Thymeleaf, Oracle Driver - Oracle DBMS에 연동하며 Web Server를 구축하기 위한 가장 기본적은 모듈들이라고 생각했습니다. 아주 단순한 DBMS 연동만 계획하고 있어서 JPA나 MyBatis는 사용하지 않습니다. MVC 패턴도 사용하지 않습니다.

     

    • 이제 Generate 버튼을 눌러 압축파일을 다운받읍시다. 그리고 다운로드 받은 압축파일을 프로젝트를 관리하는 폴더로 이동시켜 압축을 풀어 줍니다.

     

    • 그리고 오랜만에 IntelliJ 통합개발환경을 기동시켜 압축을 푼 폴더를 open합니다.

     

    • File → Project Structure로 이동하여 SDK 버전이 17로 지정되어 있는지 확인합니다. 그리고 Labguage level을 SDK Default로 선택합니다.

     

    • 아래 화면과 같이 src/main/resources/application.properties 파일에 server.port=8087을 입력하여 Oracle DBMS와의 포트 충돌을 사전에 예방합니다. 최신 버전의 Oracle DBMS Express 버전을 설치하면 8080 포트와 충돌하지 않아서 별도의 설정이 필요하지 않습니다.

     

    • 그리고 아래 화면과 같이 src/main/resources/templates 폴더에 index.html 파일을 만든 후 아래 화면과 같이 편집합니다. HTML의 틀을 IntelliJ 통합개발환경이 만들어 주기 때문에 여러분은 <body>와 </body> 사이에 <H1>Flutter와 연동하기 위한 Flutter Backend Server입니다.</H1> 문장만 입력하면 됩니다.

     

    • 그리고 아래 화면과 같이 src/main/java/com.example.flutter_server 폴더의 FlutterServerApplication으로 이동하여 마우스 우측 클릭한 후 Run ‘FlutterServerA…main() 메뉴를 선택합니다. 나중에는 상단에 실행 메뉴를 눌러 더 쉽게 프로젝트를 실행할 수 있으나 처음에 프로젝트를 수행할 때에는 이 방법이 가장 안전합니다.

     

    • 프로젝트가 실행된 후 IntelliJ 통합개발환경 화면 하단에 메세지들을 유심히 살펴보기 바랍니다. 혹시 오류가 발생한 것이 있다면 해결한 후 다음 단계로 넘어가야 합니다. 다행히 아래 화면의 메세지를 보니 Started FlutterServerApplication in 5.221 seconds (process running for 6.912)라고 나타나는 것으로 보아 문제없이 프로젝트가 실행 중입니다.

     

    • IntelliJ 통합개발환경의 화면 상단에서 Edit Configuration 메뉴를 클릭하여 설정이 잘 되어 있는지 확인합니다. Java 17 SDK 환경으로 설정되어 있다면 정상입니다.

     

     

    • 그럼 인터넷 브라우저에서 http://localhost:8087을 주소창에 입력하여 Flutter Server가 정상적으로 동작하는지 확인해 보겠습니다. https://www.naver.com 달리 https를 사용하지 못하는 것은 웹서버에 보안설정을 하지 않았기 때문입니다.
    • 성공입니다. 웹서버가 정상적으로 구축되었습니다. 이제 Flutter Client와 Flutter Server를 연동해 보겠습니다.

     

    • Naver에 연결되었던 앱을 조금 전에 만든 로컬 서버로 연결하는 작업은 아주 간단할 것 같습니다. 아래 코드와 같이 http.get() 메소드에 넘겨 주는 서버의 주소를 https://www.naver.com에서 http://localhost:8087로 바꾸어 주면 될 것 같습니다.
    // http_listview_page.dart 파일 - 변경후 코드
    ... 생략 ...
      requestResponseServer() async {
        //var response = await http.get(Uri.parse("https://www.naver.com"));
        var response = await http.get(Uri.parse("http://localhost:8087"));  // 연결 주소 변경
    
        setState(() {
          textHTML = response.body;
        });
      }
    ... 생략 ...

     

    • 그러나 운영 환경에서는 맞는 말인데 개발 환경에서는 그렇지 않습니다. 그래서 코드를 위와 같이 수정한 후 실행시켜 보니 한없이 Progress Circle만 나타납니다. Server를 찾지 못하고 있는 것입니다.

     

    • 이유는 아래와 같은 그림에서 설명하는 것처럼 이뮬레이터는 별도의 가상 장치로 localhost는 이뮬레이터이지 여러분의 PC가 아니기 때문입니다.

     

    • 똑똑한 iOS의 이뮬레이터는 이 문제를 해결해 놓아서 http://localhost:8087로 접근이 가능하나 Andrioid 이뮬레이터는 그렇지 않아서 http://10.0.2.2:8087로 접근하여야 합니다.
    // http_listview_page.dart 파일 - Android 이뮬레이터에서 PC의 localhost를 접속하도록 변경후 코드
    ... 생략 ...
      requestResponseServer() async {
        //var response = await http.get(Uri.parse("https://www.naver.com"));
        //var response = await http.get(Uri.parse("http://localhost:8087"));
        var response = await http.get(Uri.parse("http://10.0.2.2:8087"));  // 연결 주소 변경
    
        setState(() {
          textHTML = response.body;
        });
      }
    ... 생략 ...

     

    • 다시 실행시켜 보니 로딩 표시가 나타나다가 우리가 구축한 웹사이트에서 구축해 놓은 index.html 파일의 HTML을 가져와서 보여 줍니다.

     

     

    • 그런데 느끼시겠지만 위의 코드는 Android에서만 돌아가는 반쪽짜리 코드입니다. 코드가 수행되는 플랫폼(Platform,여기서는 Server 혹은 PC의 종류를 의미합니다.)에 따라 그에 적합한 Url를 사용하도록 코드를 바꾸어야 합니다.
    // Android와 iOS 이뮬레이터에서 모두 동작하는 코드 (참조용/실행해 보지 않음)
    ... 생략 ...
    import 'package:flutter/material.dart';
    
    import 'package:http/http.dart' as http;
    import 'dart:io';                        // 실행 중인 플랫폼을 알려주는 패키지 import
    ... 중략 ...
      requestResponseServer() async {
        //var response = await http.get(Uri.parse("https://www.naver.com"));
        String serverUrl = "http://localhost:8087";    // 서버 URL의 초기값 지정
    
        if (Platform.isAndroid) {                      // Android 플랫폼인 경우
          serverUrl = "http://10.0.2.2:8087";          // 서버 URL 변경
        }
    
        var response = await http.get(Uri.parse(serverUrl));  // 변수를 사용하여 호출
    
        setState(() {
          textHTML = response.body;
        });
      }
    ... 생략 ...

     

    • 그런데 아래와 같은 HTML을 가져다가 정보를 내가 원하는 형태로 만들어 사용하는 것은 상당히 복잡하고 기술적인 도전이 될 수 있습니다.

     

    • 이런 일을 Parsing(구문분석, Compiler를 만드는 사람들이 처음에 사용한 말입니다. Rendering 만큼이나 어려운 말입니다.)한다고 합니다. HTML을 가져다가 Parsing하여 사용하는 것은 여러분이 배운 Web Crawling의 주제이며 Selenium과 BeautifulSoup 패키지의 관심사일 만큼 큰 주제입니다. 이 두가지 패키지로도 한계에 부딛히면 정규식(Regular Expression)의 도움을 받아야 할 수도 있고, 때로는 find, substring, replace 등과 같은 문자열 객체(String)들이 제공하는 다양한 함수들의 도움을 받아야 할 수도 있습니다. 그리고 여러분이 Text Mining에서 배웠던 okt나 mecab과 같은 다양한 언어 형태소 분석기들을 사용하여야 할지도 모릅니다.
    • 결국 HTML은 사람의 생각을 표현하기에는 적합한 구조이나, Server와 Client간의 Data를 주고 받기에는 적합한 구조가 아닙니다. Data를 주고 받기 위해서는 더 단순한 구조가 필요합니다.

     

    json request → response

    • 그래서 사람들은 데이터를 주고 받는 용도로 더 단순하고 쉬운 방법을 생각하게 되었습니다.
    • 먼저 개발된 것은 HTML을 확장시킨 XML(Extensible Markup Language)를 만들어 냈습니다.
    • 그러나 아래 화면에 보이는 것과 같이 XML도 HTML 보다 구조화되고 개선되기는 했으나 데이터를 쉽게 주고 받을만큼 단순화되지는 못하였습니다.

     

    • 그래서 최근에는 JSON(JavaScript Object Notation)이라는 단순화된 형식이 각광을 받게 되었습니다. JSON의 형식을 잠시 보고 갑시다.

     

    • 위의 화면의 형식을 보면 여러분이 배운 프로그래밍 언어에서 익숙한 두가지 구조로 되어 있습니다. 대괄호([ ])는 Python의 리스트나 Java의 배열에서 사용하던 기호이고, 중괄호({ })는 Python의 딕셔너리(dict)의 기호이고 JSON을 구성하는 값들은 Python의 딕셔너리(dict)나 Java의 Map과 같이 Key와 Value의 구조를 가지고 있습니다. 굳이 차이가 있다면 프로그래밍 언어의 내부에 저장되지 않고 인터넷 상에서 데이터를 주고 받아야 하기 때문에 Key가 모두 Text 문자열이라는 것입니다. Value는 문자열뿐만 아니라 숫자, 배열, 불리언 값 및 객체(Nested json) 등이 될 수 있습니다.
    • 아주 단순한 구조이지요? 그래서 최근에는 JSON이 XML을 제치고 데이터를 주고 받는 표준으로 자리잡고 있으며, 이 뿐만이 아니라 No SQL이나 객체의 저장 형태로도 이 형식을 사용해 가는 추세입니다.
    • 우리는 XML을 배우지 않고 JSON을 배울 것입니다.
    • 지금 여기서 예를 들려고 하는 것은 아래의 왼쪽 화면과 같은 https://jsonplaceholder.typicode.com/users Url에서 json Data를 받아와서 아래의 오른쪽 화면과 같은 ListView에 보여 주는 것입니다.

     

     

    • 인터넷 상의 json과 같은 Data Service를 조금 더 이해하기 위하여 https://jsonplaceholder.typicode.com/users/1 Url을 브라우저에 입력해 봅시다. 그러면 전체 사용자(users)가 아니라 사용자 중 id가 1인 사용자의 정보만 보여줄 것입니다. 아주 전형적으로 인터넷 상에서 정보를 조회하는 방식입니다.

     

    또 단계적으로 코드를 하며 이해해 봅시다. 일단 json Data를 가져 오는 코드는 Naver에서 HTML을 가져 오던 로직과 동일합니다. Data를 가져오는 주소만 달라집니다.

    // http_listview_page.dart 파일 - json Data 가져오는 로직이 반영된 코드
    ... 생략 ...
      requestResponseServer() async {
        //var response = await http.get(Uri.parse("https://www.naver.com"));
        //String serverUrl = "http://localhost:8087";
        //String serverUrl = "http://10.0.2.2:8087";
        String serverUrl = "https://jsonplaceholder.typicode.com/users/1";  // Url 변경
    
        var response = await http.get(Uri.parse(serverUrl));
    
        setState(() {
          textHTML = response.body;
        });
      }
    ... 생략 ...

     

    • json Data를 잘 가져 옵니다.

     

    • json Data를 가져 왔으니 남은 일은 json Data를 Parsing(구문분석)하여 사용하는 것입니다. Dart 언어에서 json Data Parsing은 매우 간편합니다. Dart convert 패키지의 jsonDecode() 함수는 json을 구문분석하여 Map(딕셔너리) 객체로 만들어 주는데 Map 객체에서 Data를 꺼내서 사용할 때 아래와 같이 가져올 json의 형식과 구조을 정확하게 알고 그에 맞추어 사용하기만 하면 됩니다.
    {
      "id": 1,
      "name": "Leanne Graham",
      "username": "Bret",
      "email": "Sincere@april.biz",
      "address": {
        "street": "Kulas Light",
        "suite": "Apt. 556",
        "city": "Gwenborough",
        "zipcode": "92998-3874",
        "geo": {
          "lat": "-37.3159",
          "lng": "81.1496"
        }
      },
      "phone": "1-770-736-8031 x56442",
      "website": "hildegard.org",
      "company": {
        "name": "Romaguera-Crona",
        "catchPhrase": "Multi-layered client-server neural-net",
        "bs": "harness real-time e-markets"
      }
    }

     

    • 그러면 json Data를 Map의 형태로 파싱하여 ListView에 출력해 보겠습니다.
    // http_listview_page.dart 파일 코드
    import 'package:flutter/material.dart';
    
    import 'package:http/http.dart' as http;
    import 'dart:convert';        // jsonDecode() 메소드를 사용하기 위하여 import
    
    class HttpListviewPage extends StatefulWidget {
      const HttpListviewPage({super.key});
    
      @override
      State<HttpListviewPage> createState() => _HttpListviewPageState();
    }
    
    class _HttpListviewPageState extends State<HttpListviewPage> {
      //String textHTML = "서버로 부터 HTML을 가져오기 전";   // 주석처리 (문제를 해결할 때 사용하게 됨)
      var parsedJson;             // json을 파싱하여 저장할 변수 정의
    
      requestResponseServer() async {
        //var response = await http.get(Uri.parse("https://www.naver.com"));
        //String serverUrl = "http://localhost:8087";
        //String serverUrl = "http://10.0.2.2:8087";
        String serverUrl = "https://jsonplaceholder.typicode.com/users/1";
    
        var response = await http.get(Uri.parse(serverUrl));
    
        setState(() {
          //textHTML = response.body;
          parsedJson = jsonDecode(response.body);   // json 파싱
          print(parsedJson);                        // 파싱된 json 출력
        });
      }
    
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        requestResponseServer();
      }
    
      @override
      Widget build(BuildContext context)
        /* LisView로 보여 주도록 변경
        return Center(
          child: textHTML == "서버로 부터 HTML을 가져오기 전"
            ? CircularProgressIndicator()
            : Text(
                textHTML,
                style: TextStyle(fontSize: 30),
              ),
        );
        */
        return parsedJson == null
          ? Center(
              child: CircularProgressIndicator(),
            )
          : ListView.builder(            // Parsing된 json 즉 Map(딕셔너리)을 ListView에 출력
    		      itemCount: 1,              // Data가 하나라서 1을 강제 지정
    		      itemBuilder: (context, index) {
    		        return Card(
    		          child: Center(
    		            child: Column(
    		              children: [
    		                Text("ID: ${parsedJson['id']}",   // parsedJson을 Map의 형태로 보여줌
    		                  style: TextStyle(fontSize: 30),
    		                ),
    		                Text("이름: ${parsedJson['name']}",
    		                  style: TextStyle(fontSize: 25),
    		                ),
    		                Text("사용자명: ${parsedJson['username']}",
    		                  style: TextStyle(fontSize: 25),
    		                ),
    		                Text("메일주소: ${parsedJson['email']}",
    		                  style: TextStyle(fontSize: 25),
    		                )
    		              ],
    		            ),
    		          ),
    		        );
    		      },
        );
      }
    }

     

    • 그러면 하단의 실행창에 파싱된 1개의 Map Data가 출력되는 것을 볼 수 있고, Map Data가 ListView에 출력되는 것을 볼 수 있습니다.

     

     

    • 그러면 하단의 실행창에 파싱된 여러개의 Map Data가 리스트(List)의 형태로 출력되는 것을 볼 수 있고, Map Data의 리스트가 ListView에 출력되는 것을 볼 수 있습니다.
    // http_listview_page.dart 파일 코드
    import 'package:flutter/material.dart';
    
    import 'package:http/http.dart' as http;
    import 'dart:convert';
    
    class HttpListviewPage extends StatefulWidget {
      const HttpListviewPage({super.key});
    
      @override
      State<HttpListviewPage> createState() => _HttpListviewPageState();
    }
    
    class _HttpListviewPageState extends State<HttpListviewPage> {
      //var parsedJson;
      var users;                 // 변수명을 의미있게 변경
      
      requestResponseServer() async {
        //var response = await http.get(Uri.parse("https://www.naver.com"));
        //String serverUrl = "http://localhost:8087";
        //String serverUrl = "http://10.0.2.2:8087";
        //String serverUrl = "https://jsonplaceholder.typicode.com/users/1";
        String serverUrl = "https://jsonplaceholder.typicode.com/users";  // Url 변경
    
        var response = await http.get(Uri.parse(serverUrl));
    
        setState(() {
          // textHTML = response.body;
          //parsedJson = jsonDecode(response.body);
          users = jsonDecode(response.body);      // 의미있게 바꾼 변수명을 사용
          print(users);
        });
      }
    
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        requestResponseServer();
      }
    
      @override
      Widget build(BuildContext context) {
        return users == null                      // 의미있게 바꾼 변수명을 사용
            ? Center(
                child: CircularProgressIndicator(),
              )
            : ListView.builder(
    			      //itemCount: 1,
    			      itemCount: users.length,          // 여러개의 Data가 넘어와서 변경
    			      itemBuilder: (context, index) {   // 의미있게 바꾼 변수명을 사용
    			        var user = users[index];        // 여러개의 Data가 넘어와서 추가
    			        return Card(                    // 변수의 이름을 의미있게 users/user로 변경
    			          child: Center(
    			            child: Column(
    			              children: [
    			                Text("ID: ${user['id']}",
    			                  style: TextStyle(fontSize: 30),
    			                ),
    			                Text("이름: ${user['name']}",
    			                  style: TextStyle(fontSize: 25),
    			                ),
    			                Text("사용자명: ${user['username']}",
    			                  style: TextStyle(fontSize: 25),
    			                ),
    			                Text("메일주소: ${user['email']}",
    			                  style: TextStyle(fontSize: 25),
    			                )
    			              ],
    			            ),
    			          ),
    			        );
    			      },
        );
      }
    }

     

    웹서버 구축 후 request → response (json)

    • 웹서버에 json Data를 반환하는 기능을 구축하기 위하여 IntelliJ 통합개발환경으로 이동해서 아래 위치에 UserController Java Class를 만듭니다.

     

    // Flutter Server의 UserController 클래스 코드
    package com.example.flutter_server;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    @Controller       // Controller로 사용되게 해주는 Annotation
    @ResponseBody     // HTML 문서가 아니라 일반 문자열을 반환하게 해 주는 Annotation
    public class UserController {
        @GetMapping("/users")    // http://localhost:8087/users가 접속 Url
        public String users() {
            return "여기에 붙여 넣습니다.";
        }
    }

     

    • 웹브라우저에서 https://jsonplaceholder.typicode.com/users 주소를 쳐서 json Datga를 불러온 후 반환되는 json 문자열 전체를 복사합니다. 그리고 위의 코드에서 return "여기에 붙여 넣습니다"; 문자에 붙여 넣습니다.

     

    • 그러면 IngtelliJ가 문법 오류가 나지 않도록 알아서 잘 문자열을 처리해서 return 문장으로 json Data 문자열을 반환하게 해 줍니다.

     

    • 복사해 넣은 json Data를 원본 데이터와 달라지도록 살짝 수정하여 Test Data를 완성하겠습니다.

     

    • 그러면 웹 서버를 다시 실행한 후 웹브라우저에서 localhost:8087/users를 쳐 json Data가 정상적으로 반환되는지 확인합시다. 아래 화면을 보니 json Data가 잘 반환됩니다.

     

    • json Data의 형식을 동일하게 가져갔기 때문에 코드에서 바꿀 부분은 url을 우리가 개발한 flutter_server의 users Url로 바꾸는 것 뿐입니다.
    // http_listview_page.dart 파일 코드
    import 'package:flutter/material.dart';
    
    import 'package:http/http.dart' as http;
    import 'dart:convert';
    
    class HttpListviewPage extends StatefulWidget {
      const HttpListviewPage({super.key});
    
      @override
      State<HttpListviewPage> createState() => _HttpListviewPageState();
    }
    
    class _HttpListviewPageState extends State<HttpListviewPage> {
      var users;
    
      requestResponseServer() async {
        //var response = await http.get(Uri.parse("https://www.naver.com"));
        //String serverUrl = "http://localhost:8087";
        //String serverUrl = "http://10.0.2.2:8087";
        //String serverUrl = "https://jsonplaceholder.typicode.com/users/1";
        //String serverUrl = "https://jsonplaceholder.typicode.com/users";
        var serverUrl = "http://10.0.2.2:8087/users";        // Url 변경
    
        var response = await http.get(Uri.parse(serverUrl));
    
        setState(() {
          users = jsonDecode(response.body);
          print(users);
        });
      }
    
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        requestResponseServer();
      }
    
      @override
      Widget build(BuildContext context) {
        return users == null
            ? Center(
                child: CircularProgressIndicator(),
              )
            : ListView.builder(
          itemCount: users.length,
          itemBuilder: (context, index) {
            var user = users[index];
            return Card(
              child: Center(
                child: Column(
                  children: [
                    Text("ID: ${user['id']}",
                      style: TextStyle(fontSize: 30),
                    ),
                    Text("이름: ${user['name']}",
                      style: TextStyle(fontSize: 25),
                    ),
                    Text("사용자명: ${user['username']}",
                      style: TextStyle(fontSize: 25),
                    ),
                    Text("메일주소: ${user['email']}",
                      style: TextStyle(fontSize: 25),
                    )
                  ],
                ),
              ),
            );
          },
        );
      }
    }

     

    • 이제 원하는 대로 우리가 만든 Flutter 서버에서 Data를 가져다가 Flutter 클라이언트 화면에 보여 주었습니다.

     

    로그인 화면

    • 로그인 화면을 만드는 것도 단계적으로 점진적으로 만들며 이해해 봅시다.
    • 먼저 login_page.dart 파일을 만든 후 그 안에 LoginPage 클래스를 만들겠습니다. 로그인 화면은 User ID와 Password 등을 입력받아야 하니 Stateful Widget으로 만듭니다. 지금부터는 안드로이드 스튜디오를 조작하는 방법을 설명하지 않으니 이전에 배운 것들을 떠올리며 코딩하기 바랍니다.
    • 아래 코드를 보면 TextField Widget과 InputDecoration 객체와 TextBox Widget이 사용된 것을 제외하면 기존에 배운 것들입니다. TextField는 키보드 입력을 받는 기능을 하고, InputDecoration은 입력 필드를 꾸며주는 역할을 합니다. 로그인 버튼을 누르면 우선 MainHomePage로 이동하게 만들었습니다.
    • 먼저 로그인 화면에 필요한 Image 파일을 images 폴더에 저장하고

     

    • pubspec.yaml 파일에 자원(Asset)으로 등록한 후 Pub get 메뉴를 클릭하기 바랍니다.
    // login_page.dart에 만든 LoginPage Stateful Widget 클래스 코드
    import 'package:flutter/material.dart';
    
    import "main.dart";
    
    class LoginPage extends StatefulWidget {
      const LoginPage({super.key});
    
      @override
      State<LoginPage> createState() => _LoginPageState();
    }
    
    class _LoginPageState extends State<LoginPage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Image.asset("images/LG전자 DX School.PNG"),
                SizedBox(height: 50),
                SizedBox(
                  height: 200,
                  child: Column(
                    children: [
                      SizedBox(
                        width: 200,
                        child: TextField(                // TextField 사용
                          decoration: InputDecoration(   // InputDecoration사용
                            labelText: "아이디",
                            border: OutlineInputBorder(),
                          ),
                        ),
                      ),
                      SizedBox(
                        width: 200,
                        child: TextField(                // TextField 사용
                          obscureText: true,             // 입력하는 암호가 필드에 보이지 않게 처리함
                          decoration: InputDecoration(   // InputDecoration사용
                            labelText: "비밀번호",
                            border: OutlineInputBorder(),
                          ),
                        ),
                      ),
                      SizedBox(height: 20),
                      Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          TextButton(                   // TextButton 사용
                              onPressed: () {},
                              child: Text("회원가입")
                          ),
                          TextButton(                   // TextButton 사용
                              onPressed: () {
                                Navigator.of(context).push(
                                  MaterialPageRoute(builder: (_) => MyHomePage(title: 'Flutter Demo Home Page')),
                                );
                              },
                              child: Text("로그인")
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
                Image.asset("images/lab4dx.PNG"),
              ],
            ),
          ),
        );
      }
    }

     

    • 로그인 화면은 Home 화면보다 앞에 와야 하니 main.dart 파일의 코드를 Home 화면보다 Login 화면을 먼저 실행하도록 수정하여야 하겠습니다.
    // 로그인 화면을 먼저 나오게 수정한 main.dart 코드
    ... 생략 ...
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
          //home: const MyHomePage(title: 'Flutter Demo Home Page'),
          home: LoginPage(),   // 최초 기동할 Page를 MyHomePage에서 LoginPage로 변경
        );
      }
    }
    ... 생략 ...

     

    • 이제 실행해 봅시다. 로그인 화면이 나타난 후 로그인 TextButton을 누르면 MyHomePage로 이동합니다. 그런데 Back 버튼을 눌러서 다시 로그인화면으로 돌아 가는 것이 마음에 들지 않습니다.

     

     

     

    • 그럴 때에는 화면 이동을 할 때 Navigator.of(context).push() 메소드 대신에 Navigator.of(context).pushReplacement() 메소드를 사용합니다. 메소드 이름이 의미하는 것으로 자명(Self Explanatory)하지만 설명해 보면 push는 호출하는 페이지를 Navigator Stack에 남기고 이동하여 원래의 화면으로 돌아갈 수 있고, pushReplacement는 호출하는 페이지를 Navigator Stack에 남기지 않고 이동하여 원래 화면으로 이동할 수 없습니다.
    // _LoginPageState 클래스의 변경후 화면이동 코드
        //Navigator.of(context).push(
        Navigator.of(context).pushReplacement(    // 돌아가기 기능을 제거하도록 메소드 변경
          MaterialPageRoute(builder: (_) => MyHomePage(title: 'Flutter Demo Home Page')),
        );

     

    • 실행해 보면 MyHomePage의 상단 AppBar에 ← 화살표가 사라지며 로그인 화면으로 다시 돌아 가는 기능이 사라진 것을 확인할 수 있습니다.

     

     

    • 이제 아래 코드를 주석 처리한 것을 중심으로 읽어 봅시다.
    // TextField에 초기값을 저장하고 입력된 값을 가져와 사용하는 _LoginPageState 클래스 코드
    class _LoginPageState extends State<LoginPage> {
      TextEditingController userIdController = TextEditingController();   // 콘트롤러 추가
      TextEditingController passwordController = TextEditingController(); // 콘트롤러 추가
    
      @override                             // 초기값 부여를 위해 initState() 메소드 override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        userIdController.text = "test";     // User ID에 초기값 부여
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Container(
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Image.asset("images/LG전자 DX School.PNG"),
                  SizedBox(height: 50),
                  SizedBox(
                    height: 200,
                    child: Column(
                      children: [
                        SizedBox(
                          width: 200,
                          child: TextField(
                            controller: userIdController,   // TextField와 Controller 연결
                            decoration: InputDecoration(
                              labelText: "아이디",
                              border: OutlineInputBorder(),
                            ),
                          ),
                        ),
                        SizedBox(
                          width: 200,
                          child: TextField(
                            controller: passwordController,  // TextField와 Controller 연결
                            obscureText: true,
                            decoration: InputDecoration(
                              labelText: "비밀번호",
                              border: OutlineInputBorder(),
                            ),
                          ),
                        ),
                        SizedBox(height: 20),
                        Row(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            TextButton(
                                onPressed: () {},
                                child: Text("회원가입")
                            ),
                            TextButton(
                                onPressed: () {
                                  if(userIdController.text == "test" && passwordController.text == "test")        // 입력된 값을 Check하여 두값이 모두 test일 경우 로그인하도록 수정
                                    Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => const MyHomePage(title: 'Flutter Demo Home Page')));
                                  else              // 일치하지 않는 경우 알림창 출력
                                    showDialog(
                                        context: context, 
                                        builder: (context) {
                                          return AlertDialog(content: Text("아이디가 존재하지 않거나 비밀번호가 일치하지 않습니다."));
                                        }
                                    );
                                },
                                child: Text("로그인")
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                  Image.asset("images/lab4dx.PNG"),
                ],
              ),
            )
          ),
        );
      }
    }

     

    • 실행해 보겠습니다. 아이디가 없거나 비빌번호가 틀리면 경고창이 나타나고, Test를 위하여 만든 임시코드와 같이 모두 “test”를 입력하면 로긴되어 MyHomePage로 이동합니다. 경고창을 가능한 단순한 형태로 만들어서 확인(OK) 버튼이 나타나지 않는데 경고창의 외부를 클릭하면 경고창이 닫힙니다. 곧 개선할 것입니다.

     

     

     

    • 이제 Spring Boot로 구축하고 있는 Flutter Server에 접속하여 사용자 정보를 가져오도록 기능을 더 개선해 보겠습니다. 일반적으로 사용자 정보는 스마트폰에 저장하지 않고 중앙의 서버에 보관하게 됩니다. 그래서 Flutter 개발자는 DBMS 기술보다는 통신 기술과 클라우드 기술에 더 관심을 가져야 합니다. 이 과정에서는 클라우드 기술을 사용하지 않는데 웹 서버와 연동 방법을 알면 클라우드 연결도 쉽게 익혀서 사용할 수 있습니다.
    • 아래 코드도 우선 Flutter Server 연동을 먼저 Test할 수 있도록 아이디와 비밀번호가 모두 “test”인 경우 true를 반환하고 그렇지 않은 경우 false를 반환하게 만들었습니다.
    // Flutter Server의 UserController 클래스 코드
    package com.example.flutter_server;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    import org.springframework.web.bind.annotation.PostMapping;   // import 추가
    import org.springframework.web.bind.annotation.RequestParam;  // import 추가
    
    @Controller
    @ResponseBody
    public class UserController {
        @PostMapping("/login-check")         // 추가된 메소드
        public String loginCheck(@RequestParam("userId") String userId, @RequestParam("password") String password) {
            if ( userId.equals("test") && password.equals("test") )
                return "true";
            else
                return "false";
        }
    
        @GetMapping("/users")    // http://localhost:8087/users가 접속 Url
        public String users() {
            return "[\n" +
                    "    {\n" +
                    "        \"id\": 320811,\n" +
                    "        \"name\": \"안용제\",\n" +
                    "        \"username\": \"yongjaeahn\",\n" +
                    "        \"email\": \"yongjaeahn@naver.com\",\n" +
    ... 생략 ...

     

    • 이제 Flutter에서 위에서 만든 Flutter Server의 Restful API Service-위에서 만든 웹 서비스를 사용하여 로그인을 Check하는 기능을 만들어 봅시다. 경고창을 자주 사용하게 될 것으로 예상되어 여러 상황을 함께 고려하도록 경고창 기능을 하는 함수로 분리하였습니다. 그리고 지금까지 미루어 놓았던 http 통신 작업 후의 Return Code 처리 로직도 추가하였습니다. 그러나 예외 처리는 로직이 지나키게 복잡해 질 것을 우려하여 추가하지 않았습니다. (예외처리는 사용자 프로그래머이 영역이 아니라 전문 프로그래머의 영역이라고 생각합니다.) 관심이 있으신 분들은 개인적으로 문의하거나 여력이 될 때 여러분 스스로 추가해 보기 바랍니다.
    • 아래 코드를 주석을 중심으로 읽어 봅시다.
    // Flutter 서버를 통하여 Login Check하는 기능이 추가된_LoginPageState 클래스 코드 - 문자열
    import 'package:flutter/material.dart';
    import 'package:http/http.dart' as http;  // http 통신을 위하여 import
    
    import "main.dart";
    
    class LoginPage extends StatefulWidget {
      const LoginPage({super.key});
    
      @override
      State<LoginPage> createState() => _LoginPageState();
    }
    
    class _LoginPageState extends State<LoginPage> {
      TextEditingController userIdController = TextEditingController();
      TextEditingController passwordController = TextEditingController();
    
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        userIdController.text = "test";
      }
    
      loginCheck(String userId, String password) async {  // 로그인 체크 함수 추가
        String serverUri = "http://10.0.2.2:8087/login-check";  // Flutter Server의
                                                                // login check url
        var response = await http.post(     // Post 방식으로 호출
          Uri.parse(serverUri),
          headers: {                        // Post 방식 호출을 위해서 headers 정보 필요
            'Content-Type': 'application/x-www-form-urlencoded', // 응용프로그램/폼 인코드
          },
          body: {                // Request Parameter로 userId와 password를 넘겨줌
            'userId': userId,
            'password': password,
          },
        );
    
        print(response.body);    // 읽어온 문자열을 확인
    
        if (response.statusCode == 200) { // 오류없이 잘 읽어온 경우
          var loginStatus = response.body;
          if (loginStatus == 'true') {    // 아이디와 비밀번호가 일치하여 'true' 반환된 경우
            Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => const MyHomePage(title: 'Flutter Demo Home Page')));
          } else {                        // // 아이디와 비밀번호가 불일치하여 'false'인 경우
            showAlertDialog("로그인 실패", "아이디가 존재하지 않거나 비밀번호가 일치하지 않습니다.");
          }
        } else {       // Flutter 서버에서 로그인 체크하다가 알 수 없는 오류가 발생한 경우
          showAlertDialog("서버 오류", "Flutter Server의 정상 동작 여부를 점검하세요.(${response.statusCode})");
        }
      }
    
      showAlertDialog(title,message) {     // 경고창을 보여주는 기능을 함수로 분리
        showDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                title: Text(title),          // 경고창의 타이틀 추가
                content: Text(message),
                actions: [                   // 경고창에 Ok 버튼 추가
                  TextButton(
                    onPressed: () {          // Ok 버튼 누르면 이전 창으로 돌아감
                      Navigator.of(context).pop();
                    },
                    child: Text("Ok"),
                  )
                ],
              );
            }
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Image.asset("images/LG전자 DX School.PNG"),
                SizedBox(height: 50),
                SizedBox(
                  height: 200,
                  child: Column(
                    children: [
                      SizedBox(
                        width: 200,
                        child: TextField(
                          controller: userIdController,
                          decoration: InputDecoration(
                            labelText: "아이디",
                            border: OutlineInputBorder(),
                          ),
                        ),
                      ),
                      SizedBox(
                        width: 200,
                        child: TextField(
                          controller: passwordController,
                          obscureText: true,
                          decoration: InputDecoration(
                            labelText: "비밀번호",
                            border: OutlineInputBorder(),
                          ),
                        ),
                      ),
                      SizedBox(height: 20),
                      Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          TextButton(
                              onPressed: () {},
                              child: Text("회원가입")
                          ),
                          TextButton(
                              onPressed: () {
                                /* 기존에 단순화된 Test 코드 삭제 후
                                if(userIdController.text == "test" && passwordController.text == "test")
                                  //Navigator.of(context).push(
                                  Navigator.of(context).pushReplacement(
                                    MaterialPageRoute(builder: (_) => MyHomePage(title: 'Flutter Demo Home Page')),
                                  );
                                else
                                  showDialog(
                                      context: context,
                                      builder: (context) {
                                        return AlertDialog(content: Text("아이디가 존재하지 않거나 비밀번호가 일치하지 않습니다."));
                                      }
                                  );
                                */
                                loginCheck(userIdController.text,passwordController.text);  // loginCheck 함수 사용
                              },
                              child: Text("로그인")
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
                Image.asset("images/lab4dx.PNG"),
              ],
            ),
          ),
        );
      }
    }

     

     

     

    • 이번에는 Login Check를 Text 즉 String 방식이 아닌 json 방식으로 해 보겠습니다. 먼저 IntelliJ 통합개발환경으로 Flutter 서버 측의 코드부터 json 문자열을 반환하도록 수정하겠습니다.
    // Flutter Server의 UserController 클래스 코드
    package com.example.flutter_server;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    
    import java.util.HashMap;    // import 추가
    import java.util.Map;        // import 추가
    
    @Controller
    @ResponseBody
    public class UserController {
    		/* String으로 반환하는 함수 주석 처리
        @PostMapping("/login-check")
        public String loginCheck(@RequestParam("userId") String userId, @RequestParam("password") String password) {
            if ( userId.equals("test") && password.equals("test") )
                return "true";
            else
                return "false";
        }
        */
        @PostMapping("/login-check")    // Map 즉 딕셔너리 즉 json으로 반환하는 함수 추가
        public Map<String, Boolean> loginCheck(@RequestParam("userId") String userId, @RequestParam("password") String password) {
            boolean loginSuccess = userId.equals("test") && password.equals("test");
    
            Map<String, Boolean> response = new HashMap<>();
            response.put("loginSuccess", loginSuccess);
    
            return response;
        }
    
        @GetMapping("/users")    // http://localhost:8087/users가 접속 Url
        public String users() {
            return "[\n" +
                    "    {\n" +
                    "        \"id\": 320811,\n" +
                    "        \"name\": \"안용제\",\n" +
                    "        \"username\": \"yongjaeahn\",\n" +
                    "        \"email\": \"yongjaeahn@naver.com\",\n" +
    ... 생략 ...

     

    • 그리고 Flutter Client에서 문자열을 확인하던 로직을 json을 확인하는 로직으로 바꾸어 보겠습니다.
    // Flutter 서버를 통하여 Login Check하는 기능이 추가된_LoginPageState 클래스 코드 - json
    import 'package:flutter/material.dart';
    import 'package:http/http.dart' as http;
    import 'dart:convert';                    // jsonDecode 함수를 사용하기 위하여 import
    ... 중략 ...
      loginCheck(String userId, String password) async {
        String serverUri = "http://10.0.2.2:8087/login-check";
    
        var response = await http.post(
          Uri.parse(serverUri),
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          body: {
            'userId': userId,
            'password': password,
          },
        );
    
        print(response.body);
    
        if (response.statusCode == 200) {
          //var loginStatus = response.body;
          var parseJson = jsonDecode(response.body);      // Json을 Decode하여 Map으로 변경
          bool loginSuccess = parseJson['loginSuccess'];  // Map에서 loginSuccess 키의 값을 읽음
    
          //if (loginStatus == 'true') {
          if (loginSuccess) {              // Map에서 받아온 bool 값을 비교
            Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => const MyHomePage(title: 'Flutter Demo Home Page')));
          } else {
            showAlertDialog("로그인 실패", "아이디가 존재하지 않거나 비밀번호가 일치하지 않습니다.");
          }
        } else {
          showAlertDialog("서버 오류", "Flutter Server의 정상 동작 여부를 점검하세요.(${response.statusCode})");
        }
      }
    ... 생략 ...

     

     

    • 이제 간단한 Animation을 구현해 보겠습니다.
    • 먼저 아이디와 비밀번호 TextField가 서서히 나타나게 해 보겠습니다.

     

    // 아이디와 비밀번호 Text가 서서히 나타나는 Animation이 추가된 login_page.dart 파일 코드
    import 'package:flutter/material.dart';
    import 'package:http/http.dart' as http;
    import 'dart:convert';
    import 'dart:async';                    // Timer 객체를 사용하기 위하여 import
    
    import "main.dart";
    
    class LoginPage extends StatefulWidget {
      const LoginPage({super.key});
    
      @override
      State<LoginPage> createState() => _LoginPageState();
    }
    
    class _LoginPageState extends State<LoginPage> {
      TextEditingController userIdController = TextEditingController();
      TextEditingController passwordController = TextEditingController();
    
      double idPasswordOpacity = 0;  // 처음에 불투명도를 0으로 즉, 완전히 투명하게 설정
    
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        userIdController.text = "test";
    
        WidgetsBinding.instance.addPostFrameCallback((_) {  // Timer를 Widget에 전달하여 실행
          Timer(Duration(seconds: 2), () {                  // 2초뒤
            setState(() {                                   // 완전히 불투명하게 설정
              idPasswordOpacity = 1;                        // 불투명도를 1로
            });
          });
        });
      }
      ... 중략...
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Container(
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Image.asset("images/LG전자 DX School.PNG"),
                  SizedBox(height: 50),
                  SizedBox(
                    height: 200,
                    child: AnimatedOpacity(           // AnimatedOpacity Widget으로 감쌈
                      opacity: idPasswordOpacity,     // 불투명도를 앞에서 지정한 변수로 지정
                      duration: Duration(seconds: 3), // 3초간 서서히 나타나도록 설정
                      child: Column(
                        children: [
                          SizedBox(
                            width: 200,
                            child: TextField(
                              controller: userIdController,
                              decoration: InputDecoration(
                                labelText: "아이디",
                                border: OutlineInputBorder(),
                              ),
                            ),
                          ),
                          SizedBox(
                            width: 200,
                            child: TextField(
                              controller: passwordController,
                              obscureText: true,
                              decoration: InputDecoration(
                                labelText: "비밀번호",
                                border: OutlineInputBorder(),
                              ),
                            ),
                          ),
     ... 생략 ...

     

     

     

     

    • 이번에는 이미지를 회전시키는 Animation을 해 보겠습니다.

     

    // 이미지가 회전하는 Animation이 추가된 login_page.dart 파일 코드
    import 'package:flutter/material.dart';
    import 'package:http/http.dart' as http;
    import 'dart:convert';
    import 'dart:async';
    import 'dart:math';        // pi 상수를 사용하기 위하여 import
    
    import "main.dart";
    
    class LoginPage extends StatefulWidget {
      const LoginPage({super.key});
    
      @override
      State<LoginPage> createState() => _LoginPageState();
    }
    
    //class _LoginPageState extends State<LoginPage> {
    class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
                        // SingleTickerProviderStateMixin을 사용하도록 설정
                        // Mixin은 Java에는 없는 개념으로 특정 기능만 제공하는 클래스로 보면 됨
                        // Animation COntroller를 사용하기 위하여 이 Mixin이 필요함
                        // 여러 Animation을 사용하려면 TickerProviderStateMixin을 대신 사용함
                        // 아래 코드에서 vsync: this를 동작가능하게 하여 Animation을 동기화함
                        
      TextEditingController userIdController = TextEditingController();
      TextEditingController passwordController = TextEditingController();
    
      double idPasswordOpacity = 0;
    
      var animationController;   // animationController 변수
      var animation;             // 어떤 Animation을 할지 결정함
    
      @override
      void initState() {         // 객체 사용전 최초 자원을 할당 및 초기화
        // TODO: implement initState
        super.initState();      // Parent들이 할당할 자원과 초기화를 맨앞에 수행
                                // dispose()와 반대로 앞에 배치
    
        // Animation Controller에 3초 Animation을 설정/Animation 대상은 이 객체(vsync: this)
        animationController = AnimationController(duration: Duration(seconds: 3), vsync: this);
        // 0도 에서 360도(pi * 2) 돌아가는 Animation을 Animation Controller에 설정. (3초에 한바퀴)
        animation = Tween<double>(begin:0,end: pi * 2).animate(animationController);
        animationController.repeat(); // animation Controller로 Animation 시작
                                      // animation이 수치상으로 발생하고
                                      // 이를 가져다 화면에 보여주는 Widget은 AnimatedBuilder임
    
        userIdController.text = "test";
    
        WidgetsBinding.instance.addPostFrameCallback((_) {
          Timer(Duration(seconds: 2), () {
            setState(() {
              idPasswordOpacity = 1;
            });
          });
        });
      }
    
      @override
      void dispose() {
        animationController.dispose();  // 할당받은 Animation Controller 자원을 반납함
        // TODO: implement dispose
        super.dispose();                // Parent들이 해제할 자원을 맨뒤에 해제(initState()와 반대로 뒤에 배치)
      }                                 // super.initState()는 앞에 배치되어 Parent가 할당할 자원을 먼저 할당
      
    ... 중략 ...
                AnimatedBuilder(                     // image 객체를 AnimatedBuilder로 감쌈
                    animation: animationController,  // animationController를 사용하여 Animation
                    builder: (context, widget) {     // context : 현재 화면, widget : 감싸진 Widget
                      return Transform.rotate(       // animation에 설정된 각도 정보를 가지고 회전시킴
                        angle: animation.value,      // 각도 정보가 포함된 animation을 사용
                        child: widget,               // AnimatedBuilder로 감싸진 Widget
                      );
                    },
                    child: Image.asset("images/lab4dx.PNG")
                ),
              ],
            ),
          ),
        );
      }
    }

     

     

     

     

    • Instructor Note : 스마튼폰에서 팀즈 화면 공유를 통하여 Flutter Animation 앱 소개 - 코드와 애니메이션 형태가 들어 있음

     

    회원가입

    • Oracle DBMS가 설치되어 있지 않다면 Oracle Database Express Edition 설치 압축 파일을 다운로드하여 압축을 푼 후 Setup.exe 실행 파일을 실행하여 설치합니다. 설치시 입력한 비밀번호를 잘 기억해 두었다가 system 사용자 ID로 로그인할 때 사용하여야 합니다.
    • Oracle SQL Developer가 설치되어 있지 않다면 설치 압축 파일을 다운로드하여 압축을 푼 후 별도의 설치절차없이 sqldeveloper.exe 실행 파일을 실행하여 사용하면 됩니다.
    • Instructor Note: PC에 설치된 Oracle DBMS의 system 계정의 비밀 번호를 자신의 것으로 바꾸어 사용하여야 합니다. 변경 방법은 아래와 같습니다.

     

    • Instructor Note: system 계정의 비밀번호를 변경하여 사용 중 Oracle DB 접속시 오류가 발생한다면 Oracle DBMS를 지웠다가 다시 설치하여야 합니다. 오라클 삭제 방법
     

    오라클 삭제 방법

    오라클은 삭제가 무척이나 불편하다.

    velog.io

     

     

    • 먼저 Back End 기능의 예로 회원가입을 위한 데이터베이스 테이블을 Flutter Server에 만들겠습니다. 아래와 같은 SQL 문장을 Oracle SQL Developer에서 실행시키면 됩니다. 이미 테이블이 존재하는 경우 삭제하고 다시 만들도록 SQL 문장이 구성되었습니다.
    -- 테이블이 이미 존재하면 삭제
    DROP TABLE flutter_user CASCADE CONSTRAINTS PURGE;
    
    -- 테이블 생성
    CREATE TABLE flutter_user (
        id VARCHAR2(10) PRIMARY KEY,     -- id 필드는 PRIMARY KEY로 설정
        password VARCHAR2(20) NOT NULL,  -- password 필드는 NULL 불가
        name VARCHAR2(20) NOT NULL       -- name 필드는 NULL 불가
    );

     

    • Flutter Server에서 생성한 Database Table에 연결하기 위하여 DBConnection 객체를 만들어 생성자 주입을 위하여 @Component 어노테이션(Annotation)을 추가합니다.
    // DBConnection 클래스
    package com.example.flutter_server;
    
    import org.springframework.stereotype.Component;
    
    import java.sql.Connection;
    import java.sql.DriverManager;
    import java.sql.SQLException;
    
    @Component
    public class DBConnection {
        private static Connection connection;
    
        public Connection getConnection() throws SQLException, ClassNotFoundException {
            if (connection == null || connection.isClosed()) {
                String url = "jdbc:oracle:thin:@localhost:1521:xe";
                String driver = "oracle.jdbc.driver.OracleDriver";
                String username = "your_oracle_db_id";
                String password = "your_oracle_db_password";
    
                Class.forName(driver);
                connection = DriverManager.getConnection(url, username, password);
            }
            return connection;
        }
    }

     

    • http://localhost:8087/add-user Url로 Database Table에 사용자를 추가하기 위한 로직을 추가합니다. 아래 코드는 JPA나 MyBatis을 사용하지 않고 일반적인 SQL를 사용하기 때문에 코드를 읽으면서 직관적으로 이해할 수 있을 것입니다. JPA나 MyBatis의 경우 응용프로그램의 복잡도가 충분히 높아져서 JPA나 MyBatis의 도움을 받아야만 하는 시점에 Refactoring으로 구현하여 사용하면 됩니다. TDD의 점진적 개발 방법론에 따르면 지금은 굳이 JPA나 MyBatis를 사용하여 프로그램의 복잡도를 높일 이유가 없습니다.
    // UserController 클래스에 DBConnection을 주입(Autowired)하여 
    // flutter_user DB 테이블에 사용자를 추가하는 로직 추가
    package com.example.flutter_server;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    
    import java.util.HashMap;
    import java.util.Map;
    
    import org.springframework.beans.factory.annotation.Autowired;  // 의존성 주입을 위하여 import
    import java.sql.Connection;                                     // DB 연결을 위하여 Import
    import java.sql.PreparedStatement;
    import java.sql.SQLException;
    
    @Controller
    @ResponseBody
    public class UserController {
        @Autowired
        DBConnection dbConnection;     // DBConnection 의존성 주입
    
        @PostMapping("/add-user")      // 사용자를 추가하기 위한 함수 추가
        public String addUser(@RequestParam("userId") String userId, @RequestParam("password") String password, @RequestParam("name") String name) {
            PreparedStatement pstmt;
    
            try {
                Connection connection = dbConnection.getConnection();
    
                String sql = "INSERT INTO flutter_user (id, password, name) VALUES (?, ?, ?)";
                pstmt = connection.prepareStatement(sql);
    
                pstmt.setString(1, userId);
                pstmt.setString(2, password);
                pstmt.setString(3, name);
    
                int rowsInserted = pstmt.executeUpdate();
                if (rowsInserted > 0) {
                    System.out.println("사용자 데이터가 성공적으로 추가되었습니다.");
                    return "success";
                } else {
                    System.out.println("사용자 데이터 추가시 오류가 발생하였습니다.");
                    return "failure";
                }
            } catch (SQLException | ClassNotFoundException e) {
                System.out.println("사용자 데이터 추가시 DBMS 오류가 발생하였습니다.");
                return "failure";
            }
        }
    
        @PostMapping("/login-check")
        public Map<String, Boolean> loginCheck(@RequestParam("userId") String userId, @RequestParam("password") String password) {
            boolean loginSuccess = userId.equals("test") && password.equals("test");
    
            Map<String, Boolean> response = new HashMap<>();
            response.put("loginSuccess", loginSuccess);
    
            return response;
        }
    
        @GetMapping("/users")    // http://localhost:8087/users가 접속 Url
        public String users() {
            return "[\n" +
                    "    {\n" +
                    "        \"id\": 320811,\n" +
                    "        \"name\": \"안용제\",\n" +
                    "        \"username\": \"yongjaeahn\",\n" +
                    "        \"email\": \"yongjaeahn@naver.com\",\n" +
    ... 생략 ...

     

    • 그러면 Flutter Client에서 Flutter Server의 add-user API를 사용하여 회원가입 정보를 입력한 후 사용자를 추가하는 로직을 구현해 보겠습니다.
    • 이번에는 Front End 기능의 예로 회원가입을 위한 UI(User Interface) 화면을 Flutter Client에 만들겠습니다.
    • 아래의 코드에서 보듯이 회원가입을 위한 SignupPage Widget 화면 구성은 매우 평범하고, Flutter Server의 add-user API를 호출하는 코드는 로그인 기능을 구현할때 개발했던 loginCheck() 함수를 복사한 후 필요한 곳의 로직을 수정하여 구현하였습니다. 핵심 로직이 아닌 데이터 검증 등의 기능은 코드의 이해도 향상을 위하여 구현하지 않았습니다. 필요시 각자 구현하여 사용바랍니다.
    // signup_page.dart 파일 
    import 'package:flutter/material.dart';
    import 'package:http/http.dart' as http;
    
    import "main.dart";
    import "login_page.dart";
    
    class SignupPage extends StatefulWidget {
      const SignupPage({super.key});
    
      @override
      State<SignupPage> createState() => _SignupPageState();
    }
    
    class _SignupPageState extends State<SignupPage> with SingleTickerProviderStateMixin  {
      TextEditingController userIdController = TextEditingController();
      TextEditingController passwordController = TextEditingController();
      TextEditingController passwordConfirmController = TextEditingController();
      TextEditingController nameController = TextEditingController();
    
      addUser(String userId, String password, String name) async {
        String serverUri = "http://10.0.2.2:8087/add-user";  // URL 변경
    
        var response = await http.post(
          Uri.parse(serverUri),
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          body: {
            'userId': userId,
            'password': password,
            'name': name,               // Data로 name을 추가로 넘겨줌
          },
        );
    
        print(response.body);
    
        if (response.statusCode == 200) {
          var addUserStatus = response.body;
          if (addUserStatus == 'success') {   // Flutter 서버에서 "true" 대신 "success" 반환
            // 회원가입에 성공하면 자동으로 로그인되게 구현
            Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const MyHomePage(title: 'Flutter Demo Home Page')));
          } else {
            showAlertDialog("사용자 추가 실패", "Flutter Server 관리자에게 문의하세요.");
          }
        } else {
          showAlertDialog("서버 오류", "Flutter Server의 정상 동작 여부를 점검하세요.(${response.statusCode})");
        }
      }
    
      showAlertDialog(title,message) {
        showDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                title: Text(title),
                content: Text(message),
                actions: [
                  TextButton(
                    onPressed: () {
                      Navigator.of(context).pop();
                    },
                    child: Text("Ok"),
                  )
                ],
              );
            }
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text("회원가입"),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Container(
                  height: 300,
                  child: Column(
                    children: [
                      Container(
                        width: 200,
                        child: TextField(
                          controller: userIdController,
                          maxLines: 1,                          // 최대 라인수를 1줄로 제한
                          decoration: InputDecoration(
                              labelText: "아이디",
                              border: OutlineInputBorder(),
                              hintText: "8자 이상 입력해 주세요."  // 안내 문구 추가
                          ),
                        ),
                      ),
                      Container(
                        width: 200,
                        child: TextField(
                          controller: passwordController,
                          obscureText: true,
                          maxLines: 1,                          // 최대 라인수를 1줄로 제한
                          decoration: InputDecoration(
                              labelText: "비밀번호",
                              border: OutlineInputBorder(),
                              hintText: "8자 이상 입력해 주세요."  // 안내 문구 추가
                          ),
                        ),
                      ),
                      Container(
                        width: 200,
                        child: TextField(
                          controller: passwordConfirmController,
                          obscureText: true,
                          maxLines: 1,                          // 최대 라인수를 1줄로 제한
                          decoration: InputDecoration(
                            labelText: "비밀번호확인",
                            border: OutlineInputBorder(),
                          ),
                        ),
                      ),
                      Container(
                        width: 200,
                        child: TextField(
                          controller: nameController,
                          maxLines: 1,                          // 최대 라인수를 1줄로 제한
                          decoration: InputDecoration(
                            labelText: "이름",
                            border: OutlineInputBorder(),
                          ),
                        ),
                      ),
                      SizedBox(height: 20),
                      Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          TextButton(
                              onPressed: () {
                                Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => LoginPage()));
                              },
                              child: Text("취소")
                          ),
                          TextButton(
                              onPressed: () { // 회원가입 클릭시 Server의 add-user API 호출
                                addUser(userIdController.text,passwordController.text,nameController.text);
                              },
                              child: Text("회원가입")
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }

     

    • 회원가입 화면을 만들었으니 로그인 화면에서 회원가입을 클릭하면 회원가입으로 이동하는 로직을 구현하겠습니다.
    // LoginPage Widget
    ... 생략 ...
      TextButton(
          onPressed: () {
            Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => SignupPage()));  // 회원가입 페이지로 이동
          },
          child: Text("회원가입")
      ),
    ... 생략 ...

     

    • 그럼 회원가입 기능을 사용하여 회원가입을 해 보겠습니다. 이뮬레이터는 한글 추가 작업을 하지 않으면 한글을 사용할 수 없어 이름을 영문으로 입력하겠습니다. 이뮬레이터에 한글을 추가하고 싶으면 노션의 “Flutter FAQ와 Tips”에서 “안드로이드 에뮬레이터에 한글 추가”를 참조하기 바랍니다.
    • 회원가입이 성공하면 자동으로 로긴되어 초기화면이 나타납니다.

     

     

    • 회원가입이 Flutter Server의 DBMS에 어떤 영향을 미쳤는지 아래와 같은 SQL 문장을 Oracle SQL Developer에서 실행시킵시다.
    -- Flutter 사용자 테이블을 조회
    SELECT * FROM flutter_user;

     

    • 그러면 회원가입한 사용자의 정보가 DBMS의 테이블에 추가된 것을 확인할 수 있습니다

     

    • 사용자를 추가하는 Flutter Server의 add-user API를 개발하였으니, 로그인시 사용자 로그인을 Check하는 login-check API를 test 사용자로 하드코딩해 놓은 것에서 DBMS에 연결하여 확인하도록 수정하겠습니다.
    // Flutter Server의 UserController 클래스
    package com.example.flutter_server;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    
    import java.util.HashMap;
    import java.util.Map;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import java.sql.Connection;
    import java.sql.PreparedStatement;
    import java.sql.SQLException;
    
    @Controller
    @ResponseBody
    public class UserController {
        @Autowired
        DBConnection dbConnection;
    
        @PostMapping("/add-user")
        public String addUser(@RequestParam("userId") String userId, @RequestParam("password") String password, @RequestParam("name") String name) {
            PreparedStatement pstmt;
    
            try {
                Connection connection = dbConnection.getConnection();
    
                String sql = "INSERT INTO flutter_user (id, password, name) VALUES (?, ?, ?)";
                pstmt = connection.prepareStatement(sql);
    
                pstmt.setString(1, userId);
                pstmt.setString(2, password);
                pstmt.setString(3, name);
    
                int rowsInserted = pstmt.executeUpdate();
                if (rowsInserted > 0) {
                    System.out.println("사용자 데이터가 성공적으로 추가되었습니다.");
                    return "success";
                } else {
                    System.out.println("사용자 데이터 추가시 오류가 발생하였습니다.");
                    return "failure";
                }
            } catch (SQLException | ClassNotFoundException e) {
                System.out.println("사용자 데이터 추가시 DBMS 오류가 발생하였습니다.");
                return "failure";
            }
        }
    
        // ID와 Password가 일치하는 사용자가 있는지 확인하는 함수 추가
        private boolean matchedUser(String userId, String password) {
            PreparedStatement pstmt;
    
            try {
                Connection connection = dbConnection.getConnection();
    
                // 사실상 SQL 문장 하나만 차이나는 것과 같음 (INSERT 문장이 SELECT 문장으로 변경됨)
                String sql = "SELECT * FROM flutter_user WHERE id = ? AND password = ?";
                pstmt = connection.prepareStatement(sql);
    
                pstmt.setString(1, userId);
                pstmt.setString(2, password);
    
                int rowsSelected = pstmt.executeUpdate();
                if (rowsSelected > 0) {
                    System.out.println("사용자의 ID와 Password가 일치합니다.");
                    return true;
                } else {
                    System.out.println("ID가 존재하지 않거나 Password가 일치하지 않습니다.");
                    return false;
                }
            } catch (SQLException | ClassNotFoundException e) {
                System.out.println("사용자 데이터 조회시 DBMS 오류가 발생하였습니다.");
                return false;
            }
        }
    
        @PostMapping("/login-check")
        public Map<String, Boolean> loginCheck(@RequestParam("userId") String userId, @RequestParam("password") String password) {
            //boolean loginSuccess = userId.equals("test") && password.equals("test");
            boolean loginSuccess = matchedUser(userId, password);     // DBMS의 데이터를 사용하도록 수정
            
            Map<String, Boolean> response = new HashMap<>();
            response.put("loginSuccess", loginSuccess);
    
            return response;
        }
    
        @GetMapping("/users")    // http://localhost:8087/users가 접속 Url
        public String users() {
            return "[\n" +
                    "    {\n" +
                    "        \"id\": 320811,\n" +
                    "        \"name\": \"안용제\",\n" +
                    "        \"username\": \"yongjaeahn\",\n" +
                    "        \"email\": \"yongjaeahn@naver.com\",\n" +
    ... 생략 ...

     

    • 이제 회원가입 절차를 통하여 Flutter Server의 DBMS에 등록된 사용자 ID와 암호로만 로그인할 수 있게 되었습니다. 회원가입이 성공하면 바로 로긴되게 구현하여 회원가입 후 다시 로그인하는 번거로운 절차를 없앴습니다. → Flutter Server와 Flutter Client를 모두 재기동한 후 테스트합시다.

     

     

    • 이제 로그아웃(Logout) 기능을 만들어 보겠습니다. 아래 코드와 같이 로그아웃 버튼을 MyHomePage Widget에 있는 AppBar의 우측 상단에 만들고 클릭시 로그인 화면으로 이동하게 하는 것으로 충분합니다.
    // main.dart 파일에 있는 MyHomePage Widget
    ... 생략 ...
    class _MyHomePageState extends State<MyHomePage> {
      int _selectedNaviIndex = 0;
    
      _onBottomNavigationItemTapped(index) {
        setState(() {
          _selectedNaviIndex = index;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            backgroundColor: Theme.of(context).colorScheme.inversePrimary,
            title: Text(widget.title),
            actions: [                 // actions로 logout 아이콘이 추가되고
              IconButton(              
                icon: Icon(Icons.logout),
                onPressed: () {        // 클릭하면 Login 페이지로 이동함
                  Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => LoginPage()));
                }
              ),
            ]
          ),
    ... 생략 ...

     

    • 이제 화면 우측 상단에 생겨난 로그아웃 버튼을 눌러 다시 로그인 화면으로 이동하게 되었습니다.

     

     

    • 그런데 로그아웃 후 최근에 로그인한 사용자의 ID가 아니라 test라는 ID가 계속 나타나는 것이 눈에 거슬립니다. 로그인할 때 최근에 로그인한 ID를 저장했다가 로그인 화면에 다시 보여주는 로직을 구현해 보겠습니다. 이런 기능은 스마트폰에 문자열 데이터를 저장했다가 불러오는 방법으로 구현할 수 있습니다.
    • 우선 pubspec.yaml 파일에 shared_preferences 라이브러리를 등록한 후 pub get 메뉴를 클릭합니다.
    # pubspec.yaml
    ... 생략 ...
    dependencies:
      flutter:
        sdk: flutter
    
    
      # The following adds the Cupertino Icons font to your application.
      # Use with the CupertinoIcons class for iOS style icons.
      cupertino_icons: ^1.0.6
      http: ^1.1.0
      shared_preferences: ^2.1.0   # 추가된 라이브러리
    ... 생략 ...

     

    • 그리고 login_page.dart 파일에서 추가된 라이브러리를 import한 후 문자열을 저장하고 가져오는 비동기 함수를 정의하여 호출합니다.
    // login_page.dart
    ... 생략 ...
    import 'package:shared_preferences/shared_preferences.dart'; // library import
    ... 중략 ...
    class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
      TextEditingController userIdController = TextEditingController();
      TextEditingController passwordController = TextEditingController();
    
      double idPasswordOpacity = 0;
    
      var animationController;
      var animation;
    
      // 로그인 ID를 저장하는 함수
      saveUserId(String userId) async {
        SharedPreferences prefs = await SharedPreferences.getInstance();
        await prefs.setString('user_id', userId);
      }
      
      // 저장된 로그인 ID를 불러오는 함수
      loadSavedUserId() async {
        SharedPreferences prefs = await SharedPreferences.getInstance();
        setState(() {
          var userId = prefs.getString('user_id');
          if(userId != null)
            userIdController.text = userId;
        });
      }
      
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
    
        //userIdController.text = "test";    // 하드코딩해 놓은 문자열을
        loadSavedUserId();                   // 저장된 문자열을 가져오는 함수의 호출로 변경
    ... 중략 ...
      loginCheck(String userId, String password) async {
    ... 중략 ...
        if (response.statusCode == 200) {
            var parseJson = jsonDecode(response.body);
            bool loginSuccess = parseJson['loginSuccess'];
    
            if (loginSuccess) {
              saveUserId(userId);        // 로그인 성공시 성공한 User Id 문자열을 저장
    ... 생략 ...

     

    • initState() 함수가 변경되는 등 수정이 크게 발생했으니 Flutter Client를 재시작하여 확인합시다.

     

     

     

    [실습]  자신의 가족을 소개하는 family_intro2 앱을 개선해 보세요.

    • family_intro2 프로젝트에 로그인 기능과 Animation 기능과 회원가입 기능을 추가해 보세요.
    • 사용자 정보와 DBMS 테이블은 백엔드 서버(Back End Server)에 있어야 합니다.

     

     참고 문헌

    [논문]

    • 없음

    [보고서]

    • 없음

    [URL]

    • 없음

     

     문의사항

    [기상학/프로그래밍 언어]

    • sangho.lee.1990@gmail.com

    [해양학/천문학/빅데이터]

    • saimang0804@gmail.com
    • 네이버 블러그 공유하기
    • 네이버 밴드에 공유하기
    • 페이스북 공유하기
    • 카카오스토리 공유하기