개발/FRONT

async/await로 완성하는 비동기 조회 마스터 클래스 - Promise 지옥에서 벗어나기

Hdev&Shoes 2025. 12. 21. 17:01
728x90

 

async/await로 완성하는 비동기 조회 마스터 클래스

Promise 지옥에서 벗어나기

Angular + Firebase 프로젝트에서 async/await를 사용해 비동기 데이터를 조회하는 방법을 실제 프로덕션 코드와 함께 설명한다. Observable과 Promise의 차이, async/await 패턴, 에러 처리까지 모든 것을 다룬다.


1. async/await란 무엇인가?

async/await는 Promise를 더 쉽게 사용할 수 있게 해주는 문법이다. 비동기 코드를 동기 코드처럼 읽기 쉽게 작성할 수 있다.

1.1 Promise의 문제점

Promise를 중첩해서 사용하면 코드가 복잡해진다:

// ❌ Promise 중첩 (콜백 지옥)
getUser().then((user) => {
  getData(user.id).then((data) => {
    getAddress(data.addressId).then((address) => {
      console.log(address);
    });
  });
});

1.2 async/await의 해결책

async/await를 사용하면 동기 코드처럼 읽기 쉽다:

// ✅ async/await (깔끔한 코드)
async function loadData() {
  const user = await getUser();
  const data = await getData(user.id);
  const address = await getAddress(data.addressId);
  console.log(address);
}
💡 핵심: async 함수는 항상 Promise를 반환하고, await는 Promise가 완료될 때까지 기다린다.

2. Observable vs Promise vs async/await

Angular 프로젝트에서는 Observable과 Promise를 모두 사용한다. 각각의 특징을 이해하고 적절히 선택해야 한다.

타입 특징 사용 시기
Observable 여러 값을 스트림으로 전달, 실시간 업데이트 가능 Firestore 실시간 리스너, HTTP 요청
Promise 단일 값을 한 번만 반환 한 번만 실행되는 작업
async/await Promise를 동기 코드처럼 작성 순차적 비동기 작업

2.1 Observable을 Promise로 변환

Firestore의 collection$는 Observable을 반환하지만, 한 번만 조회하려면 Promise로 변환해야 한다:

import { lastValueFrom, take } from 'rxjs';

// Observable을 Promise로 변환
async getUser() {
  return await lastValueFrom(this.user$.pipe(take(1)));
}
⚠️ 주의: take(1)을 사용하면 첫 번째 값만 가져온 후 구독이 자동으로 해제된다.

3. 기본 async/await 사용법

async/await의 기본 문법을 익힌다.

3.1 async 함수 선언

// 함수 선언
async function fetchData() {
  // ...
}

// 화살표 함수
const fetchData = async () => {
  // ...
};

// 클래스 메서드
class DataService {
  async fetchData() {
    // ...
  }
}

3.2 await 사용

async function loadUser() {
  // Promise가 완료될 때까지 기다림
  const user = await getUser();
  console.log(user);
  
  // 다음 코드는 user가 로드된 후 실행됨
  const data = await getData(user.id);
  return data;
}
💡 팁: awaitasync 함수 안에서만 사용할 수 있다.

4. Firestore 비동기 조회 구현

실제 프로덕션 환경에서 사용하는 Firestore 비동기 조회 패턴을 설명한다.

4.1 toCollection$ 메서드 구현

한 번만 조회하는 경우 Promise 기반 메서드를 사용한다:

import { Injectable } from '@angular/core';
import { Firestore, collection, query, where, getDocs } from '@angular/fire/firestore';

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

  /**
   * Promise 기반 컬렉션 조회 (한 번만 실행)
   * @param collectionName 컬렉션 이름
   * @param queryFn 쿼리 함수 (선택사항)
   * @returns Promise<any[]>
   */
  async toCollection$(collectionName: string, queryFn?: Function): Promise<any[]> {
    const result: any[] = [];
    
    if (queryFn) {
      // 쿼리가 있는 경우
      const queryData = this.parseQuery(queryFn);
      const q = query(collection(this.firestore, collectionName), ...queryData);
      const querySnapshot = await getDocs(q);
      
      querySnapshot.forEach((doc) => {
        result.push({ id: doc.id, ...doc.data() });
      });
      
      return result;
    } else {
      // 전체 조회
      const querySnapshot = await getDocs(
        collection(this.firestore, collectionName)
      );
      
      querySnapshot.forEach((doc) => {
        result.push({ id: doc.id, ...doc.data() });
      });
      
      return result;
    }
  }

  /**
   * 쿼리 파싱 헬퍼
   */
  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;
  }
}

4.2 사용 예제

컴포넌트에서 사용하는 방법:

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

@Component({
  selector: 'app-users',
  template: `
    <div *ngFor="let user of users">
      {{ user.name }} - {{ user.email }}
    </div>
  `,
})
export class UsersComponent implements OnInit {
  users: any[] = [];

  constructor(private db: DbService) {}

  async ngOnInit() {
    // 쿼리 없이 전체 조회
    this.users = await this.db.toCollection$('users');
    
    // 또는 쿼리와 함께 조회
    this.users = await this.db.toCollection$('users', (ref) =>
      ref.where('active', '==', true).orderBy('createdAt', 'desc').limit(10)
    );
  }
}

4.3 이메일 중복 체크 예제

회원가입 시 이메일 중복을 체크하는 경우:

async checkDuplicateEmail(email: string): Promise<boolean> {
  const usedEmail = await this.db.toCollection$('members', (ref) =>
    ref.where('email', '==', email).where('exitSwitch', '==', false)
  );
  
  return usedEmail.length > 0;
}

// 사용 예제
async registerUser(email: string, password: string) {
  const isDuplicate = await this.checkDuplicateEmail(email);
  
  if (isDuplicate) {
    throw new Error('이미 가입된 이메일입니다');
  }
  
  // 회원가입 진행
  await this.auth.registerUser(email, password);
}

5. 순차적 데이터 로딩

데이터를 순서대로 로딩해야 하는 경우 async/await를 사용하면 간단하다.

5.1 순차적 로딩 구현

// 순차적 데이터 로딩
async loadAllDataSequentially() {
  try {
    // 1. 멤버 데이터 로딩
    await this.setMemberData();
    this.loadingProgress.member = true;
    console.log('멤버 데이터 로딩 완료');

    // 2. 공지사항 데이터 로딩
    await this.loadNoticesData();
    this.loadingProgress.notices = true;
    console.log('공지사항 데이터 로딩 완료');

    // 3. 순위 데이터 로딩
    await this.getData();
    await this.getAddress();
    this.rankingDataReady = true;
    console.log('순위 데이터 로딩 완료!');

    // 4. 모든 데이터 로딩 완료
    this.loadingProgress.applications = true;
    console.log('모든 데이터 로딩 완료!');
  } catch (error) {
    console.error('데이터 로딩 중 오류:', error);
    // 오류가 발생해도 기본 데이터는 표시
    this.dataReady = true;
  }
}

5.2 각 단계별 구현

async setMemberData() {
  const user = await this.auth.getUser();
  if (user) {
    this.member = await this.db.toDoc$(`members/${user.uid}`);
  }
}

async loadNoticesData() {
  this.notices$ = this.db.collection$('notices', (ref) =>
    ref.where('active', '==', true).orderBy('createdAt', 'desc').limit(5)
  );
}
💡 팁: 순차적 로딩은 각 단계가 이전 단계의 결과에 의존할 때 사용한다. 예를 들어, 사용자 정보를 먼저 가져온 후 그 정보로 다른 데이터를 조회하는 경우.

6. 병렬 데이터 로딩

여러 데이터를 동시에 로딩하면 성능이 향상된다. Promise.all()을 사용한다.

6.1 Promise.all() 사용

// 병렬 데이터 로딩
async loadAllDataInParallel() {
  try {
    // 모든 데이터를 동시에 로딩
    const [users, notices, categories] = await Promise.all([
      this.db.toCollection$('users'),
      this.db.toCollection$('notices', (ref) =>
        ref.where('active', '==', true).limit(5)
      ),
      this.db.toCollection$('categories'),
    ]);

    this.users = users;
    this.notices = notices;
    this.categories = categories;
    
    console.log('모든 데이터 로딩 완료!');
  } catch (error) {
    console.error('데이터 로딩 중 오류:', error);
  }
}

6.2 Promise.allSettled() 사용

일부가 실패해도 나머지를 계속 실행하려면 Promise.allSettled()를 사용한다:

async loadDataWithErrorHandling() {
  const results = await Promise.allSettled([
    this.db.toCollection$('users'),
    this.db.toCollection$('notices'),
    this.db.toCollection$('categories'),
  ]);

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`데이터 ${index} 로딩 성공:`, result.value);
    } else {
      console.error(`데이터 ${index} 로딩 실패:`, result.reason);
    }
  });
}
⚠️ 차이점:
  • Promise.all(): 하나라도 실패하면 전체 실패
  • Promise.allSettled(): 일부 실패해도 나머지 계속 실행

7. 에러 처리와 최적화

실제 프로덕션 환경에서는 에러 처리가 필수다.

7.1 try-catch 에러 처리

async loadUserData(userId: string) {
  try {
    const user = await this.db.toDoc$(`members/${userId}`);
    
    if (!user) {
      throw new Error('사용자를 찾을 수 없습니다');
    }
    
    return user;
  } catch (error: any) {
    console.error('사용자 데이터 로딩 실패:', error);
    
    // 에러 타입에 따른 처리
    if (error.code === 'permission-denied') {
      console.error('권한이 없습니다');
    } else if (error.code === 'not-found') {
      console.error('데이터를 찾을 수 없습니다');
    }
    
    // 기본값 반환 또는 에러 재발생
    return null;
    // 또는
    // throw error;
  }
}

7.2 타임아웃 처리

응답이 너무 오래 걸리면 타임아웃을 설정한다:

async loadDataWithTimeout(timeoutMs: number = 5000) {
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('타임아웃')), timeoutMs)
  );

  try {
    const data = await Promise.race([
      this.db.toCollection$('users'),
      timeoutPromise,
    ]);
    
    return data;
  } catch (error) {
    console.error('데이터 로딩 타임아웃:', error);
    return [];
  }
}

7.3 재시도 로직

네트워크 오류 시 자동으로 재시도하는 로직:

async loadDataWithRetry(maxRetries: number = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const data = await this.db.toCollection$('users');
      return data;
    } catch (error) {
      if (i === maxRetries - 1) {
        throw error; // 마지막 시도에서 실패하면 에러 발생
      }
      
      // 재시도 전 대기
      await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
      console.log(`재시도 ${i + 1}/${maxRetries}`);
    }
  }
}

7.4 성능 최적화 팁

  • 병렬 로딩 활용: 독립적인 데이터는 Promise.all()로 동시에 로딩
  • 필요한 데이터만 조회: limit()where()로 필터링
  • 캐싱 전략: 자주 조회하는 데이터는 로컬에 캐싱
  • 로딩 상태 표시: 사용자 경험을 위해 로딩 인디케이터 표시

마무리

async/await를 사용하면 비동기 코드를 동기 코드처럼 읽기 쉽게 작성할 수 있다. 하지만 Observable과 Promise의 차이를 이해하고, 적절한 상황에 맞는 패턴을 선택하는 것이 중요하다.

실시간 업데이트가 필요한 경우 Observable을, 한 번만 조회하는 경우 async/await를 사용하자. 순차적 로딩과 병렬 로딩을 적절히 조합하면 성능과 가독성을 모두 확보할 수 있다.

✅ 핵심 요약:
  • async 함수는 항상 Promise를 반환
  • await는 Promise가 완료될 때까지 대기
  • 순차적 로딩: await를 순서대로 사용
  • 병렬 로딩: Promise.all() 사용
  • 에러 처리: try-catch 필수

728x90