안녕하세요~
어느덧 세 번째 글을 작성하게 됐습니다 ㅎㅎ
이번 글에서는 Bridge-Api 라이브러리의 클라이언트 측 구현 과정과 그 과정에서 마주친 비동기 처리 문제, 그리고 그 해결 과정에 대해 다루어보겠습니다.
1. 친숙한 인터페이스의 클라이언트 라이브러리 구상
우리가 일반적으로 사용하는 HTTP 클라이언트 라이브러리들, 예를 들어 axios나 jQuery의 Ajax는 매우 직관적이고 사용하기 쉬운 인터페이스를 제공합니다.
Bridge-Api의 클라이언트 라이브러리도 이와 유사한 사용 경험을 제공하고자 했습니다.
목표로 한 사용 방식은 다음과 같았습니다:
const api = BridgeApi.create({
headers: {"Authorization": "Bearer token"},
timeout: 5000,
});
api.get('/users/1')
.then(user => console.log(user))
.catch(error => console.error(error));
이러한 인터페이스를 구현하기 위해, 저는 BridgeApi
클래스와 각 HTTP 메서드에 대응하는 메서드들을 설계했습니다.
2. 초기 구현 과정
초기 구현은 다음과 같이 진행 되었습니다.
export class BridgeApi {
config: BridgeApiConfig;
constructor(config?: BridgeApiConfig) {
this.config = config ?? {headers: {}, timeout: 5000};
}
static create(config?: BridgeApiConfig): BridgeApi {
return new BridgeApi(config);
}
get<ResT>(pathAndQuery: string, body: any = ''): Promise<ResT> {
return this.request({
pathAndQuery,
method: MethodType.GET,
headers: this.config.headers,
body
});
}
// POST, PUT, DELETE, PATCH 메서드도 유사하게 구현
private request<ResT>(apiCommonRequest: ApiCommonRequest<any>): Promise<ResT> {
return new Promise((resolve, reject) => {
try {
const jsonString = JSON.stringify(apiCommonRequest);
const result = window.BridgeApi.bridgeRequest(jsonString);
resolve(JSON.parse(result));
} catch (error) {
reject(error);
}
});
}
}
이 구현에서 window.BridgeApi.bridgeRequest
는 안드로이드의 WebView에서 제공하는 JavascriptInterface 메서드를 호출합니다.
3. 비동기 처리 문제 발생
초기 구현을 테스트해본 결과, 예상치 못한 문제가 발생했습니다.
웹뷰가 멈추거나 응답을 받지 못하는 등의 문제가 발생한 것입니다.
이는 window.BridgeApi.bridgeRequest
호출이 동기적으로 이루어져, JavaScript의 메인 스레드를 블로킹하고 있었기 때문으로 추측 됩니다.
당연하게도 백엔드 로직의 처리시간이 길어지는 경우, 해당 문제는 더욱 두드러졌습니다.
4. 문제점 파악 및 원인 분석
문제의 핵심은 다음과 같았습니다:
- JavascriptInterface의 메서드는 기본적으로 동기적으로 동작합니다.
- 동기 호출은 JavaScript의 단일 스레드 특성 때문에 전체 UI를 블로킹할 수 있습니다.
- 네트워크 요청이나 복잡한 연산은 상당한 시간이 소요될 수 있어, 사용자 경험을 크게 저하시킬 수 있습니다.
이 문제를 해결하기 위해서는 비동기적인 방식으로 JavascriptInterface를 호출하고, 그 결과를 받아올 수 있는 메커니즘이 필요했습니다.
5. 해결 방법 구현
문제를 해결하기 위해 머리를 열심히 써보았으나, 제 짧은 실력으로는 해결할 수 없었습니다 ㅠㅠ
결국 저의 사랑 존경하는 사수님에게 도움을 받아 프로미스를 캐싱하고 백엔드에서 비동기 작업이 끝나면, 그 결과에 따라 캐싱된 프로미스를 resolve하거나 reject하는 식의 아이디어를 얻을 수 있었습니다.
해결 방법을 정리하면 아래와 같습니다.
- 각 요청마다 고유한 ID를 생성합니다.
- 이 ID와 함께 요청을 보내고, 즉시 Promise를 반환합니다.
- 안드로이드 측에서 작업이 완료되면, 이 ID를 사용하여 JavaScript 측에 결과를 전달합니다.
- JavaScript 측에서는 ID에 해당하는 Promise를 찾아 resolve 또는 reject합니다.
구체적인 구현은 다음과 같습니다:
const promiseMap: Map<string, { resolve: (result: any) => void; reject: (reason?: any) => void }> = new Map();
const generateUniqueCallbackId = (): string => {
const timestamp = Date.now().toString(16);
const random = Math.floor(Math.random() * 0xffff).toString(16);
return `promise_${timestamp}_${random}`;
}
const callBridgeApiFunction = async <ReqT, ResT>(
apiCommonRequest: ApiCommonRequest<ReqT>,
timeout: number = 5000,
): Promise<ResT> => {
const promiseId = generateUniqueCallbackId();
return new Promise<ResT>((resolve, reject) => {
const timeoutId = setTimeout(() => {
promiseMap.delete(promiseId);
reject(new Error(`Timeout after ${timeout}ms`));
}, timeout);
try {
const jsonString = JSON.stringify(apiCommonRequest);
window.BridgeApi.bridgeRequest(promiseId, jsonString);
promiseMap.set(promiseId, {
resolve: (result: ResT) => {
clearTimeout(timeoutId);
resolve(result);
},
reject: (e: any) => {
clearTimeout(timeoutId);
reject(e);
}
});
} catch (error) {
clearTimeout(timeoutId);
promiseMap.delete(promiseId);
reject(error);
}
});
}
window.resolveAsyncPromise = (id: string, result: any) => {
if (!promiseMap.has(id)) {
console.warn(`Promise with ID ${id} not found`);
return;
}
const {resolve} = promiseMap.get(id)!;
resolve(JSON.parse(result));
promiseMap.delete(id);
};
window.rejectAsyncPromise = (id: string, error: any) => {
if (!promiseMap.has(id)) {
console.warn(`Promise with ID ${id} not found`);
return;
}
const {reject} = promiseMap.get(id)!;
reject(JSON.parse(error));
promiseMap.delete(id);
};
이 구현에서 window.BridgeApi.bridgeRequest
는 이제 콜백 ID와 요청 데이터를 받아 즉시 반환합니다. 안드로이드 측에서는 작업이 완료되면 window.resolveAsyncPromise
또는 window.rejectAsyncPromise
를 호출하여 결과를 전달합니다.
6. 마치며
이러한 접근 방식을 통해 우리는 다음과 같은 이점을 얻을 수 있었습니다:
- 비동기 작업으로 인한 UI 블로킹 문제 해결
- Promise 기반의 깔끔한 API 유지
- 타임아웃 처리 기능 추가
결과적으로, 우리의 Bridge-Api 클라이언트는 다음과 같이 사용할 수 있게 되었습니다:
const api = BridgeApi.create({
headers: {"Authorization": "Bearer token"},
timeout: 5000,
});
api.get('/users/1')
.then(user => console.log(user))
.catch(error => console.error(error));
이번 경험을 통해 다음과 같은 교훈을 얻을 수 있었습니다:
- 플랫폼 간 통신의 복잡성: 웹뷰와 네이티브 코드 간의 통신은 생각보다 복잡할 수 있습니다. 동기/비동기 처리의 차이, 스레딩 모델의 차이 등을 잘 이해해야 합니다.
- 사용자 경험을 항상 최우선으로: 기술적으로 작동하는 솔루션이라도 사용자 경험을 해치면 의미가 없습니다. UI 블로킹은 유저 경험을 저해할 수 있습니다.
- 비동기 프로그래밍의 중요성: JavaScript에서 비동기 프로그래밍은 매우 중요합니다. Promise, async/await 등의 개념을 잘 이해하고 활용해야 합니다.
- 에러 처리와 타임아웃의 중요성: 네트워크 요청 등의 비동기 작업에서는 항상 실패 가능성을 고려해야 합니다. 적절한 에러 처리와 타임아웃 설정은 필수입니다.
이번 경험은 단순히 기능 구현에 그치지 않고, 실제 사용 환경에서의 성능과 사용자 경험까지 고려해야 한다는 것을 일깨워 주었습니다.
앞으로도 이러한 경험들을 바탕으로 더 나은 개발자가 되기 위해 노력하겠습니다.
다음 글에서는 bridge-api를 구현하는 과정에서 욕심이 생겨 추가한 기능들인 데코레이터와 에러핸들러 등에 대해 작성해 보겠습니다.
긴 글 읽어주셔서 감사합니다!
+ 라이브러리 레포지토리 : https://github.com/shiniseong/bridge-api