본문 바로가기

개발/BACK

Angular + Ionic + Capacitor에서 Firebase 완벽 연동 가이드 예제포

728x90

 

Angular + Ionic + Capacitor에서 Firebase 완벽 연동 가이드

모바일 앱 개발에서 백엔드 인프라 구축은 시간과 비용이 많이 드는 작업이다. Firebase를 사용하면 인증, 데이터베이스, 스토리지, 푸시 알림 등을 빠르게 구현할 수 있다.

이 글에서는 Angular + Ionic + Capacitor 프로젝트에 Firebase를 연동하는 방법을 실제 프로덕션 환경에서 사용하는 패턴으로 설명한다.


1. Firebase 프로젝트 생성

먼저 Firebase Console에 접속해서 새 프로젝트를 생성한다.

1.1 프로젝트 생성

  1. 1Firebase Console에서 "프로젝트 추가" 클릭
  2. 2프로젝트 이름 입력 (예: [projectId])
  3. 3Google Analytics 설정 (선택사항)
  4. 4프로젝트 생성 완료

1.2 웹 앱 등록

  1. 1프로젝트 대시보드에서 웹 아이콘(</>) 클릭
  2. 2앱 닉네임 입력
  3. 3Firebase Hosting 설정은 선택사항
  4. 4Firebase SDK 설정 정보 복사 (나중에 사용)

복사한 설정 정보는 다음과 같은 형태다:

const firebaseConfig = {
  apiKey: "[apiKey]",
  authDomain: "[authDomain]",
  projectId: "[projectId]",
  storageBucket: "[storageBucket]",
  messagingSenderId: "681712569882",
  appId: "[appId]",
  measurementId: "[measurementId]"
};

1.3 Firestore Database 활성화

  1. 1좌측 메뉴에서 "Firestore Database" 선택
  2. 2"데이터베이스 만들기" 클릭
  3. 3보안 규칙 모드 선택:
    • 테스트 모드: 개발 중에만 사용 (모든 읽기/쓰기 허용)
    • 프로덕션 모드: 실제 서비스용 (규칙 설정 필요)
  4. 4위치 선택 (가장 가까운 리전 선택, 예: asia-northeast3)
⚠️ 주의: 테스트 모드는 개발 중에만 사용하고, 프로덕션 환경에서는 반드시 보안 규칙을 설정해야 한다.

1.4 Authentication 활성화

  1. 1좌측 메뉴에서 "Authentication" 선택
  2. 2"시작하기" 클릭
  3. 3사용할 로그인 제공업체 활성화:
    • 이메일/비밀번호
    • Google
    • Apple
    • 등등

2. 필요한 패키지 설치

Angular 프로젝트에서 Firebase를 사용하려면 @angular/firefirebase 패키지가 필요하다.

npm install firebase @angular/fire

버전 호환성 확인:

  • Angular 16: @angular/fire@^7.6.1, firebase@^9.23.0
  • Angular 17+: @angular/fire@^17.x, firebase@^10.x

3. 환경 변수 설정

Firebase 설정 정보는 환경 변수로 관리하는 것이 좋다. Angular는 environment.ts 파일을 사용한다.

3.1 개발 환경 설정

src/environments/environment.ts 파일을 생성한다:

export const environment = {
  production: false,
  firebaseConfig: {
    apiKey: '[apiKey]',
    authDomain: '[authDomain]',
    projectId: '[projectId]',
    storageBucket: '[storageBucket]',
    messagingSenderId: '681712569882',
    appId: '[appId]',
    measurementId: '[measurementId]',
  },
};

3.2 프로덕션 환경 설정

src/environments/environment.prod.ts 파일을 생성한다:

export const environment = {
  production: true,
  firebaseConfig: {
    apiKey: '[apiKey]',
    authDomain: '[authDomain]',
    projectId: '[projectId]',
    storageBucket: '[storageBucket]',
    messagingSenderId: '681712569882',
    appId: '[appId]',
    measurementId: '[measurementId]',
  },
};
⚠️ 주의사항:
  • 실제 프로젝트에서는 apiKey를 환경 변수나 CI/CD 시크릿으로 관리한다
  • Git에 커밋할 때는 .gitignore에 환경 파일을 추가하거나, 실제 키 값은 제거한다

4. Firebase 초기화

Angular의 @angular/fire는 모듈 방식으로 Firebase를 초기화한다. app.module.ts에서 설정한다.

4.1 AppModule 설정

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { RouteReuseStrategy } from '@angular/router';

// Firebase imports
import { environment } from 'src/environments/environment';
import {
  FirestoreModule,
  getFirestore,
  provideFirestore,
} from '@angular/fire/firestore';
import {
  FirebaseAppModule,
  getApp,
  initializeApp,
  provideFirebaseApp,
} from '@angular/fire/app';
import {
  getAuth,
  indexedDBLocalPersistence,
  initializeAuth,
  provideAuth,
} from '@angular/fire/auth';
import { provideStorage, getStorage } from '@angular/fire/storage';
import { provideFunctions, getFunctions } from '@angular/fire/functions';
import { Capacitor } from '@capacitor/core';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    IonicModule.forRoot({ mode: 'ios' }),
    FirestoreModule,
    FirebaseAppModule,
    
    // Firebase 초기화
    provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
    
    // Authentication 초기화 (네이티브 플랫폼 대응)
    provideAuth(() => {
      if (Capacitor.isNativePlatform()) {
        // iOS/Android에서는 indexedDB 사용
        return initializeAuth(getApp(), {
          persistence: indexedDBLocalPersistence,
        });
      } else {
        // 웹에서는 기본 Auth 사용
        return getAuth();
      }
    }),
    
    // Firestore 초기화
    provideFirestore(() => getFirestore()),
    
    // Storage 초기화
    provideStorage(() => getStorage()),
    
    // Functions 초기화
    provideFunctions(() => getFunctions()),
  ],
  providers: [
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

4.2 Capacitor 네이티브 플랫폼 대응

Ionic + Capacitor로 iOS/Android 앱을 빌드할 때는 Authentication의 persistence 설정이 중요하다. 웹에서는 localStorage를 사용하지만, 네이티브 앱에서는 indexedDBLocalPersistence를 사용해야 한다.

💡 팁: Capacitor 환경을 감지해서 자동으로 적절한 persistence를 설정하면 웹과 네이티브 앱 모두에서 정상 작동한다.

5. Firestore 서비스 구현

Firestore를 사용하기 위한 서비스 클래스를 만든다. 이 서비스는 CRUD 작업과 쿼리를 추상화한다.

5.1 DbService 기본 구조

import { Injectable } from '@angular/core';
import {
  Firestore,
  collection,
  collectionData,
  doc,
  docData,
  addDoc,
  deleteDoc,
  query,
  where,
  orderBy,
  limit,
  getDoc,
  getDocs,
  setDoc,
} from '@angular/fire/firestore';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class DbService {
  constructor(public firestore: Firestore) {}

  // 컬렉션 전체 조회 (Observable)
  collection$(collectionName: string, queryFn?: Function): Observable<any[]> {
    const collectionRef = collection(this.firestore, collectionName);
    
    if (queryFn) {
      const queryConstraints = this.parseQuery(queryFn);
      const q = query(collectionRef, ...queryConstraints);
      return collectionData(q, { idField: 'id' });
    }
    
    return collectionData(collectionRef, { idField: 'id' });
  }

  // 단일 문서 조회 (Observable)
  doc$(path: string): Observable<any> {
    const docRef = doc(this.firestore, path);
    return docData(docRef, { idField: 'id' });
  }

  // 컬렉션에 문서 추가
  async add(collectionName: string, data: any): Promise<string> {
    const collectionRef = collection(this.firestore, collectionName);
    const docRef = await addDoc(collectionRef, data);
    return docRef.id;
  }

  // 문서 업데이트
  async updateAt(path: string, data: any): Promise<void> {
    const docRef = doc(this.firestore, path);
    await setDoc(docRef, data, { merge: true });
  }

  // 문서 삭제
  async delete(path: string): Promise<void> {
    const docRef = doc(this.firestore, path);
    await deleteDoc(docRef);
  }

  // 쿼리 파싱 헬퍼
  private parseQuery(queryFn: Function): any[] {
    const queryArray: any[] = [];
    const queryRef = {
      where: (field: string, operator: any, value: any) => {
        queryArray.push(where(field, operator, value));
        return queryRef;
      },
      orderBy: (field: string, direction?: 'asc' | 'desc') => {
        queryArray.push(orderBy(field, direction || 'asc'));
        return queryRef;
      },
      limit: (count: number) => {
        queryArray.push(limit(count));
        return queryRef;
      },
    };
    queryFn(queryRef);
    return queryArray;
  }

  // Promise 기반 컬렉션 조회 (한 번만 실행)
  async toCollection$(collectionName: string, queryFn?: Function): Promise<any[]> {
    const collectionRef = collection(this.firestore, collectionName);
    const result = [];
    
    if (queryFn) {
      const queryConstraints = this.parseQuery(queryFn);
      const q = query(collectionRef, ...queryConstraints);
      const querySnapshot = await getDocs(q);
      querySnapshot.forEach((doc) => {
        result.push({ id: doc.id, ...doc.data() });
      });
    } else {
      const querySnapshot = await getDocs(collectionRef);
      querySnapshot.forEach((doc) => {
        result.push({ id: doc.id, ...doc.data() });
      });
    }
    
    return result;
  }
}

5.2 실제 사용 예제

import { Component, OnInit } from '@angular/core';
import { DbService } from './core/services/db.service';
import { Observable } from 'rxjs';

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

@Component({
  selector: 'app-users',
  template: `
    <ion-list>
      <ion-item *ngFor="let user of users$ | async">
        {{ user.name }} - {{ user.email }}
      </ion-item>
    </ion-list>
  `,
})
export class UsersPage implements OnInit {
  users$: Observable<User[]>;

  constructor(private db: DbService) {}

  ngOnInit() {
    // 실시간 조회 (변경사항 자동 반영)
    this.users$ = this.db.collection$('users', (ref) =>
      ref.where('active', '==', true).orderBy('createdAt', 'desc').limit(10)
    );
  }

  async addUser() {
    await this.db.add('users', {
      name: '홍길동',
      email: 'hong@example.com',
      active: true,
      createdAt: new Date(),
    });
  }

  async updateUser(userId: string) {
    await this.db.updateAt(`users/${userId}`, {
      name: '홍길동 수정',
    });
  }

  async deleteUser(userId: string) {
    await this.db.delete(`users/${userId}`);
  }
}

5.3 문서 조인 (Join) 구현

Firestore는 NoSQL이라 JOIN이 없지만, 수동으로 문서를 조인할 수 있다:

// leftJoinDocument 헬퍼 함수
export const leftJoinDocument =
  (firestore: Firestore, field: string, collection: string) => (source: Observable<any[]>) =>
    defer(() => {
      let collectionData: any[];
      const cache = new Map();

      return source.pipe(
        switchMap((data) => {
          cache.clear();
          collectionData = data;

          const reads$ = [];
          let i = 0;
          for (const dData of collectionData) {
            if (!dData[field] || cache.get(dData[field])) {
              continue;
            }
            reads$.push(
              docData(doc(firestore, `${collection}/${dData[field]}`), {
                idField: 'id',
              })
            );
            cache.set(dData[field], i);
            i++;
          }

          return reads$.length ? combineLatest(reads$) : of([]);
        }),
        map((joins) =>
          collectionData.map((v) => {
            const joinIdx = cache.get(v[field]);
            return { ...v, [field]: joins[joinIdx] || null };
          })
        )
      );
    });

// 사용 예제
this.posts$ = this.db.collection$('posts').pipe(
  leftJoinDocument(this.db.firestore, 'userId', 'users')
);

6. Authentication 사용

Firebase Authentication을 사용해서 이메일/비밀번호, 소셜 로그인을 구현한다.

6.1 AuthService 구현

import { Injectable } from '@angular/core';
import { Auth, createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut, User } from '@angular/fire/auth';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  user$: Observable<User | null>;

  constructor(private auth: Auth) {
    // 사용자 상태를 Observable로 제공
    this.user$ = new Observable((observer) => {
      this.auth.onAuthStateChanged((user) => {
        observer.next(user);
      });
    });
  }

  // 회원가입
  async registerUser(email: string, password: string) {
    try {
      const userCredential = await createUserWithEmailAndPassword(
        this.auth,
        email,
        password
      );
      return userCredential.user;
    } catch (error) {
      console.error('회원가입 실패:', error);
      throw error;
    }
  }

  // 로그인
  async loginUser(email: string, password: string) {
    try {
      const userCredential = await signInWithEmailAndPassword(
        this.auth,
        email,
        password
      );
      return userCredential.user;
    } catch (error) {
      console.error('로그인 실패:', error);
      throw error;
    }
  }

  // 로그아웃
  async logout() {
    await signOut(this.auth);
  }

  // 현재 사용자 가져오기 (Promise)
  async getUser(): Promise<User | null> {
    return new Promise((resolve) => {
      this.auth.onAuthStateChanged((user) => {
        resolve(user);
      });
    });
  }
}

6.2 사용 예제

import { Component } from '@angular/core';
import { AuthService } from './core/services/auth.service';
import { DbService } from './core/services/db.service';

@Component({
  selector: 'app-login',
  template: `
    <ion-input [(ngModel)]="email" placeholder="이메일"></ion-input>
    <ion-input [(ngModel)]="password" type="password" placeholder="비밀번호"></ion-input>
    <ion-button (click)="login()">로그인</ion-button>
  `,
})
export class LoginPage {
  email = '';
  password = '';

  constructor(
    private auth: AuthService,
    private db: DbService
  ) {}

  async login() {
    try {
      const user = await this.auth.loginUser(this.email, this.password);
      console.log('로그인 성공:', user);
      
      // Firestore에 사용자 정보 저장
      await this.db.updateAt(`members/${user.uid}`, {
        email: user.email,
        lastLoginAt: new Date(),
      });
    } catch (error) {
      console.error('로그인 실패:', error);
    }
  }
}

7. Storage 사용

이미지나 파일을 업로드할 때 Firebase Storage를 사용한다.

7.1 StorageService 구현

import { Injectable } from '@angular/core';
import { Storage, ref, uploadBytes, getDownloadURL, deleteObject } from '@angular/fire/storage';

@Injectable({
  providedIn: 'root',
})
export class StorageService {
  constructor(private storage: Storage) {}

  // 파일 업로드
  async uploadFile(path: string, file: File): Promise<string> {
    const storageRef = ref(this.storage, path);
    await uploadBytes(storageRef, file);
    const downloadURL = await getDownloadURL(storageRef);
    return downloadURL;
  }

  // 파일 삭제
  async deleteFile(path: string): Promise<void> {
    const storageRef = ref(this.storage, path);
    await deleteObject(storageRef);
  }
}

7.2 사용 예제

import { Component } from '@angular/core';
import { StorageService } from './core/services/storage.service';

@Component({
  selector: 'app-upload',
  template: `
    <input type="file" (change)="onFileSelected($event)" accept="image/*">
    <ion-button (click)="upload()">업로드</ion-button>
  `,
})
export class UploadPage {
  selectedFile: File | null = null;

  constructor(private storage: StorageService) {}

  onFileSelected(event: any) {
    this.selectedFile = event.target.files[0];
  }

  async upload() {
    if (!this.selectedFile) return;

    const path = `images/${Date.now()}_${this.selectedFile.name}`;
    const downloadURL = await this.storage.uploadFile(path, this.selectedFile);
    console.log('업로드 완료:', downloadURL);
  }
}

8. 실전 팁과 주의사항

8.1 Firestore 보안 규칙

프로덕션 환경에서는 반드시 보안 규칙을 설정한다:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // 사용자 자신의 데이터만 읽기/쓰기 가능
    match /members/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }
    
    // 공개 데이터는 읽기만 가능
    match /public/{document=**} {
      allow read: if true;
      allow write: if request.auth != null;
    }
  }
}

8.2 인덱스 설정

복합 쿼리를 사용할 때는 Firestore 인덱스가 필요하다. 콘솔에서 자동으로 생성하거나 firestore.indexes.json 파일로 관리한다:

{
  "indexes": [
    {
      "collectionGroup": "applications",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "cityAddress", "order": "ASCENDING" },
        { "fieldPath": "dateCreated", "order": "DESCENDING" }
      ]
    }
  ]
}

8.3 네트워크 오프라인 대응

Firestore는 기본적으로 오프라인 캐싱을 지원한다. 하지만 명시적으로 설정할 수 있다:

import { enableIndexedDbPersistence } from '@angular/fire/firestore';

enableIndexedDbPersistence(getFirestore())
  .catch((err) => {
    if (err.code == 'failed-precondition') {
      console.warn('다중 탭에서 오프라인 지속성 사용 불가');
    } else if (err.code == 'unimplemented') {
      console.warn('브라우저가 오프라인 지속성을 지원하지 않음');
    }
  });

8.4 에러 처리

Firebase 에러는 코드로 구분할 수 있다:

import { FirebaseError } from '@angular/fire/app';

try {
  await this.auth.loginUser(email, password);
} catch (error) {
  if (error instanceof FirebaseError) {
    switch (error.code) {
      case 'auth/user-not-found':
        console.error('사용자를 찾을 수 없습니다');
        break;
      case 'auth/wrong-password':
        console.error('비밀번호가 잘못되었습니다');
        break;
      case 'auth/too-many-requests':
        console.error('너무 많은 요청입니다. 잠시 후 다시 시도하세요');
        break;
      default:
        console.error('알 수 없는 오류:', error.message);
    }
  }
}

8.5 성능 최적화

  • 필요한 필드만 조회: select() 사용 (현재 버전에서는 제한적)
  • 페이지네이션: limit()startAfter() 사용
  • 캐싱 전략: 자주 조회하는 데이터는 로컬에 캐싱
  • 배치 작업: 여러 문서를 한 번에 처리할 때 batch() 사용
import { writeBatch, doc } from '@angular/fire/firestore';

async batchUpdate() {
  const batch = writeBatch(this.firestore);
  
  batch.update(doc(this.firestore, 'users/user1'), { name: '홍길동' });
  batch.update(doc(this.firestore, 'users/user2'), { name: '김철수' });
  
  await batch.commit();
}

마무리

이 가이드에서는 Angular + Ionic + Capacitor 프로젝트에 Firebase를 연동하는 방법을 설명했다. 실제 프로덕션 환경에서 사용하는 패턴과 주의사항을 포함했다.

Firebase는 빠르게 프로토타입을 만들 수 있게 해주지만, 규모가 커지면 비용과 성능을 고려해야 한다. Firestore의 읽기/쓰기 비용, Storage의 저장 용량, Functions의 실행 시간 등을 모니터링하면서 최적화하는 것이 중요하다.

추가로 궁금한 점이 있으면 Firebase 공식 문서나 커뮤니티를 참고하자.

728x90