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

1. 소개

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

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

학습할 내용

  • 공유 비즈니스 로직을 독립형 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.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

공유 모델 정의

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_functions:
  google_cloud_firestore:
  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(List<String> args) async {
  await fireUp(args, (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 = request.url.queryParameters['step'] as int? ?? 1;
          await counterDoc.update({'count': FieldValue.increment(step)});
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Atomic increment complete',
            newCount: value + step,
          );
        } else {
          throw FailedPreconditionError(
            '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에 배포

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

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가 누락됨

firebase init functions를 실행할 때 Dart가 배포할 옵션 목록으로 표시되고 템플릿 코드를 생성하려면 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를 사용하면 fromJsontoJson 메서드를 수동으로 작성할 때 발생하는 오류를 방지할 수 있습니다. 프런트엔드와 백엔드가 정확히 동일한 데이터 형식을 예상하도록 합니다.

11. 축하합니다

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