본문 바로가기

개발 이야기/Spring

[spring/ flutter] flutter를 활용해 Spring Boot 서버 화면을 웹뷰로 띄우기 [2/2]

우선 저번 포스팅을 안보고 오신분들중 아직 설치가 안되어있으시면 저번 글을 보고 오시면됩니다.

https://kjunh972.tistory.com/135

 

[spring/ flutter] flutter를 활용해 Spring Boot 서버 화면을 웹뷰로 띄우기 [1/2]

Spring Boot로 반응형 웹페이지를 만들었고 모바일 앱으로도 웹을 띄우고 싶었다.1. 개발 환경 세팅먼저, Xcode를 먼저 설치하자1. Xcode 설치https://developer.apple.com/download/all/ 로그인 - Apple idmsa.apple.com

kjunh972.tistory.com

저번 글에는 flutter를 활용해 Spring Boot 서버 화면을 웹뷰로 띄우기 위해 이것저것 설정을 해보았고 이번엔 프로젝트를 생성해 웹뷰를 만들고 실행까지 해보겠습니다.

1. 터미널을 통해 XCode 실행

Flutter 프로젝트 폴더로 이동해 아래와 같은 명령어를 입력합니다.

open ios/Runner.xcworkspace

XCode 실행화면

이렇게 Xcode가 실행됩니다.
먼저 Team 칸에서 애플 계정을 로그인 한 후에 Bundle Identifier 에서 프로젝트 도메인 명을 입력합니다.
그 후에 좌측 상단 화살표를 눌러서 빌드를 진행합니다.

1. 빌드 에러 1

빌드를 하던 도중 KeyChain 암호를 입력하라는 창이 뜬다. 여기서 많이 당황 할것이다. KeyChain 암호를 설정한적이 없기 때문입니다.
필자도 여기서 오류가 많이 발생했습니다.

아래 명령어를 입력하면

security list-keychains

위에 명령어는 현재 사용 가능한 키체인의 목록을 표시합니다.

"/Users/kjunh972/Library/Keychains/login.keychain-db"
"/Library/Keychains/System.keychain"
  • login.keychain-db: 사용자 계정에 연결된 기본 키체인으로, 일반적으로 사용자 로그인 암호로 잠금이 해제됩니다.
  • System.keychain: 시스템 키체인으로, 시스템 인증서 및 기타 시스템 관련 항목을 저장합니다.

그 다음 아래 명령어를 입력합니다.

unlock-keychain ~/Library/Keychains/login.keychain

위 명령어는 키체인에 저장된 항목에 접근하기 위해 특정 키체인의 잠금을 해제합니다.

맥북 비밀번호를 입력하면 잠금 해제 할 수 있습니다.
암호를 잘못 입력하면 다시 시도해야 하며 키체인을 열어야 그 안에 저장된 비밀번호, 인증서 등을 사용할 수 있습니다.

그 다음 아래 명령어를 입력합니다.

security set-keychain-password

security set-keychain-password

위 명령어를 입력하면 키체인의 암호를 변경할 수 있습니다.

 

  • Old Password: 현재 키체인의 암호를 입력해야 합니다.
  • New Password 및 Retype New Password: 새 암호를 두 번 입력하여 확인합니다.

그 다음 Flutter와 iOS의 캐시 및 설정 파일을 초기화합니다.

# 1. 프로젝트 완전 클린
flutter clean

# 2. iOS 관련 파일들 정리
cd ios
pod deintegrate
pod cache clean --all
rm -rf Pods
rm -rf Podfile.lock

# 3. 프로비저닝 프로파일 삭제 (필요한 경우)
rm -rf ~/Library/MobileDevice/Provisioning\ Profiles/

# 4. 프로젝트 루트로 돌아가서 의존성 다시 받기
cd ..
flutter pub get

# 5. iOS 설정
cd ios
pod install

# 6. Xcode 프로젝트 열기
cd ..
open Runner.xcworkspace

이렇게 하면 Key Chain 암호를 입력하라는 창이 뜨면 위와 같이 해결 할 수 있습니다.

2. 빌드 에러 2

Framework 'Pods_Runner' not found

위에 같은 빌드 에러가 발생 하면 원인은 CocoaPods가 Flutter 플러그인 프로젝트의 종속성을 올바르게 설치하지 못했을 때 발생합니다.
그러면 프로젝트를 클린하면 해결된다. 아래와 같이 명령어를 입력하면 됩니다.

# 1. 프로젝트의 빌드 캐시와 임시 파일들을 모두 삭제
flutter clean

# 2. pubspec.yaml에 명시된 모든 패키지 의존성을 다시 다운로드
flutter pub get

# 3. iOS 디렉토리로 이동
cd ios

# 4. CocoaPods 의존성 설치 (iOS 네이티브 라이브러리 설치)
pod install

# 5. CocoaPods 의존성 최신 버전으로 업데이트
pod update

# 6. Xcode 프로젝트 열기
cd ..
open Runner.xcworkspace

 

2. 신뢰하지 않는 개발자 승인

XCode를 통해 빌드까지 성공했다면 핸드폰에서 신뢰하지 않는 개발자 승인을 해야 Flutter로 프로젝트 실행할 때 핸드폰에서 앱이 정상적으로 실행합니다.

1. 신뢰하지 않는 개발자 승인 창이 떴다면

신뢰하지 않는 개발자 승인 창

이렇게 현재는 승인이 되지않아 오류 창이 휴대폰에 뜨게 됩니다.

2. 휴대폰에서 개발자 모드 활성화

설정 - 개인정보 보호 및 보안에 들어가 개발자 모드 할성화 하면 아래처럼 표시됩니다.

3. 신뢰하지 않는 개발자 승인하기

설정 - 일반 - VPN 및 기기 관리에 들어가면 위에 이미지처럼 '신뢰할 수 없음'이 표시됩니다.
저기를 클릭하면 아래 이미지처럼 뜨는데

'kjunh972@어쩌구' 신뢰 버튼을 누르면 됩니다.

그러면 여기서 한번 더 허용이라고 누르면

위에 2개 이미지처럼 정상적으로 신뢰하는 개발자로 바뀌고 허용이 된것을 확인할 수 있습니다.

그러면 이제 모든 준비는 다 끝났습니다. 안드로이드 스튜디오로 해당 Flutter 프로젝트 폴더를 열고 실행만 하면 됩니다.

3. Flutter로 웹뷰 실행

우선 안드로이드 스튜디오를 실행하여 프로젝트 폴더를 실행합니다.

위와 같이 프로젝트 파일이 열립니다. 일단 저는 코드를 다 완성된 상태이기 때문에 이렇게 작성이 되어있지만 새로 프로젝트 생성해서 열면 위 처럼 코드가 작성되어있진 않습니다.

1. pubspec.yaml
프로젝트 의존성 관리 파일

dependencies:
  flutter:
    sdk: flutter
  flutter_inappwebview: ^6.0.0
  permission_handler: ^10.0.0
  flutter_spinkit: ^5.2.0

dependencies 라고 써 있는 곳을 찾아서 위와 같이 의존성을 수정하면 됩니다.

1-1. flutter

  • Flutter SDK를 사용한다는 의미입니다.
  • Flutter 앱의 기본 구성 요소를 제공합니다.

1-2. flutter_inappwebview: ^6.0.0

  • InAppWebView 플러그인입니다.
  • 앱 내에서 웹 페이지를 열고 조작할 수 있는 고급 웹뷰 기능을 제공합니다.
  • 브라우저와 유사한 기능을 Flutter 앱에 추가할 수 있습니다.

1-3. permission_handler: ^10.0.0

  • 앱에서 사용자 권한(예: 카메라, 위치, 저장소 접근 등)을 요청하고 관리할 수 있는 패키지입니다.
  • 플랫폼별 권한 처리 로직을 간단히 구현할 수 있게 도와줍니다.

1-4. flutter_spinkit: ^5.2.0

  • 로딩 애니메이션을 위한 패키지입니다.
  • 다양한 스타일의 로딩 위젯을 간단히 사용할 수 있도록 제공됩니다.

 

2. lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'dart:io';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'javascript/webview_scripts.dart';

// 앱 시작점 - 필수 초기화 수행
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // iOS 권한 요청 처리
  if (Platform.isIOS) {
    await Permission.camera.request();
    await Permission.microphone.request();
  }

  runApp(const MyApp());
}

// 앱의 기본 MaterialApp 설정
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '강의실 예약 시스템',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

// 메인 홈페이지 StatefulWidget
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

// 홈페이지 상태 관리 클래스
class _MyHomePageState extends State<MyHomePage> {
  // WebView 컨트롤러 및 URL 설정
  final GlobalKey webViewKey = GlobalKey();
  InAppWebViewController? webViewController;
  String url = 'http://192.168.45.134:8055/';

  // 로딩 상태 관리 변수
  bool isLoading = true;
  double _loadingOpacity = 1.0;

  // 로딩 인디케이터 UI 구성
  Widget _buildLoadingIndicator() {
    return AnimatedOpacity(
      opacity: _loadingOpacity,
      duration: const Duration(milliseconds: 300),
      child: Container(
        width: double.infinity,
        height: double.infinity,
        color: Colors.white,
        child: const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SpinKitFadingCircle(
                color: Colors.blue,
                size: 50.0,
              ),
              SizedBox(height: 20),
              Text(
                '로딩중...',
                style: TextStyle(
                  fontSize: 16,
                  color: Colors.black54,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    // 화면 크기에 따른 패딩 계산
    final screenHeight = MediaQuery.of(context).size.height;
    final topPadding = screenHeight * 0.06;

    return PopScope(
      canPop: false,
      // 뒤로가기 버튼 처리 로직
      onPopInvoked: (didPop) async {
        if (webViewController != null) {
          if (await webViewController!.canGoBack()) {
            if (!isLoading) {
              // 사이드바 닫기 처리
              await webViewController!.evaluateJavascript(source: """
                (function() {
                  const mobileSidebar = document.getElementById('menu-mobile-sidebar');
                  const overlay = document.getElementById('menu-overlay');
                  const mobileToggle = document.getElementById('menu-mobile-toggle');
                  
                  if (mobileSidebar) {
                    mobileSidebar.classList.remove('active');
                  }
                  if (overlay) {
                    overlay.classList.remove('active');
                  }
                  if (mobileToggle) {
                    mobileToggle.style.transform = 'rotate(0deg)';
                  }
                  
                  localStorage.removeItem('openSidebar');
                })();
              """);

              // 페이지 뒤로가기 실행
              await Future.delayed(const Duration(milliseconds: 100));
              await webViewController!.goBack();

              // 사이드바 상태 재확인
              await Future.delayed(const Duration(milliseconds: 200));
              await webViewController!.evaluateJavascript(source: """
                (function() {
                  const mobileSidebar = document.getElementById('menu-mobile-sidebar');
                  const overlay = document.getElementById('menu-overlay');
                  if (mobileSidebar) {
                    mobileSidebar.classList.remove('active');
                  }
                  if (overlay) {
                    overlay.classList.remove('active');
                  }
                })();
              """);

              // 페이지 새로고침
              await Future.delayed(const Duration(milliseconds: 300));
              await webViewController!.reload();
            }
          } else {
            Navigator.of(context).pop();
          }
        }
      },
      child: Scaffold(
        body: Stack(
          children: [
            // WebView 구성
            Padding(
              padding: EdgeInsets.only(top: topPadding),
              child: InAppWebView(
                key: webViewKey,
                initialUrlRequest: URLRequest(
                  url: WebUri(url),
                ),
                // WebView 기본 설정 부분 수정
                initialSettings: InAppWebViewSettings(
                  javaScriptEnabled: true,
                  mediaPlaybackRequiresUserGesture: false,
                  allowsInlineMediaPlayback: true,
                  mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW,
                  useWideViewPort: false,
                  // 텍스트 선택 시 자동 확대 방지 설정 추가
                  disableContextMenu: false,
                  supportZoom: false,
                  builtInZoomControls: false,
                  displayZoomControls: false,
                ),
                // WebView 생성 완료 콜백
                onWebViewCreated: (controller) {
                  webViewController = controller;
                  setState(() => isLoading = true);
                },
                // 페이지 로딩 시작 콜백
                onLoadStart: (controller, url) {
                  setState(() {
                    isLoading = true;
                    _loadingOpacity = 1.0;
                  });
                },
                // 페이지 로딩 완료 콜백
                onLoadStop: (controller, url) async {
                  setState(() {
                    isLoading = false;
                    _loadingOpacity = 0.0;
                  });

                  // 세션 체크 스크립트 실행
                  await controller.evaluateJavascript(
                    source: WebViewScripts.sessionCheckScript,
                  );

                  // 학교 검색 기능 스크립트 실행
                  await controller.evaluateJavascript(
                    source: WebViewScripts.schoolSearchScript,
                  );

                  // 네비게이션 스크립트 실행
                  await controller.evaluateJavascript(
                    source: WebViewScripts.navigationScript,
                  );

                  // 사이드바 초기 상태 설정
                  await controller.evaluateJavascript(source: """
                    (function() {
                      const mobileSidebar = document.getElementById('menu-mobile-sidebar');
                      const overlay = document.getElementById('menu-overlay');
                      if (mobileSidebar && mobileSidebar.classList.contains('active')) {
                        mobileSidebar.classList.remove('active');
                      }
                      if (overlay && overlay.classList.contains('active')) {
                        overlay.classList.remove('active');
                      }
                    })();
                  """);
                },
                // 에러 발생 처리 콜백
                onReceivedError: (controller, request, error) {
                  if (error.type == WebResourceErrorType.CANCELLED) {
                    return;
                  }
                  setState(() => isLoading = false);

                  // 에러 메시지 표시
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      content: Text('오류: ${error.description}'),
                      duration: const Duration(seconds: 3),
                      action: SnackBarAction(
                        label: '다시 시도',
                        onPressed: () {
                          webViewController?.reload();
                        },
                      ),
                    ),
                  );
                },
              ),
            ),
            // 로딩 인디케이터 표시
            if (isLoading) _buildLoadingIndicator(),
          ],
        ),
      ),
    );
  }
}

lib/main.dart를 위에 있는 코드로 교체 하시면 

String url = 'http://192.168.45.134:8055/';

위에 있는 주소를 모바일로 웹뷰를 띄울수 있습니다.

3. lib/javascript/webview_scripts.dart

webview_scripts.dart 파일을 새로 생성하여 아래와 같이 코드를 복붙하면 된다.

class WebViewScripts {
  static String get sessionCheckScript => '''
    (function() {
      const errorElement = document.querySelector('.error');
      if (errorElement && errorElement.textContent.includes('세션이 만료되었습니다')) {
        Swal.fire({
          icon: 'error',
          title: '알림',
          text: errorElement.textContent,
          confirmButtonText: '확인',
          allowOutsideClick: false,
          focusConfirm: true,
          customClass: {
            popup: 'timetable-popup',
            confirmButton: 'timetable-confirm'
          },
          didOpen: () => {
            const confirmButton = Swal.getConfirmButton();
            if (confirmButton) {
              confirmButton.focus();
            }
          }
        }).then((result) => {
          if (result.isConfirmed) {
            const mobileSidebar = document.getElementById('menu-mobile-sidebar');
            const overlay = document.getElementById('menu-overlay');
            const mobileToggle = document.getElementById('menu-mobile-toggle');
            
            if (mobileSidebar) mobileSidebar.classList.add('active');
            if (overlay) overlay.classList.add('active');
            if (mobileToggle) mobileToggle.classList.add('active');
          }
        });
        return true;
      }
      return false;
    })();
  ''';

  static String get navigationScript => '''
    (function() {
      // 기존 웹사이트의 이벤트 핸들러가 동작하게.
      const links = document.querySelectorAll('a');
      links.forEach(link => {
        link.addEventListener('click', (e) => {
          // 기본 동작 허용
          return true;
        });
      });
    })();
  ''';

  static String get schoolSearchScript => '''
    (function() {
      function enhanceSchoolSearch() {
        const schoolInput = document.getElementById('schoolInput');
        const schoolList = document.getElementById('schoolList');
        
        if (!schoolInput || !schoolList) return;
        
        const parentElement = schoolInput.parentElement;
        if (!parentElement) return;
        
        parentElement.style.position = 'relative';
        
        const schoolListContainer = document.createElement('div');
        schoolListContainer.id = 'schoolListContainer';
        schoolListContainer.style.cssText = 'position:absolute;width:100%;max-height:200px;overflow-y:auto;background:white;border:1px solid #ccc;border-radius:8px;margin-top:2px;top:100%;left:0;display:none;z-index:9999;box-shadow:0 2px 4px rgba(0,0,0,0.1)';
        
        const schools = ['가톨릭대학교', '고려대학교', '동양미래대학교', '서울대학교', 
                      '성공회대학교', '연세대학교', '유한대학교', '중앙대학교'];
        
        const fragment = document.createDocumentFragment();
        schools.forEach(school => {
          const item = document.createElement('div');
          item.textContent = school;
          item.style.cssText = 'padding:12px 16px;cursor:pointer;border-bottom:1px solid #eee;font-size:14px';
          
          item.addEventListener('mouseover', () => item.style.backgroundColor = '#f5f5f5');
          item.addEventListener('mouseout', () => item.style.backgroundColor = 'white');
          item.addEventListener('click', () => {
            schoolInput.value = school;
            schoolListContainer.style.display = 'none';
          });
          
          fragment.appendChild(item);
        });
        
        schoolListContainer.appendChild(fragment);
        schoolList.style.display = 'none';
        parentElement.appendChild(schoolListContainer);
        
        const showList = () => schoolListContainer.style.display = 'block';
        const hideList = (e) => {
          if (e.target !== schoolInput && e.target !== schoolListContainer) {
            schoolListContainer.style.display = 'none';
          }
        };
        
        schoolInput.addEventListener('click', (e) => {
          e.preventDefault();
          showList();
        });
        
        schoolInput.addEventListener('input', () => {
          const filter = schoolInput.value.toUpperCase();
          Array.from(schoolListContainer.children).forEach(item => {
            item.style.display = item.textContent.toUpperCase().includes(filter) ? 'block' : 'none';
          });
          showList();
        });
        
        document.addEventListener('click', hideList);
      }
      
      enhanceSchoolSearch();
    })();
  ''';
}

WebViewScripts 클래스는 웹 페이지에서 실행될 자바스크립트를 정의하며 세션 만료 체크 sweetAlert2(sessionCheckScript), 링크 기본 동작 유지(navigationScript), 대학 검색 자동 완성 기능(schoolSearchScript)을 제공합니다.

가끔 웹뷰에서 자바스크립트가 몇개 동작하지 않아 파일을 새로 만들어서 상속 받아서 자바스크립트를 새로 다시 정의했습니다.

위에 코드를 다 적용 시켰다면 프로젝트를 실행하면 빌드를 성공하고 앱으로 해당 폰에서 정상적으로 실행됩니다.

 

좌석 예약

 

좌석 예약 금지

위에 동영상 처럼 정상적으로 앱을 실행하여 웹을 웹뷰를 간편하게 사용할수 있습니다.

https://github.com/kjunh972/SeatReservation

 

GitHub - kjunh972/SeatReservation: 강의실 좌석 예약

강의실 좌석 예약. Contribute to kjunh972/SeatReservation development by creating an account on GitHub.

github.com

전체코드는 위에 깃허브에서 확인하실수 있습니다.

이상으로 flutter를 활용해 Spring Boot 서버 화면을 웹뷰로 띄우는 법을 알아보았습니다.