
작업 일정
- #29 [config] Firebase 설정
- #30 [use-case] CompleteSessionUseCase
- #15 [state] Riverpod 의존성 설정 및 Repository Provider 구성
- +) 개발자 계정 설정
이제 Model도 확정되었고, Service에 의해 어떤 복합 색인을 추가해야 하는지도 확정되었으니 Firebase 설정을 하도록 하겠다. 프로젝트 이름은 중복이 없으니 move-sketch 를 그대로 사용한다.
좌측 메뉴의 [보안 > Authentification] 에서 [시작하기]를 눌러 로그인 제공업체로 [이메일/비밀번호]를 선택하여 추가한다. [Google] 및 [Apple]도 추가해야 하지만 그것은 기능 구현이 완성된 후에 확장하기로 하였으므로 그때 가서 추가하도록 하겠다.
좌측 메뉴의 [데이터베이스 및 스토리지 > Firestore]에서 [데이터베이스 만들기]를 눌러 [Standard 버전]을 선택하고 region을 [asia-northeast3 (서울)]으로 선택한다. 보안 규칙은 기본값인 [프로덕션 모드에서 시작]으로 한다. 잠시 후에 보안 규칙을 설정할 것이다.
좌측 메뉴의 [데이터베이스 및 스토리지 > Storage]에서 [시작하기]를 눌러 [모든 위치]의 [ASIA-NORTHEAST3 / 표준]을 선택한다. 여기서도 보안 규칙은 [프로덕션 모드에서 시작]으로 한다.
무료 범위 내에서 사용할 거라도 Blaze 요금제로 프로젝트를 업그레이드 해야 한다는 것을 유의하자.
터미널에서 다음과 같이 입력하여 FlutterFire CLI를 설치 및 활성화한다. 처음이라면 환경변수와 관련된 경고가 뜨겠지만 우리는 실습 때 환경변수 설정을 했으므로 문제 없다.
dart pub global activate flutterfire_cli
그리고 프로젝트 루트에서 다음 명령어를 통해 프로젝트를 연동한다.
flutterfire configure --project=move-sketch
Flutter 프로젝트를 생성할 때 선택했던 플랫폼들이 자동으로 체크되어 있으니 그대로 엔터를 치면 된다. 프로젝트 연동이 완료되면 lib/firebase_options.dart 파일이 생성되는데, 여기에 적힌 apiKey, appId는 Secret Key가 아니라, 클라이언트 앱이 Firebase 프로젝트를 식별하기 위한 공개 식별자이므로 .gitignore 하지 않고 Git에 커밋하는 것이 Flutter의 표준 관례라고 한다. 설정에 따라 .gitignore 에 관성적으로 들어가 있기도 한데 CI/CD 측면에서 이 녀석도 같이 commit 해주는 게 좋기 때문에 .gitignore 에 포함되어 있다면 제외하도록 하자.
lib/main.dart 에 Firebase 초기화 코드를 추가하면 설정이 완료된다.
lib/main.dartimport 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'firebase_options.dart'; import 'routing/app_router.dart'; import 'ui/core/theme/theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Firebase 초기화 await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await initializeDateFormatting('ko', null); runApp(const MoveSketchApp()); } class MoveSketchApp extends StatelessWidget { const MoveSketchApp({super.key}); Widget build(BuildContext context) { return MaterialApp.router( routerConfig: router, title: '무브스케치', theme: MoveSketchTheme.lightTheme, ); } }
다중 조건 검색이나 정렬이 결합된 쿼리는 복합 색인을 필요로 한다. 좌측 메뉴의 [데이터베이스 및 스토리지 > Firestore]에서 [색인] 탭을 눌러 [색인 만들기]를 한다. [구조화된 색인 만들기]를 눌러 적절한 색인을 생성한다.
혹은 애뮬레이터/시뮬레이터로 앱을 실행하는 동안 콘솔에 뜨는 URL을 통해서도 등록할 수 있지만, 나는 이렇게 미리 등록해 놓는 것을 선호한다.
Firestore 및 Storage에 각각의 보안 규칙을 적용해야 한다.
Firestore 보안 규칙은 collection별로 지정한다. 아이디와 UID, 이메일을 매핑하는 /account_ids/{username} 문서는 특정 아이디 1건의 존재 여부 및 이메일 조회를 위한 GET은 모두에게 열어놓지만 전체 내용을 읽어오는 LIST는 허용하지 않고, 본인에 해당하는 문서만 생성하거나 삭제할 수 있도록 한다.
사용자 프로필을 담은 /users/{userId} 는 로그인한 모든 사용자에게 읽기 권한이 있고 쓰기 및 삭제 권한은 본인에게만 있다. 개인 세션 결과를 담은 /session_results/{resultId} 는 오직 본인만 읽고 쓰고 삭제할 수 있으며, 이를 바탕으로 스케치를 공유해야 /sketch_posts/{sketchId} 에 작성되어 로그인한 사용자가 읽을 수 있게 된다. 응원이나 댓글 같은 것만 다른 사용자에게 수정 권한이 부여된다.
이런 식으로 모든 collection에 대해 권한을 지정한다.
Storage 보안 규칙에는 그곳에 저장한 이미지 파일들에 대한 규칙이 들어간다. 각 사용자는 자신의 디렉토리 내의 파일만 수정 및 삭제 가능하다. 공유되지 않고 개인 세션 결과 기록에만 남는 이미지는 읽기 권한도 자신에게만 부여하며, 공유 가능한 이미지는 로그인한 사용자 전체에게 읽기 권한을 부여한다.
권한이 잘못 지정될 경우 필요 이상의 권한이 부여되어 보안 상의 허점이 되거나 반대로 필요한 권한도 충분히 부여되지 않아 앱 사용 중 Permission Denied가 뜰 수 있으므로 유의하도록 하자. 이를 방지하기 위해 AI를 통해 코드베이스 전수 대조와 모의 보안 감사를 요청하여 최대한 빈틈을 매울 수 있도록 하였다.
Flutter App Architecture 공식 문서를 처음 읽었을 때 그래서 이게 뭐지 했던 부분 중 하나다. 사실 Service가 뭐고 Repository가 뭔지 명확히 파악하지 못한 채 프로젝트를 시작했다. 직접 작성하다 보니 어느 정도 감이 잡히긴 하더라. 처음부터 알고 설계했으면 좀 더 체계적으로 개발할 수 있었을 것 같기도 하고.
UseCase는 간단히 말해, 하나의 구체적인 비즈니스 행위다. 둘 이상의 Repository를 조합해서 사용하는 비즈니스 로직이 많아질수록 UseCase를 사용하지 않는다면 ViewModel이 너무 많은 책임을 지게 만들어 코드가 기하급수적으로 비대해진다.
ViewModel의 책임을 줄이겠다고 Repository끼리 서로 참조하게 될 경우 순환 참조가 발생하거나 코드가 꼬일 수 있다. 피해야 할 anti-pattern 중 하나다.
Repository와 1대1로 깔끔하게 소통 가능한 화면에 대해서는 UseCase를 만드는 게 boilerplate로 작용한다. ViewModel에서 UseCase만 사용하기로 의사결정을 했다면 그렇게 작성할 수도 있긴 하지만 단순 CRUD는 ViewModel이 Repository를 직접 주입받아 호출하는 게 깔끔하다.
구분 UseCase를 사용해야 하는 경우 UseCase를 쓰지 말아야 하는 경우 작업 형태 복합 오케스트레이션 / 복잡한 규칙 단순 CRUD 참조 리포지토리 2개 이상의 리포지토리가 협력할 때 1개의 리포지토리만 호출할 때 코드 예시 세션 종료 시 로컬 DB 완료 + 서버 업로드 + 로컬 정리 단순히 userRepository.getUser(id)만 호출이유 비즈니스 조율 로직이 명확히 존재하므로 분리 가치가 높음 단순 전달용 껍데기 클래스만 늘어나 복잡도 증가 아키텍처 흐름 ViewModel ➔ UseCase ➔ Repositories ViewModel ➔ Repository (직접 호출)
내 프로젝트의 경우 대부분의 것들은 UseCase를 굳이 필요로 하지 않지만 세션 종료에 한해서 SessionRepository 와 SessionResultRepository 가 함께 사용된다. 로컬 Drift DB와 원격 Firebase DB가 만나는 지점이라고 할 수 있겠다.
UseCase를 만들지 않는 걸로 의사결정을 했다면 ViewModel에서 장황한 코드를 작성하는 것도 방법이지만, UseCase에 대한 이해가 떨어져서 초기 기획에서 UseCase로 빼지 못했던 것뿐이므로 뒤늦게라도 issue를 추가하여 작업에 포함시켰다.
Dart 3에서 패턴 매칭이 추가되었다는데 문법은 조금 다르지만 대략적인 느낌은 Rust에서 복구 가능한 에러를 처리하던 방식이랑 비슷해서 사람들이 구현해 놓은 예제를 몇 개 보니 금방 이해할 수 있었다.
당장은 세션 업로드 시도 직후에만 업로드 재시도를 할 수 있게 하기로 했다. 일시적인 네트워크 오류에 대한 대처일 뿐, 완전한 오프라인 상태에 대한 대처는 아니다. 오프라인 상태에서 완료 세션이 몇 개 쌓였을 때 앱 실행 시 일괄 업로드 재시도 하는 코드는 초기 배포 후 업데이트 하는 편이 나을 듯하다. 그것도 UseCase로 작성하게 될 듯.
flutter_riverpod package로 상태 관리를 할 것이다. 그리고 공식 Compass 예제 앱에 따라 관성적으로 추가한 lib/utils/command.dart 는 Riverpod을 사용할 경우 필요 없다는 것을 알게 되었다. 역시 뭐든 알고 해야 제대로 설계할 수 있다. 경험의 중요성을 다시 한 번 느낀다. 공식 Compass 예제 앱에서는 lib/config/dependencies.dart 에 Provider 코드를 넣었는데, 마찬가지로 나도 이 파일을 생성하여 Riverpod 사용을 준비하기로 했다.
lib/di/repository_providers.dart 로도 하고 lib/data/repositories/repository_providers.dart 로도 하고 lib/data/repositories/<feature_name>/<feature_name>_repository.dart 내에 구현하기도 하고 정답은 없는 모양이지만. (마지막 방식이 Riverpod 공식 문서의 방식이라나.)
[AI에 의하면...]
- 기존 Provider: 위젯 트리 결합을 위해 한 파일(
dependencies.dart)에 몰아서 리스트 형태로 묶어 선언하는 구조가 필수적이었습니다.- Riverpod: 결합이 전역 공간에서 유연하게 일어나므로 해당 클래스가 있는 파일에 Provider를 1:1로 쪼개어 선언하고, 만약 환경별(Remote/Local) 교체가 필요하다면
main.dart에서overrides옵션을 통해 제어하는 것이 Riverpod 고유의 표준 설계 방식입니다.
단순 객체 제공용 Provider<T> 는 한 곳에 모아두고 비동기 상태 관리용 AsyncNotifierProvider 는 각 화면별로 분산시켜 응집도를 높이는 하이브리드 방식도 많이 쓰인다고 한다. 지금 보니까 공식 Compass 예제 앱도 하이브리드 방식이구나. 이걸 따르는 게 좋겠다.
아직 앱 개발이 마무리되지는 않았으나 개발자 계정에 대한 신원 인증에 시간이 꽤나 소요되니 미리 준비해 두는 게 좋다고 하더라.
절차는 비교적 간단하다. Google Play Console에 접속하여 2단계 인증 후 개인 개발자로 계정을 생성한다. (나는 이미 2단계 인증이 되어 있어서 바로 계정 생성으로 넘어갔다.)
PlayStore에서 앱 이름 하단에 표기될 개발자 이름을 정해야 하는데, 이건 나중에 수정 가능하다고 하더라.
그러고 나면 결제 프로필을 만들라고 하는데, 이때 본인의 서류상의 이름 및 주소를 띄어쓰기까지 정확하게 입력해주는 게 심사에 유리하다고 한다. 개발자 계정 생성에는 25달러가 필요한데, 그래도 여긴 구독제가 아니라 한 번 결제하면 추가적인 비용이 들지 않는다.
계정이 생성되면 대시보드에서 본인 인증을 해주어야 한다. 본인 명의로 발행된 전기, 수도 또는 공공요금 청구서나 신용카드 명세서, 은행 명세서, 임대 계약서를 요구하는데, 이건 미국의 행정 서식을 기반으로 한 것이고 국내에서는 주로 정부24에서 발급받는 공적 서류로 승인받는다고 한다. 구글 결제 프로필의 이름과 주소가 한글인지 영문인지에 따라 주민등록등본의 언어도 맞춰서 제출하는 게 좋다고 하더라.
만약 반려된다면 본인 이름 및 주소가 포함되어 있는 체크카드 이용대금명세서나 은행 예금잔액증명서로도 시도해 볼 수 있다고 한다.
Gemini에 의하면 다음과 같은 주의사항을 지켜야 한다더라.
- 구글 결제 프로필 주소와 100% 일치 여부
구글 플레이 콘솔(또는 Google 결제 센터)에 등록된 이름, 도로명 주소, 동·호수, 우편번호가 제출할 서류(등본)의 내용과 글자 하나, 띄어쓰기 하나까지 일치해야 합니다.
만약 프로필 주소가 약칭으로 적혀 있다면, 서류를 제출하기 전에 구글 결제 프로필 주소를 서류상의 공식 도로명 주소와 동일하게 먼저 수정하세요.- 화면 캡처(스크린샷) 금지
컴퓨터 모니터나 스마트폰 화면을 캡처한 이미지는 위변조 방지를 위해 자동으로 거부됩니다. 반드시 발급된 공식 PDF 원본이거나 출력물을 실물 촬영한 사진이어야 합니다.- 90일 이내 발급 일자
서류 하단에 표시된 발급 일자가 최근 90일 이내여야 합니다 (오늘 바로 발급받으시는 것을 권장합니다).
앱 서명이니 뭐니 하는 것들은 추후에 배포 준비를 하며 하도록 하고 일단 이대로 작업을 마저 하며 신원 확인이 완료되길 기다려 보자. 반려되지 않고 무사히 승인되길 바라며...ㅎ
라고 했는데 최대 48시간이라는 말이 무색하게 12시간도 안 되어 처리되어 있었다. 이메일 온 걸 보니 9시간만에 처리되었군.
Apple Developer에 접속하여 Apple ID로 로그인하여 개발자 등록을 할 수 있다. 이 녀석은 매년 99달러를 가져간다. 개발자 계정마저 구독제라니. 그러니까 양아치 소리를 듣는 거지, 라는 생각이 스쳐 지나간다. 1회 비용도 Google보다 비싼데 심지어 연간 결제야? 하여간 난 장학금 받는 게 있으니 너무 아끼려고 하지 말고 투자해 보기로 했다. SeSAC 안 다녔으면 못 받았을 돈이니 아까워 하지 말자. 한 달치 장학금보다 1년치 Apple 개발자 멤버십이 더 싸다!
여긴 따로 서류를 제출하라는 거 없이 빈 칸만 잘 채워 내면 결제하라고 하면서 잘 흘러간다.
구입을 처리하는 데 48시간까지 걸릴 수 있다고 하고 아무튼 (대기 중) 이라고 뜨니 역시 개발 작업이나 이어서 하고 있도록 하자.
는 마찬가지로 몇 시간 뒤에 이미 처리되어 있었다. 이메일 온 걸 보니 70분만에 처리되었군. 확실히 돈을 많이 받아먹어서 그렇지 개발자 계정 만드는 거 자체는 이쪽이 빠르긴 하다.