하이브리드 앱 웹뷰 환경에서 네이티브앱과 통신을 주고받다보면, 백엔드 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의 생성과 완료 시점을 직접 제어해보게 되면서 비동기를 다루는 방식 자체가 한 단계 넓어진 듯한 느낌을 받았다.
'Tech > JavaScript' 카테고리의 다른 글
| 자바스크립트가 웹에서 비동기적으로 작동하는 원리에 대하여 (0) | 2024.02.12 |
|---|---|
| [js/자바스크립트] js 기초 2 (0) | 2022.10.06 |
| [js/자바스크립트] js 기초 1 (0) | 2022.10.05 |