본문 바로가기

Tech/JavaScript

Promise를 외부로 꺼내 비동기 직접 제어하기 (Deferred Pattern)

하이브리드 앱 웹뷰 환경에서 네이티브앱과 통신을 주고받다보면, 백엔드 API 와의 통신과는 또 다르게 비동기 문제에 부딪힐 때가 있다.

 

이번 이슈는 앱과 블루투스 기기를 연동할 때 펌웨어 유효성을 검증하는 로직에서 발생하였고,

해결을 위해서는 네이티브 앱에서 비동기로 넘어오는 파편화된 이벤트를 순서대로 제어하는 방법이 필요했다.

 

📌 문제 정의 

구현하고자 하는 기능은 단순했다.

1. 웹뷰 -> 앱에 기기 정보를 요청

2. 앱이 응답

3. 백엔드API로 펌웨어 검증

4. 검증 성공 시 블루투스 연결

 

하지만 실제 구현과정에서는 크게 3가지 걸림돌이 있었다.

 

1. <가장 큰 이슈>
펌웨어 검증이 완벽히 끝난 후 결과에 따라 블루투스 연결 시도 로직이 실행되어야 한다.

앱 응답이 언제 도착할지 모르는 구조 특성상 호출부에서 검증 완료 시점까지 코드 실행을 중단해두어야 했다.

이를 위해 구조에 알맞는 비동기 처리가 필요했다.

 

2. 네이티브 앱에서 넘겨주는 여러개의 데이터는 한 번에 들어오는 것이 아니라 서로 다른 타이밍에 별개의 콜백 이벤트로 수신된다.

그래서 백엔드 검증 API를 어느 시점에 호출해야 할지 기준이 모호해졌다.

 

3. 이 펌웨어 검증 프로세스는 한 곳에서만 사용되는 게 아니다.

앱 내 다양한 진입점이 있고 공통으로 처리하기 위해서는 완전한 모듈화가 필요했다.

 

📌 해결 과정

처음에 이 검증 프로세스를 떠올렸을 땐 단순히 setInterval를 이용한 폴링 방식을 활용해서 검증 api를 호출하면 되지 않을까 생각했다.

하지만 실제로 구현을 해보니 폴링은 검증 데이터가 다 모였는지에 대해 감지만 해줄 뿐이지 그걸 어떻게 풀어내야 할지에 대한 문제가 여전히 남아 있었다.

 

이 지점에서 굳이 폴링으로 감지하지 말고 애초에 Promise를 먼저 만들어 두고 이벤트가 조건을 만족할 때 resolve 시키면 될 것 같단 생각을 하게 되었다.

그렇게 되면 호출부에서는 깔끔하게 코드 한 줄로 실행할 수도 있겠다 싶어 3번 문제도 간단히 해결될 거라 믿었다.

const isValid = await checkFirmwareValidation()

 

이에 뒤따른 아이디어는 그럼 Promise를 생성하는 시점과 resolve/reject를 호출하는 시점을 분리해야한다는 것이었다.

 

앱 응답이 언제 완료될지 모르는 상황에서 호출부를 await으로 잡아두기 위해, Promise의 resolve/reject 핸들러를 외부 변수로 추출하고 제어하는 방식을 선택했다.

나중에 찾아보니 이 방식에는 이미 이름이 있었다.
Deferred Pattern 이라는 이름의 패턴이라고 한다.😅

 

흐름은 대략적으로 다음과 같다

 

1. Promise 생성 직후 resolve와 reject 핸들러를 훅 내부의 외부 스코프 변수에 할당한다.

이렇게 하면 호출부는 대기상태로 머물게 된다.

// Promise 핸들러를 저장할 외부 스코프 변수
let resolveValidation: ((value: boolean) => void) | null = null;
let rejectValidation: ((reason?: any) => void) | null = null;

const checkFirmwareValidation = (): Promise<boolean> => {

  isChecking.value = true;

  return new Promise<boolean>(async (resolve, reject) => {
    // ⭐ 핵심: 생성된 Promise의 제어권을 외부 변수에 바인딩!
    resolveValidation = resolve;
    rejectValidation = reject;

    // Native App에 데이터 요청 (응답은 이 함수가 끝난 뒤 이벤트로 들어옴)
    await getFirmwareInfo();
  });
};

 

2. 앱에서 이벤트가 들어올 때마다 값을 담아두고, 3가지 필수 데이터가 모두 모인 순간에만 백엔드 검증 API를 호출하도록 한다.

이때 

// 앱 응답 이벤트 수신
const checkFirmwareResponse = async (args: string) => {
  if (args.startsWith('{if16}')) macAddress.value = args.replace('{if16}', '');
  if (args.startsWith('{if18}')) firmwareVersion.value = args.replace('{if18}', '');
  if (args.startsWith('{integrityok}')) hashValue.value = args.replace('{integrityok}', '');

  await callPostFirmwareValidation();
};

const callPostFirmwareValidation = async () => {
  // 3가지 정보가 모두 수집되지 않았다면 API를 부르지 않음
  if (!isChecking.value || !macAddress.value || !firmwareVersion.value || hashValue.value === null) {
    return;
  }

  // API 호출 진행...
};

 

3. 백엔드 API 결과에 따라 호출부에게 결과를 넘긴다.

try {
  await postFirmwareValidation({ /* payload */ });

  // ⭐ 검증 성공: External Promise를 resolve(true)하여 호출부를 깨움
  if (resolveValidation) resolveValidation(true);

} catch (error) {
  const status = (error as AxiosError).response?.status;

  // 423, 451 등 예견된 비즈니스 검증 실패는 resolve(false) 반환
  if (status === 423 || status === 451) {
    if (resolveValidation) resolveValidation(false);
  } else {
    // 시스템 에러는 reject 처리
    if (rejectValidation) rejectValidation(error);
  }
} finally {
  // 상태 초기화 및 메모리 누수 방지
  resolveValidation = null;
  rejectValidation = null;
  isChecking.value = false;
}

 

그러면 이제 앞서 말했던 것처럼 호출부에서는 코드 한 줄로 검증 프로세스를 마칠 수 있게 된다.

const isValid = await checkFirmwareValidation();
if (isValid) {
  connectBluetooth();
} else {
  showInvalidFirmwareGuide();
}

 

📌 아쉬운 점

이 구조로 문제를 해결하긴 했지만, 블로그 글을 위해 내용을 정리하다 보니 완전히 다루지 못한 추가적인 부분들을 인지하게 되었다.

 

1. checkFirmwareValidation()를 동시에 중복 호출할 경우 Promise 핸들러가 새롭게 교체된다.

앱 내 여러 진입점에서 같은 함수를 쓰는 구조라면 이론적으로는 이 지점에서 첫번째 호출부가 무한 대기 상태에 빠질 가능성이 있기 때문에 요청마다 객체 단위로 관리하는 방식을 추가 고려해봐야 한다.

 

2. Promise executor에서의 비동기 처리 형태로 executor 자체를 async 형태로 만들었는데, 이 경우 executor 내부에서 발생한 에러가 Promise의 reject로 자동 연결되지 않는다. 현재는 getFirmwareInfo()가 실패하는 케이스를 별도로 처리하지 않고 있어서 여기서 예외가 나게될 경우 무한 대기 상태로 남을 가능성이 있다.

 

개선사항으로 남겨두면 좋을 것 같은 부분 체크..


그동안 비동기 처리라고 하면 대부분 백엔드 api를 async/await로 호출하고 응답을 기다리는 정도가 전부였는데,

이번에는 Promise의 생성과 완료 시점을 직접 제어해보게 되면서 비동기를 다루는 방식 자체가 한 단계 넓어진 듯한 느낌을 받았다.