토이프로젝트

[토이프로젝트]대출이자계산기 만들어보기 - 4편 : Flutter로 모바일 서비스 개발하기

  • -
반응형

본 포스팅부터 모바일 서비스 개발편이 시작됩니다. 진행을 위해 Flutter가 필수적으로 설치되어 있어야 하니 Flutter 설치 글을 먼저 확인해 주세요.

모바일 개발편부터는 MacOS를 사용합니다.

  • vscode
  • Flutter 3.16.0
  • Dart 3.2.0

4-1. 프로젝트 생성

vscode를 실행하고 프로젝트를 생성해 보겠습니다.

Command + Shift + P 를 눌러 Next Project를 클릭합니다.

 

Application 선택 후 프로젝트를 생성할 경로를 지정합니다.

 

프로젝트명을 입력합니다. 이때 프로젝트명은 카멜케이스가 아닌 스네이크 케이스를 사용해야 합니다.

 

프로젝트가 생성되었습니다. Simulator를 실행하고 Command + Shift + D를 눌러 디버그 실행을 클릭합니다.

 

Simulator는 Xcode 실행 후 Open Developer Tool 탭에서 실행할 수 있습니다.

 

※ iOS 앱 배포를 위해 사전에 TestFlight 설정이 필요합니다.

이 글을 통해 TestFlight 설정 후 다음 내용을 진행해 주세요!

 

4-2. 웹 뷰를 활용해 웹과 모바일 연결하기

Flutter에서는 웹 뷰 라이브러리를 통해 모바일 어플리케이션에 웹 사이트를 뷰어로 추가해 서비스할 수 있습니다.

대표적으로 많이 사용되는 웹 뷰 라이브러리는 2가지가 있습니다.

  • webview_flutter : 경량화된 웹 뷰 기능을 제공하며, 자바스크립트 채널 이용이 가능하며 localhost 연결이 불가능합니다.(테스트시에 실제 도메인을 통해 연결해야 합니다). 
  • flutter_inappwebview : webview_flutter에 비해 기능이 많으며, 자바스크립트 채널 이용이 가능하고, localhost 연결이 가능합니다.

 

자바스크립트 채널은 자바스크립트를 통해 작성한 코드를 앱 - 웹 양방향으로 소통할 수 있게 도와주는 도구입니다.

간단한 웹 뷰 구현시에는 webview_flutter가 유용하기 때문에 우리는 webview_flutter를 이용해보겠습니다. 

여기서부터 Next.js와 Flutter 프로젝트의 코드가 섞여 있습니다. 명령어를 제외한 코드는 확장자로 코드를 구분하시면 됩니다.

  • .tsx : Next.js
  • .dart : Flutter

 

웹 뷰 설정

아래 명령어를 통해 라이브러리를 설치합니다.

flutter pub add webview_flutter

 

웹 뷰는 안드로이드 플랫폼 최소 sdk 버전을 19로 지정해주어야 합니다.

안드로이드 디렉토리 우클릭 후 안드로이드 스튜디오를 실행합니다.

 

앱 수준의 build.gradle(Module: app) 파일 클릭 후 minSdkVersion을 19로 수정합니다. Sync Now를 클릭해 리빌딩합니다.

build.gradle(Module: app)

 

 

환경 변수 추가

연결시킨 웹 사이트 주소를 환경 변수로 등록해보겠습니다.

아래 명령어를 입력해 flutter_dotenv를 설치합니다. flutter_dotenv 패키지는 .env 파일을 관리하고 사용할 수 있게 도와줍니다.

flutter pub add flutter_dotenv

 

프로젝트 수준에 .env 파일을 추가합니다.

 

 

웹 사이트 주소를 등록하겠습니다.

.env

BASE_URL=https://calculator.codedream.co.kr

 

마지막으로 env 파일을 추가하기 위해서 pubspec.yaml 파일을 열어 assets 부분의 주석을 해제하고, .env를 등록합니다.,

 

flutter는 lib/main.dart 파일로 시작됩니다. 

main.dart에서 환경 변수를 읽어올 수 있도록 dotenv.load를 추가합니다.

 

lib/main.dart

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 구성 파일 로드
  await dotenv.load(fileName: ".env");

  runApp(const MyApp());
}

 

 

웹 뷰 위젯 추가

flutter는 lib 디렉토리를 루트 디렉토리로 사용합니다.

widget 디렉토리를 추가하고 webview.dart 파일을 추가합니다.

 

 

lib/widget/webview.dart

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:webview_flutter/webview_flutter.dart';

class MainWebView extends StatefulWidget {
  const MainWebView({
    Key? key,
  }) : super(key: key);

  @override
  State<MainWebView> createState() => _MainWebViewState();
}

class _MainWebViewState extends State<MainWebView> {
  late WebViewController _controller = WebViewController();

  @override
  void initState() {
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onProgress: (int progress) {},
          onPageStarted: (String url) {},
          onPageFinished: (String url) async {},
          onWebResourceError: (WebResourceError error) {},
          onNavigationRequest: (NavigationRequest request) {
            return NavigationDecision.navigate;
          },
        ),
      )
      ..loadRequest(Uri.parse('${dotenv.env['BASE_URL']}'));

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return WebViewWidget(controller: _controller);
  }
}

 

initState() 안에서 WebViewController() 함수를 통해 웹 뷰를 생성합니다.

initState() 함수는 위젯 초기화시 최초 1회 호출됩니다. WebViewContoller의 loadRequest() 값으로 웹 사이트 주소를 입력해 연결할 웹 사이트를 설정합니다.

생성한 웹 뷰 위젯을 main에서 실행해보겠습니다.

 

lib/main.dart

import 'package:app_my_calculator/widget/webview.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 구성 파일 로드
  await dotenv.load(fileName: ".env");

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MainWebView(),
    );
  }
}

 

프로젝트를 실행합니다.

지금까지 만든 웹 사이트가 앱 안에 조회됩니다.

 

 

 

4-3. 자바스크립트 채널 사용해보기

웹 뷰를 이용해 웹 사이트를 앱으로 띄우는 경우 개발이 간편하고 수정 사항을 앱에 바로 적용할 수 있다는 장점이 있습니다. 하지만 네이티브 앱에 비해 사용감이 매끄럽지 않은 단점이 존재합니다.

예를 들면 현재 프로젝트에서 값을 입력하지 않은 경우 계산하기를 클릭하게 되면 alert 알림창을 확인할 수 있는데요. 이걸 앱에서 보면 어떻게 될까요?

좌측부터 웹 / android / ios

 

플랫폼에 따라 팝업이 다르게 출력됩니다. Android는 alert가 작동하지만 iOS는 작동하지 않습니다. webview_flutter에서 지원되지 않기 때문에 따로 코드를 추가해주어야 합니다. 또 앞서 만든 카카오톡 공유하기도 카카오톡 앱이 실행되는 매끄러운 연결이 불가능합니다. 이를 위해서 자바스크립트 채널을 이용해 웹 -> 앱으로  파라미터들을 전달해주고, 앱에서의 작업을 따로 해줄 것입니다.

결과적으로 자바스크립트 채널을 사용하는 것은 모바일 어플리케이션에 웹 콘텐츠가 잘 통합되도록 하기 위함입니다.

 

채널 추가

알림, 공유하기를 연결할 두가지의 채널을 추가합니다.

 

lib/widget/webview.dart

@override
  void initState() {
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel('shareChannel',
          onMessageReceived: (JavaScriptMessage javaScriptMessage) async {
        var data = jsonDecode(javaScriptMessage.message);

        print(data);
      })
      ..addJavaScriptChannel('alertChannel',
          onMessageReceived: (JavaScriptMessage javaScriptMessage) {
        var data = jsonDecode(javaScriptMessage.message);

        print(data);
      })
      ..setNavigationDelegate(
        NavigationDelegate(
          onProgress: (int progress) {},
          onPageStarted: (String url) {},
          onPageFinished: (String url) async {},
          onWebResourceError: (WebResourceError error) {},
          onNavigationRequest: (NavigationRequest request) {
            return NavigationDecision.navigate;
          },
        ),
      )
      ..loadRequest(Uri.parse('${dotenv.env['BASE_URL']}'));

    super.initState();
  }

 

addJavaScriptChannel를 통해 shareChannel, alertChannel 두 채널을 생성(각 채널명은 웹과 동일하게 맞추면 됩니다)했고 JavaScriptMessage를 통해 파라미터를 전달받게 됩니다. jsonDecode를 추가해 map 형태로 파라미터를 전달받아 보겠습니다.

 

 

웹으로 돌아가보겠습니다.

수정할 곳은 공유하기 컴포넌트와 계산기 컴포넌트입니다.

먼저 채널 함수를 사용하기 위해  layout 파일을 열어 Window 인터페이스에 채널명을 추가합니다.

 

app/layout.tsx

declare global {
  interface Window {
    Kakao: any;
    shareChannel: any;
    alertChannel: any;
  }
}

 

 

app/components/share/share.tsx

"use client";

import { useEffect } from "react";
import Image from "next/image";
import styles from "./share.module.scss";

type Props = {
  paymentMethod: string;
  amount: string;
  period: string;
  interest: string;
};

export default function MyShare({
  paymentMethod,
  amount,
  period,
  interest,
}: Props) {
  const onClickWebView = async () => {
    if (window.shareChannel) {
      window.shareChannel.postMessage(
        JSON.stringify({
          paymentMethod: paymentMethod,
          amount: amount,
          period: period,
          interest: interest,
        })
      );
    }
  };

  useEffect(() => {
    if (!window.shareChannel) {
      if (window.Kakao) {
        const { Kakao } = window;

        if (!Kakao.isInitialized()) {
          Kakao.init(`${process.env.NEXT_PUBLIC_KAKAO_KEY}`);
        }

        const redirectUrl = `${window.location.origin}/share?paymentMethod=${paymentMethod}&amount=${amount}&period=${period}&interest=${interest}`;
        let methodName = "";

        switch (paymentMethod) {
          case "1":
            methodName = "원리금 균등 상환";
            break;
          case "2":
            methodName = "원금 균등 상환";
            break;
          case "3":
            methodName = "만기일시 상환";
            break;
        }

        Kakao.Share.createDefaultButton({
          container: "#kakao-link-btn",
          objectType: "feed",
          content: {
            title: "my-calculator",
            description: "계산 결과를 확인해 보세요!",
            imageUrl: `${window.location.origin}/favicon.png`,
            link: {
              mobileWebUrl: redirectUrl,
              webUrl: redirectUrl,
            },
          },
          buttons: [
            {
              title: "계산 결과 확인하기",
              link: {
                webUrl: redirectUrl,
                mobileWebUrl: redirectUrl,
              },
            },
          ],
        });
      }
    }
  }, []);

  return (
    <div className={styles.share_image}>
      <Image
        id="kakao-link-btn"
        width={44}
        height={44}
        src={"/kakaotalk_sharing_btn_medium.png"}
        alt="카카오톡 공유 이미지"
        onClick={() => onClickWebView()}
      />
    </div>
  );
}

 

useEffect에서 실행되는 카카오 공유하기 버튼의 초기화를 웹에서만 실행되도록 합니다. window.shareChannel이 true가 되면 접속 상태가 앱임을 의미합니다. 접속 상태가 앱인 경우에는 onClickWebView() 함수가 실행되며 postMessage 함수를 통해 추가한 파라미터들이 전달됩니다.

 

app/components/form/form.tsx

"use client";

import { FormProvider, useForm } from "react-hook-form";
import MyInput from "../input/input";
import MyButton from "../button/button";
import MyRadio from "../radio/radio";
import Modal from "../modal/modal";
import styles from "./form.module.scss";
import { useState } from "react";
import MyGrid, { GridType } from "../grid/grid";

const OPTIONS = [
  {
    value: "1",
    name: "원리금 균등",
  },
  {
    value: "2",
    name: "원금 균등",
  },
  {
    value: "3",
    name: "만기 일시",
  },
];

export default function Calcurator() {
  const [toggleModal, setToggleModal] = useState(false);
  const [params, setParams] = useState<GridType>();
  const methods = useForm({
    mode: "onChange",
    defaultValues: {},
  });

  const onSubmit = async (form: any) => {
    console.log(form);

    setParams({
      paymentMethod: form.paymentMethod,
      amount: form.amount,
      period: form.period,
      interest: form.interest,
    });

    setToggleModal(!toggleModal);
  };

  const onInvalid = (errors: any) => {
    if (errors) {
      if (window.alertChannel) {
        let message = "";
        if (errors && errors.amount) {
          message = errors.amount.message;
        } else if (errors && errors.period) {
          message = errors.period.message;
        } else if (errors && errors.interest) {
          message = errors.interest.message;
        }

        window.alertChannel.postMessage(
          JSON.stringify({
            message: message,
          })
        );
      } else {
        if (errors && errors.amount) {
          alert(errors.amount.message);
          return;
        } else if (errors && errors.period) {
          alert(errors.period.message);
          return;
        } else if (errors && errors.interest) {
          alert(errors.interest.message);
          return;
        }
      }
    }
  };

  return (
    <FormProvider {...methods}>
      <MyRadio id={"paymentMethod"} options={OPTIONS} label={"상환방식"} />
      <MyInput id={"amount"} label={"대출원금"} require={true} unit={"원"} />
      <MyInput id={"period"} label={"대출기간"} require={true} unit={"년"} />
      <MyInput id={"interest"} label={"연이자"} require={true} unit={"%"} />

      <MyButton
        text={"계산하기"}
        onClick={methods.handleSubmit(onSubmit, onInvalid)}
      />

      {toggleModal && (
        <Modal setToggleModal={setToggleModal}>
          <article className={styles.popup}>
            <MyGrid params={params} />
          </article>
        </Modal>
      )}
    </FormProvider>
  );
}

 

같은 방식으로 onInvalid 함수에 window.alertChannel이 있는 경우 알람 문구를 파라미터로 전달합니다.

다시 앱으로 돌아와 print에 파라미터가 조회되는지 확인해보겠습니다.

 

웹 -> 앱으로 파라미터가 잘 전달되는 것을 확인할 수 있습니다. 다음 포스팅에서는 전달받은 파라미터를 통해 웹의 처리 방식들을 앱에서 구현해보도록 하겠습니다.

전체 코드는 GitHub에서 확인하실 수 있습니다.

 

참고문서
 

webview_flutter | Flutter package

A Flutter plugin that provides a WebView widget on Android and iOS.

pub.dev

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

반응형
Contents

포스팅 주소를 복사했습니다.

이 글이 도움이 되었다면 공감 부탁드립니다.