Flutter 웹 프로젝트에서 iframe을 통해 외부 웹 페이지를 표시하는 작업을 진행하다가, 이 iframe 인스턴스를 다른 위젯에서 접근하고 제어해야 하는 상황을 마주했어요. 바로 전역으로 띄워져 있는 GNB에서 로그아웃 버튼을 눌러 모달을 띄울 때였죠.
사실 이미 iframe 인스턴스가 띄워진 페이지에서 모달을 띄웠을 때도 이미 같은 문제를 겪었습니다. 이때는, 해당 위젯에서 iframe에 css 속성, ‘pointer-events’를 직접 조작하는 방법으로 해결했었죠.
Future<void> _showConfirmModal(BuildContext context) async {
_iFrameElement.style.pointerEvents = 'none';
final bool? isConfirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return CommonConfirmModal(
...
},
);
if (isConfirm == true) {
...
} else {
_iFrameElement.style.pointerEvents = 'auto';
}
}
이렇게 모달이 활성화 되었을 때 클릭 가능 여부를 설정할 수 있었지만, Global Navigtaion에서 클릭하는 버튼은 다른 위젯에 존재하기 때문에 이 방법을 사용할 수 없었어요. 때문에, 전역적으로 관리할 방법을 찾기 위해 고민하던 중, 싱글톤 패턴
을 활용하게 되었습니다. js 프레임워크만 사용하던 저로써는 싱글톤 패턴이 낯설었는데, 이번 기회에 배우게 되었죠!
class BaseLayout extends StatelessWidget {
final Widget child;
const BaseLayout({
super.key,
required this.child,
});
Widget build(BuildContext context) {
return Scaffold(
appBar: ...
child: GlobalNavigation(),
),
body: child,
);
}
GoRoute(
path: '/test-page',
pageBuilder: (BuildContext context, GoRouterState state) {
return NoTransitionPage(
child: BaseLayout(
child: TestPage(),
...
저의 모든 위젯은 최상단 Main에서 BaseLayout
에 래핑된 구조였기 때문에 TestPage
에는 GlobalNavigation
이 존재했습니다. 이 위젯에는 모달을 활성화할 수 있는 Logout 버튼이 있었죠. TestPage
위젯에 존재하는 버튼을 눌러 모달을 띄우기 위한 로직에, 해당 iframe의 css를 변경하는 함수를 추가했습니다. 모달을 띄울 때는 ‘none’로, 모달이 비활성화될 때는 다시 ‘auto’로 변경하는 것이죠.
_iFrameElement.style.pointerEvents = 'auto';
이 방법을 GlobalNavigation 위젯에서 동일하게 해보았습니다. 이를 위해서는 우선 TestPage 위젯에 존재하던 iframe 인스턴스 생성 및 관련 로직을 분리할 필요가 있었죠.
import 'package:flutter/material.dart';
import 'dart:html';
import 'dart:ui' as ui;
class IFrameService {
// Iframe 인스턴스 생성
final IFrameElement _iFrameElement = IFrameElement();
IFrameService() {
_initializeIFrame();
}
/// iframe 설정 초기화
void _initializeIFrame() {
_iFrameElement.style
..height = '100%'
..width = '100%'
..border = 'none';
// Flutter 웹에서 HTML 요소(iframe)를 삽입할 수 있도록 등록
ui.platformViewRegistry.registerViewFactory(
'iframeElement',
(int viewId) => _iFrameElement,
);
}
/// iframe 소스 설정
void setIFrameSrc(String src) {
_iFrameElement.src = src;
}
/// iframe의 클릭 가능 여부 설정
void setIFramePointerEvents(bool enabled) {
_iFrameElement.style.pointerEvents = enabled ? 'auto' : 'none';
}
/// iframe 요소를 가져오는 메서드
HtmlElementView getIFrameView() {
return HtmlElementView(
viewType: 'iframeElement',
key: UniqueKey(),
);
}
}
이 iframe 클래스를 이용하여, TestPage
에 적용합니다.
IframeService
인스턴스 생성 및 setIFramePointerEvents
메서드 호출class TestPage extends StatefulWidget {
const TestPage({
super.key,
});
State<TestPage> createState() => _TestPageState();
}
class _TestPageState extends State<AnalyzerPage> {
// iFrameService 인스턴스 생성
final IFrameService _iFrameService = IFrameService();
...
Future<void> _showConfirmModal(BuildContext context) async {
// iframe의 pointer-event:'none';
_iFrameService.setIFramePointerEvents(false);
final bool? isConfirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return CommonConfirmModal()
},
);
if (isConfirm == true) {
...
} else {
// iframe의 pointer-event:'auto';
_iFrameService.setIFramePointerEvents(true);
}
} ...
Widget build(BuildContext context) {
return Scaffold(
body: Container(
// iframe 위젯 생성
child: _iFrameService.getIFrameView()
) ...
그 다음, GlobalNavigation
에서 Logout 버튼 클릭 로직에 setIframePointerEvents
를 추가합니다. 조작이 가능할까요?
IframeService
인스턴스 생성 및 setIFramePointerEvents
메서드 호출
class GlobalNavigation extends StatelessWidget {
const GlobalNavigation({super.key});
Future<void> _showLogoutModal(BuildContext context) async {
// iFrameService 인스턴스 생성
final IFrameService _iFrameService = IFrameService();
// iframe의 pointer-event:'none';
_iFrameService.setIFramePointerEvents(false);
final bool? isLogout = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return CommonConfirmModal()
},
);
if (isLogout == true) {
...
} else {
// iframe의 pointer-event:'auto';
_iFrameService.setIFramePointerEvents(true);
}
} ...
Widget build(BuildContext context) {
return ...
TextButton(
onPressed: () => _showLogoutModal(context),
child: const Text(
'Log Out',
),
)
당연히 결과는 X였습니다…!
같은 IframeService
생성자를 사용했어도, 엄연히 각각 다른 메모리 주소에 등록된 다른 인스턴스이기 때문이죠. 그렇다면 방법을 찾아야했습니다.
TestPage에 생성한 iframe 인스턴스를 다른 위젯에서도 상태를 조작하려면 어떤 방법을 써야할까요? 아무래도 웹을 개발하는 것은 react
나 vue
같은 js 프레임워크
(편의상 프레임워크라 칭하겠습니다.)를 사용하는 것이 익숙했기 때문에, 단번에 들었던 생각은 iframe을 전역 상태로 관리하는 것이었어요. iframe을 컴포넌트로 분리하여 전역 상태 관리 도구(react라면 redux
, zustand
, jotai
등 / vue라면 pinia
)로 TestPage 컴포넌트와 GlobalNavigation 컴포넌트에서 각각 관리할 수 있도록 셋팅하였을 것입니다.
물론 flutter에도 전역 상태 관리 방법은 존재했습니다. Provider
,BLoC
, RiverPod
등이 있었죠. 하지만, 현재 프로젝트에서 iframe을 외부 웹 페이지를 연결해야하는 경우는 이 1개가 유일했기 때문에 전역 상태로 관리할 필요까지는 없었어요.(무엇보다 전역 상태 관리 도구를 셋팅해두지 않았습니다…)
그리하여 더 편리한 방법은 없는지 찾아보았습니다!
이때 마침 어렴풋이 떠오른 기억이 있었습니다! 처음 Dart를 학습할 때 공식 문서에서 보았던 factory
생성자 부분이 있었어요. 이 생성자를 사용해서 싱글톤 패턴
을 구성할 수 있다는 것이 떠올랐죠!
Factory 생성자
항상 클래스의 새로운 인스턴스를 생성하지 않는 생성자를 구현하고 싶다면,
factory
키워드를 사용하세요. 예를 들어, factory 생성자는 인스턴스를 캐시에서 반환하거나 서브타입의 인스턴스를 반환할 수 있습니다. Factory 생성자는 final 변수를 초기화 리스트에서 다루지 않는 로직을 사용하여 초기화하는 방법으로도 사용할 수 있습니다.
기존에 싱글톤 패턴은 디자인 패턴 중의 하나로만 알고 있었고, 사실 어떤 상황에서 사용되는지 잘 몰랐어요. 위키백과에서는 이렇게 설명해놓았습니다.
*소프트웨어 디자인 패턴에서 싱글턴 패턴(Singleton pattern)을 따르는 클래스는, 생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다. 이와 같은 디자인 유형을 싱글턴 패턴이라고 한다. 주로 공통된 객체를 여러개 생성해서 사용하는 DBCP(DataBase Connection Pool)와 같은 상황에서 많이 사용된다.*
간단히 말해 싱글톤 패턴은 애플리케이션 내에서 단 하나의 인스턴스
만 생성되도록 보장하는 디자인 패턴입니다. 주로 전역 상태나 설정 파일 같은 자원에 접근할 때, 인스턴스가 여러 개 생성되지 않도록 방지하고, 어디서든 동일한 인스턴스를 재사용할 수 있게 해주죠.
Flutter 프레임워크는 Dart
언어를 사용하여 다양한 플랫폼(iOS, Android, 웹, 데스크탑)에서 UI를 구현하고 애플리케이션을 만들 수 있습니다. Dart는 정적 타입을 지원하는 객체지향 언어
로, 클래스
(Class)를 통해 객체를 정의하고 인스턴스
를 생성할 수 있으며, 선언형 방식으로 UI를 구성하여 애플리케이션의 화면을 그립니다.
객체지향 언어에 익숙하시다면, 클래스에서 인스턴스를 생성할 때마다 그 인스턴스는 서로 다른 메모리 공간을 차지하는 독립적인 객체가 된다는 사실 알고 계실 것입니다.
이 때문에 동일한 IframeService
클래스를 기반으로 TestPage
위젯과 GlobalNavigation
위젯에서 각각 생성한 인스턴스는 서로 다른 객체가 됩니다. 따라서 setPointerService
메서드를 사용해 pointerEvents
를 변경하려 해도, 서로 다른 인스턴스에 대한 메서드를 호출하는 것이기 때문에 원하는 변경이 반영되지 않는 문제가 발생한 것이죠.
Chat GPT에게 싱글톤 패턴
을 사용하는 이유에 대해 물어보았습니다.
같은 객체를 계속 생성하지 않고, 하나의 인스턴스만 사용하기 때문에 메모리 낭비를 줄일 수 있습니다. 특히 데이터베이스 연결이나 API 클라이언트와 같은 리소스가 제한적인 경우에 유용합니다.
싱글톤 패턴은 애플리케이션 전역에서 단 하나의 인스턴스를 보장하므로, 상태 관리에서 유용합니다. 애플리케이션의 여러 위젯이 동일한 데이터에 접근하고 수정해야 할 때, 싱글톤 인스턴스를 사용하면 이를 쉽게 관리할 수 있죠. 예를 들어, 사용자 설정이나 앱의 상태를 전역적으로 관리할 때 효과적입니다.
싱글톤을 사용하면 여러 컴포넌트가 동일한 상태를 참조하게 되므로, 상태의 일관성을 유지할 수 있습니다. 데이터가 변경되더라도, 모든 위젯이 같은 인스턴스를 참조하기 때문에 항상 최신 상태를 반영할 수 있습니다. 즉, 상태 동기화에 유용합니다.
이 이슈를 겪은 프로젝트는 규모가 작았기 때문에 메모리 효율까지 고려할 이유는 없었지만, 저는 2번, ‘전역 상태 관리’가 필요한 상황이었죠. 3번, ‘상태의 일관성 유지’가 경우 살짝 우려가 되었지만 사실 정말 단순히 1 페이지에 있는 1개의 iframe의 css 속성만 상태에 따라 통제하면 되었습니다.
때문에 결론적으로 다음의 이유로, 저는 해당 iframe을 전역 관리하기 위해 싱글톤 패턴
을 사용하기로 결정하였습니다!
- iframe을 1 페이지에서 1개만 사용할 것이라는 점
- 다양한 상태 관리가 필요하지 않은 점
다음의 과정을 거쳐 싱글톤 패턴을 구현할 수 있습니다.
private
으로 설정static
변수를 통해 단 하나의 인스턴스를 생성하고, 이를 외부에서 접근할 수 있도록 준비factory
생성자로 구현class Singleton {
// 1. Private 생성자: 외부에서 인스턴스를 생성할 수 없도록 설정
Singleton._interal();
// 2. 인스턴스 생성: static이자 private 변수에 싱글톤 인스턴스를 저장
static final Singleton _instance = Singleton._interal();
// 3. 팩토리 생성자: 싱글톤 인스턴스를 반환
factory Singleton() {
return _instance;
}
}
void main() {
// 싱글톤 인스턴스를 2번 호출
var singleton1 = Singleton();
var singleton2 = Singleton();
// 두 인스턴스가 동일한 인스턴스인지 확인
print(identical(singleton1, singleton2)); // true
}
이제 IFrameService
를 싱글톤 패턴
으로 변경해보겠습니다.
class IFrameService {
// 1. Private 생성자
IFrameService._internal() {
_initializeIFrame(); // 싱글톤 인스턴스 생성 시 iframe 초기화
}
// 2. 인스턴스 생성
static final IFrameService _instance = IFrameService._internal();
// 3. 팩토리 생성자
factory IFrameService() {
return _instance;
}
final IFrameElement _iFrameElement = IFrameElement();
void _initializeIFrame() {
_iFrameElement.style
..height = '100%'
... // 기타 함수 동일
TestPage에 존재하던 Iframe 생성자와 setPointEvents는 그대로 두어도 되었습니다. 생성자와 함수 자체는 변하지 않았으니까요!
결과는...
속시원하게도 이제 GlobalNavigation로부터 활성화 된 모달에서 버튼 조작이 가능했습니다! 이제 TestPage에서 생성된 iframe 인스턴스와 GlobalNavigation에서 생성된 iframe 인스턴스가 같아졌기 때문에 setPointEvents
메서드로 같은 iframe의 css를 조작할 수 있었기 때문이죠!
그렇다면 Flutter에서 전역 상태 관리를 할 때는 항상 싱글톤 패턴
을 사용하는 것이 효율적일까요? 항상 절대적인 답은 없죠! 우선 주의해야할 사항이 있습니다.
싱글톤 인스턴스의 상태가 변경되면, 이를 사용하는 모든 위젯이 리렌더링되어야 합니다. 상태 변경을 감지하고 업데이트하는 로직을 잘 작성하지 않으면, 불필요한 리렌더링이 발생할 수 있습니다. 이는 곧 성능 저하로 이어지겠죠!
싱글톤 패턴으로 만들어진 단 하나의 인스턴스가 전역적으로 사용될 경우, 테스트 하기 복잡해질 수 있습니다. 각각 다른 위젯에서 사용되어도, 모든 인스턴스가 강결합 되어있기 때문이죠. 특히, 여러 테스트가 같은 상태를 공유하게 되면 테스트 간섭이 발생할 수 있습니다.
의존성 주입(DI)을 사용할 때는 보통 특정 인스턴스를 주입받아 사용하는데, 싱글톤은 그 인스턴스의 생명 주기가 고정되어 있기 때문에 문제가 발생합니다. 예를 들어 여러 사용자들이 동시에 데이터베이스에 접근해야 할 때, 싱글톤 패턴으로 구현된 DatabaseService
는 항상 같은 인스턴스를 반환하므로, 한 사용자가 데이터베이스에 변경 사항을 반영하면 다른 사용자에게 그 변화가 실시간으로 반영되지 않거나 데이터 충돌이 발생할 수 있습니다.
이런 주의점들을 고려하여, 싱글톤 패턴이 유용한 경우와 전역 상태 관리 도구를 사용하는 것이 유용한 경우를 구분하여 사용하면 좋겠습니다!
Dio
, http.Client
)를 한 번만 생성해 재사용하는 것이 효율적입니다. 새로고침 없이 SPA
형태로 동작하는 Flutter 웹에서는 같은 클라이언트를 지속적으로 사용하는 것이 성능과 리소스 측면에서 유리하죠. 한 번 생성된 API 클라이언트를 여러 페이지에서 싱글톤으로 공유하면, 불필요한 객체 생성이나 리소스 낭비를 줄일 수 있습니다.그동안 해왔던 것처럼 Javascript 및 Javascript 프레임워크를 사용하여 웹 개발을 하였다면 몰랐을 싱글톤 패턴
에 대해 학습하는 계기가 되었어요. 결론적으로, 복잡한 상태 관리나 데이터 처리가 필요한 경우가 아닌, 전체 애플리케이션에서 일관적으로 상태를 관리하는 경우라면 싱글톤 패턴이 더욱 유용할 것이라 생각되었습니다!
그런데 여전히 의문이 남은 것이 있었어요. Javascript에서는 이러한 싱글톤 패턴이 필요 없었던 것인지 궁금했어요. 또한, ES6부터 클래스 문법 작성도 가능하였는데 현재 즉시 실행 함수 표현식(IFFE)
이 보편적이게 된 이유까지 궁금했죠. 다음 포스팅은 이에 대해 다뤄봐야겠습니다!