Firebase용 Cloud Functions로 풀 스택 Dart 앱 빌드

1. 소개

이 Codelab에서는 멀티플레이어 카운터 앱을 빌드합니다. Flutter 프런트엔드와 Firebase 백엔드 모두에 Dart를 사용하는 방법을 알아봅니다.

또한 앱과 서버 간에 데이터 모델을 공유하여 로직을 중복할 필요가 없도록 하는 방법도 알아봅니다.

이 Codelab의 전체 소스 코드는 Firebase용 Cloud Functions 샘플 저장소에서 확인할 수 있습니다.

학습할 내용

  • 공유 비즈니스 로직을 독립형 Dart 패키지로 추출합니다.
  • Dart로 Firebase용 Cloud Functions를 네이티브로 작성하고 배포합니다.
  • Dart의 AOT (Ahead-of-Time) 컴파일을 활용하여 서버리스 콜드 스타트를 줄입니다.
  • Firebase 에뮬레이터 도구 모음을 사용하여 스택을 로컬에서 테스트합니다.

2. 기본 요건

  • Flutter SDK (최신 안정화 버전)
  • Firebase CLI (v15.15.0 이상 필요)
  • Dart 및 Flutter 플러그인이 설치된 코드 편집기(예: Antigravity, Visual Studio Code, IntelliJ, Android 스튜디오)
  • Flutter 및 Firebase에 대한 기본 지식

3. 백엔드에 Dart를 사용해야 하는 이유

많은 클라우드 애플리케이션에서 프런트엔드 UI에는 Dart를 사용하고 백엔드에는 TypeScript, Python, Go와 같은 다른 언어를 사용합니다. 이렇게 하려면 두 개의 별도 데이터 모델 세트를 유지해야 합니다. 데이터베이스 스키마가 변경되면 두 코드베이스를 모두 업데이트해야 합니다.

참고: 백엔드에서 Dart를 사용하면 코드를 중복하지 않고도 클라이언트의 반응형 Flutter 사용자 환경을 서버의 보안 검증과 결합할 수 있습니다.

4. Flutter 앱 만들기

표준 Flutter 앱을 만듭니다.

flutter create my_counter
cd my_counter
# Run the app to see the default counter example
flutter run

표준 Flutter 앱에서 lib/main.dart는 카운터 상태를 로컬로 처리합니다.

int _counter = 0;

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

이 접근 방식은 로컬 상태에서는 작동하지만 서버가 정보 근원 역할을 해야 하는 멀티플레이어 애플리케이션에는 적합하지 않습니다. 여러 플레이어를 지원하기 위해 다음 단계에서 이 로직을 백엔드로 이동합니다.

5. 공유 패키지 만들기

프런트엔드와 백엔드에서 모델이 중복되지 않도록 하려면 프로젝트 저장소 내에 공유 Dart 패키지를 만드세요. Flutter 앱과 Firebase용 함수는 모두 이 패키지에 종속됩니다.

my_counter 프로젝트의 루트에서 다음 명령어를 실행합니다.

mkdir -p packages
cd packages
dart create -t package shared

종속 항목 추가

packages/shared/pubspec.yaml에서 JSON 직렬화 도구를 추가합니다.

dependencies:
  json_annotation: ^4.11.0

dev_dependencies:
  build_runner: ^2.13.1
  json_serializable: ^6.13.1

공유 모델 정의

packages/shared/lib/src/models.dart를 만듭니다. 이 파일은 앱과 서버 모두에서 사용하는 데이터 구조를 정의합니다.

import 'package:json_annotation/json_annotation.dart';

part 'models.g.dart';

@JsonSerializable()
class IncrementResponse {
  final bool success;
  final String? message;
  final int? newCount;

  const IncrementResponse({required this.success, this.message, this.newCount});

  factory IncrementResponse.fromJson(Map<String, dynamic> json) =>
      _$IncrementResponseFromJson(json);

  Map<String, dynamic> toJson() => _$IncrementResponseToJson(this);
}

// Store the function name as a constant to ensure consistency between client and server.
const incrementCallable = 'increment';

packages/shared/lib/shared.dart에서 모델을 내보냅니다.

library shared;

export 'src/models.dart';

packages/shared 디렉터리에서 빌드 러너를 실행하여 JSON 직렬화 코드를 생성합니다.

dart run build_runner build

6. Firebase용 Cloud Functions 설정

Firebase용 Cloud Functions는 자체 서버를 관리하고 확장하지 않고도 백엔드 코드를 자동으로 실행할 수 있는 서버리스 프레임워크입니다. Dart는 AOT (Ahead-of-Time)로 바이너리로 컴파일되므로 Node.js나 Java와 같은 무거운 런타임 환경이 필요하지 않아 적합합니다. 이렇게 하면 함수의 콜드 스타트 시간이 크게 줄어듭니다.

프로젝트 루트로 이동하여 Firebase용 Cloud Functions를 초기화합니다.

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • 언어를 묻는 메시지가 표시되면 Dart를 선택합니다.

functions/pubspec.yaml에서 공유 패키지의 상대 경로를 추가합니다.

dependencies:
  firebase_admin_sdk: ^0.5.6
  firebase_functions: ^0.8.0
  google_cloud_firestore: ^0.5.5
  shared:
    path: ../packages/shared

7. 함수 작성

백엔드 로직을 작성하려면 functions/bin/server.dart를 열고 콘텐츠를 다음 코드로 바꿉니다.

import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart'
    show FieldValue;
import 'package:shared/shared.dart';

void main() {
  runFunctions((firebase) {

    // Listen for calls to the http request and name defined in the shared package.
    firebase.https.onRequest(name: incrementCallable, (request) async {

      // In a production app, verify the user with request.auth?.uid here.
      print('Incrementing counter on the server...');

      // Get firestore database instance
      final firestore = firebase.adminApp.firestore();

      // Get a reference to the counter document
      final counterDoc = firestore.collection('counters').doc('global');

      // Get the current snapshot for the count data
      final snapshot = await counterDoc.get();

      // Increment response we will send back
      IncrementResponse incrementResponse;

      // Check for the current count and if the snapshot exists
      if (snapshot.data() case {'count': int value} when snapshot.exists) {
        if (request.method == 'GET') {
          // Get the current result
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Read-only sync complete',
            newCount: value,
          );
        } else if (request.method == 'POST') {
          // Increment count by one
          final step =
              int.tryParse(request.url.queryParameters['step'] ?? '') ?? 1;
          await counterDoc.update({'count': FieldValue.increment(step)});
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Atomic increment complete',
            newCount: value + step,
          );
        } else {
          throw HttpResponseException.badRequest(
            message: 'only GET and POST requests are allowed',
          );
        }
      } else {
        // Create a new document with a count of 1
        await counterDoc.set({'count': 1});
        incrementResponse = const IncrementResponse(
          success: true,
          message: 'Cloud-sync complete',
          newCount: 1,
        );
      }

      // Return the response as JSON
      return Response(
        200,
        body: jsonEncode(incrementResponse.toJson()),
        headers: {'Content-Type': 'application/json'},
      );
    });

  });
}

8. Firebase 에뮬레이터 도구 모음을 사용하여 로컬에서 테스트

배포하지 않고도 프런트엔드와 백엔드를 모두 로컬에서 실행할 수 있습니다.

프로젝트 루트에서 Firebase 에뮬레이터 도구 모음을 시작합니다.

# Enable functions and firestore for the emulators
firebase init emulators
# Start the emulators and optionally open up the Admin UI
firebase emulators:start

pubspec.yaml에서 공유 패키지의 상대 경로를 추가하고 http 패키지를 추가합니다.

dependencies:
  http: ^1.6.0
  shared:
    path: packages/shared

Flutter 프로젝트에서 lib/main.dart를 열고 콘텐츠를 다음 코드로 바꿉니다. 이 프런트엔드 코드는 백엔드와 동일한 IncrementResponse 클래스를 사용합니다.

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';

/// Get from emulator output when running or when deploying:
/// ✔ functions[us-central1-increment]: http function initialized
///  (http://127.0.0.1:5001/demo-no-project/us-central1/increment).
const incrementUrl = 'FIREBASE_FUNCTIONS_URL_HERE';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
    home: const CounterPage(),
  );
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    // Fetch the current count
    _increment(readOnly: true).ignore();
  }

  Future<void> _increment({bool readOnly = false}) async {
    setState(() => _loading = true);
    try {
       // Call the Dart function.
      final uri = Uri.parse(incrementUrl);
      final response = readOnly ? await http.get(uri) : await http.post(uri);

      // Parse the response back into the shared Dart object.
      final responseData = jsonDecode(response.body);
      final incrementResponse = IncrementResponse.fromJson(responseData);

      if (incrementResponse.success) {
        setState(() => _count = incrementResponse.newCount ?? _count);
      }
    } catch (e) {
      print("Error calling function: $e");
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Multiplayer Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('You have pushed the button this many times:'),
            Text(
              '$_count',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _loading ? null : _increment,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Flutter 앱을 실행합니다. 플로팅 작업 버튼을 클릭하면 앱이 로컬 Dart 백엔드를 호출하고 새 개수를 가져와 UI를 업데이트합니다.

9. Firebase에 배포

이 Codelab에서는 Firebase 로컬 에뮬레이터 도구 모음을 사용하여 Firebase 프로젝트나 결제 계정 없이 함수를 사용해 보았습니다. 프로덕션 환경과 같은 실제 환경에서 함수를 사용하려면 Firebase 프로젝트와 결제를 설정해야 합니다.

Firebase 프로젝트 만들기

  1. Google 계정을 사용하여 Firebase 콘솔에 로그인합니다.
  2. 버튼을 클릭하여 새 프로젝트를 만든 다음 프로젝트 이름을 입력합니다.
  3. 계속을 클릭합니다.
  4. 메시지가 표시되면 Firebase 약관을 검토하고 이에 동의한 다음 계속을 클릭합니다.
  5. (선택사항) Firebase Console에서 AI 지원('Firebase의 Gemini'라고 함)을 사용 설정합니다.
  6. 이 Codelab에서는 Google 애널리틱스가 필요하지 않으므로 Google 애널리틱스 옵션을 사용 중지합니다.
  7. 프로젝트 만들기를 클릭하고 프로젝트가 프로비저닝될 때까지 기다린 다음 계속을 클릭합니다.

Firebase 요금제 업그레이드

이 Codelab에서 Firebase 서비스를 사용하려면 Firebase 프로젝트가 사용한 만큼만 지불하는 (Blaze) 요금제를 사용해야 합니다. 즉, Cloud Billing 계정에 연결되어 있어야 합니다.

  • Cloud Billing 계정에는 신용카드와 같은 결제 수단이 필요합니다.
  • 특별 프로모션 기간이거나 이벤트의 일환으로 이 Codelab을 진행하는 경우 Google Cloud 크레딧을 사용할 수 있습니다.
  • Firebase와 Google Cloud를 처음 사용하는 경우 $300 크레딧과 무료 체험판 Cloud Billing 계정을 받을 자격이 되는지 확인하세요.

프로젝트를 Blaze 요금제로 업그레이드하려면 다음 단계를 따르세요.

  1. Firebase Console에서 요금제 업그레이드를 선택합니다.
  2. Blaze 요금제를 선택합니다. 화면에 표시된 안내에 따라 Cloud Billing 계정을 프로젝트에 연결합니다.
    • 이 Codelab에 Google Cloud 크레딧을 사용하는 경우 결제 계정의 이름은 Google Cloud Platform Trial Billing Account 또는 My Billing Account일 가능성이 높습니다.
    • 이 업그레이드의 일환으로 Cloud Billing 계정을 만들어야 하는 경우 업그레이드를 완료하기 위해 Firebase Console의 업그레이드 흐름으로 돌아가야 할 수 있습니다.

Firebase 프로젝트에 배포

Dart 백엔드를 배포하려면 Firebase CLI를 사용하여 다음 명령어를 실행합니다.

firebase use <PROJECT_ID>
firebase deploy --only functions

명령어가 실행되면 URL을 복사하고 이전에 추가한 Flutter 앱 소스 코드에서 FIREBASE_FUNCTIONS_URL_HERE을 바꿉니다.

10. 문제 해결

firebase: command not found

Firebase CLI가 설치되어 있고 PATH가 업데이트되었는지 확인합니다. npm을 사용하여 설치할 수 있습니다(npm install -g firebase-tools).

init 함수 템플릿에서 Dart가 누락됨

Dart가 배포할 옵션 목록으로 표시되고 firebase init functions를 실행할 때 템플릿 코드를 생성하려면 firebase experiments:enable dartfunctions를 실행하여 실험 플래그를 설정해야 합니다.

함수 에뮬레이터가 연결되지 않음

localhost 및 포트 5001을 사용하고 있는지 확인합니다. Android 에뮬레이터에서 테스트하는 경우 기기에서 localhost를 호스트 머신으로 확인하지 않습니다. 10.0.2.2을 사용하도록 main.dart의 에뮬레이터 구성을 업데이트합니다.

공유 패키지를 찾을 수 없음

functions/pubspec.yaml에서 상대 경로를 확인합니다. 폴더 구조가 Codelab과 다른 경우 올바른 디렉터리를 가리키도록 path: ../packages/shared를 조정합니다.

json_serializable를 사용해야 하나요?

필수는 아니지만 json_serializable를 사용하면 fromJson 및 toJson 메서드를 수동으로 작성할 때 발생하는 오류를 방지할 수 있습니다. 프런트엔드와 백엔드가 정확히 동일한 데이터 형식을 예상하도록 합니다.

11. 축하합니다

풀 스택 Dart 애플리케이션을 빌드했습니다. 공유 패키지에서 데이터 모델을 유지하면 전체 스택에서 단일 프로그래밍 언어를 사용하여 API 응답과 클라이언트 UI가 동기화된 상태로 유지됩니다.

이 프로젝트의 완료된 전체 코드를 보려면 firebase/functions-samples 저장소의 Codelab 샘플을 참고하세요.