비동기 통신을 말하기 전 비동기란 개념에 대해 다뤄보겠습니다.
비동기라는 개념은 처음 접할 때 어색하게 느껴질 수 있습니다. 특히 기존에 알고 있던 동기화
, 동기적
같은 익숙한 용어와 비교했을 때 더 어색함이 크게 느껴졌습니다.
저의 경우 비- 라는 접두어가 붙는 용어는 이 접두어를 떼어낸 원어의 뜻을 먼저 떠올려 이를 부정함으로써 뜻을 유추해내려합니다.
비동기를 예로 들자면 비동기에서 비- 를 떼어내 동기의 뜻을 먼저 구한 뒤 이를 부정하여 유추합니다.
먼저 동기
를 설명하자면 시스템에서 일관성, 정합성을 맞추기 위해 상태를 같게 만드는 것입니다.
그리고 비동기
를 설명하자면 흔히 순서를 보장하지 않는 호출 이라고 합니다.
비동기
는 동기
의 반대되는 말 같은데 기존에 알고 있던 동기
개념을 부정하여 비동기
를 이해하려하면 매끄럽지가 않습니다.
그래도 동기
를 순서를 보장하는 호출 이라는 뜻으로 기존 뜻과 끼워맞추면 어느 정도 납득이 되긴 합니다.
기존 동기
의 시스템에서 일관성, 정합성을 지키기 위해 상태를 같게 하는 '행위' 라는 뜻에서 말하는 구체적인 '행위'
가 순서 보장
이라 생각하면 저는 나름 납득이 갔습니다.
이제 다음과 같이 생각할 수도 있습니다.
반대로 비동기
는 순서를 보장하지 않으니 일관성과 정합성을 깰 수 있다는 말입니다.
const fetchData = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve("데이터 로드 완료");
}, 2000);
});
};
const main = async () => {
const data = fetchData(); // await를 사용하지 않음
console.log("데이터:", data); // 이 시점에서 data는 Promise 객체임
};
main();
비동기 호출 이후 완료 여부와 무관하게 다음 로직 수행
위에서 보았듯 잘못된 비동기 호출은 문제를 일으킬 수 있습니다.
이외 비동기 호출의 특성에 대해서 생각해 보겠습니다.
빠른 응답
모든 상황에 적합한 것은 아니지만 푸시 알림이나 이벤트 발행 같은 경우엔 수행 순서가 중요하지 않아 비동기 호출 이후 바로 다음 로직을 진행해도 상관 없습니다.
성능 향상
오랜 시간이 걸리는 API 호출을 한 번에 전부 요청해 전체 응답 시간을 낮출 수 있습니다.
느슨한 결합
비동기 호출을 한 이후엔 직접적인 예외 전파나 작업 결과로부터 무관해집니다. 논리적으로도 완전히 무관해지는 것은 아니라 이를 위해 정책을 잘 세워야합니다. 기능 별 폴백이나 보상 작업 등.
복잡한 예외 처리
이미 클라이언트 로직이 완료된 이후에 비동기 호출이 실패해버린다면 이를 처리하기 위한 작업을 해야하는데 이미 완료된 작업에 영향을 줘야해 복잡도가 높습니다.
디버깅 어려움
순서가 보장되지 않아 로그와 실행 흐름을 파악하기 어렵습니다.
보통 맨 처음 접하게 될 방법은 콜백 패턴
입니다.
콜백 패턴
에서의 콜백 함수
는 인자로 넘겨 그 내부에서 실행될 함수 를 의미합니다. 따라서 콜백 패턴
은 이 콜백 함수
를 사용하는 코딩 양식이 되겠습니다.
콜백 패턴을 요약하자면 다음과 같습니다.
이 함수가 언제 끝날지 모르겠으니 함수에게 다음과 같이 명령을 하는 것입니다.
너가 언제 끝나는지 모르겠어. 너가 성공하면 어떻게 해야하는지 알려줄테니 일 끝나면 너 알아서 이렇게 해
똑똑한 컴퓨터는 정말 이렇게 동작해 줍니다.
function fetchData(callback) {
setTimeout(() => {
console.log("데이터를 가져왔습니다.");
callback();
}, 1000);
}
function processData() {
console.log("데이터를 처리합니다.");
}
fetchData(processData);
하지만 콜백 패턴은 특성 상 콜백 함수가 깊어지면 가독성이 급격하게 떨어진다는 단점이 있습니다. (콜백 지옥)
fetchData((err) => {
if (err) {
console.error("에러 발생:", err);
return;
}
processData((err) => {
if (err) {
console.error("에러 발생:", err);
return;
}
saveData((err) => {
if (err) {
console.error("에러 발생:", err);
return;
}
console.log("모든 작업 완료!");
});
});
});
첫 함수에 내부적으로 호출할 콜백 함수들을 전부 달아두는 흐름입니다.(체이닝 패턴) 첫 함수에 전부 달아두는 탓에 콜백 지옥의 들여쓰기에 의한 가독성 저하 문제는 완화합니다. (들여쓰기가 동일해집니다.)
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("1단계: 데이터를 가져왔습니다.");
resolve();
}, 1000);
});
}
function processData() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("2단계: 데이터를 처리했습니다.");
resolve();
}, 1000);
});
}
function saveData() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("3단계: 데이터를 저장했습니다.");
resolve();
}, 1000);
});
}
fetchData()
.then(processData)
.then(saveData)
.then(() => {
console.log("모든 작업 완료!");
});
기존 콜백 패턴이 가지던 가독성 문제는 잘 완화했지만 비동기 로직이 가지는 동기 방식과의 이질적인 코드는 여전히 진입 장벽을 높입니다.
비동기 로직을 동기 로직처럼 작성할 수 있게 도와주는 syntax sugar 입니다.
자세히 공부하진 않아 짐작하는 것이지만 생긴건 동기 로직 같아도 실제론 비동기 논블로킹 로직처럼 CPU 자원 점유 없이 기대한대로 동작할 것입니다.
async function run() {
try {
await fetchData();
await processData();
await saveData();
console.log("모든 작업 완료!");
} catch (err) {
console.error("에러 발생:", err);
}
}
run();
try catch 로 처리하는 비동기 로직이라니 기적이네요!
비동기 함수형 프로그래밍의 난이도를 높이는 원인 중 하나가 명시적으로 예외 처리 콜백을 넘겨야한다는 것인데 이런 부분을 일괄적으로 핸들링해 비동기 로직에선 오직 성공했을 때의 로직만을 다룰 수 있도록 설계하면 좋은 코드 상태를 유지할 수 있다 하네요.
전 성공해본적이 없습니다.
모든 피드백 감사히 잘 받겠습니다.