likeornament 님의 블로그
콜백은 왜 신뢰할 수 없는가 - 비동기 제어권의 문제 본문
콜백 함수는 다른 코드의 인자로 넘겨주는 함수를 말한다.
하지만 콜백은 단순히 함수를 전달하는 행위에 그치지 않는다.
콜백을 넘긴다는 것은 내 코드의 실행 주도권(제어권)을 상대 코드에게 위임한다는 의미에 가깝다.
이는 마치 대리 운전을 맡기며 차 키를 건네는 상황과 비슷하다.
나는 목적지만 말할 뿐,
언제 가속하고 언제 브레이크를 밟을지 어떤 경로로 갈지는 기사님(콜백을 실행하는 코드)의 판단에 맡기게 된다.
그렇다면 콜백을 받은 쪽은 구체적으로 어떤 제어권을 가져가게 될까?
1. 호출 시점에 대한 제어권
var count = 0;
var func = function() {
console.log(count);
if (++count > 4)
clearInterval(timer);
};
var timer = setInterval(func, 300);
위 코드에서 func는 setInterval의 첫 번째 인자로 전달된다.
이제 func를 언제 실행할지에 대한 제어권은 전적으로 setInterval에게 넘어가,
제어권을 넘겨받은 setInterval이 자신의 판단에 따라 0.3초마다 실행하게 된다.
즉, 호출 시점에 대한 제어권이 넘어간 것이다.
2. 인자에 대한 제어권
다음으로 map 메서드를 살펴보자.
Array.prototype.map(useCallback, [thisArg])
callback: function(currentValue, index, array);
map 메서드는 대상이 되는 배열의 요소들을 순서대로 하나씩 꺼내어 콜백 함수를 반복 호출하고,
함수의 실행 결과들을 모아 새로운 배열을 반환한다.
콜백 함수를 호출할 때 다음과 같은 규칙을 가진다.
- 첫 번째 인자: 현재 요소의 값
- 두 번째 인자: 인덱스
- 세 번째 인자: map 메서드의 대상이 되는 배열
이 순서는 사용자가 정하는 것이 아니라 map 메서드가 결정한다.
var newArr = [10, 20, 30].map(function (currentValue, index) {
console.log(currentValue, index);
return currentValue + 5;
});
// 실행 결과
// 10 0
// 20 1
// 30 2
// 콜백 함수 인자의 이름을 바꿈
var newArr2 = [10, 20, 30].map(function (index, currentValue) {
console.log(index, currentValue);
return currentValue + 5;
});
// 실행 결과
// 10 0
// 20 1
// 30 2
콜백 함수의 매개변수 이름을 바꿔도 결과는 변하지 않는다.
콜백 함수는 이름이 아니라 순서로 인자를 받기 때문이다.
즉, 콜백을 호출하는 쪽이 인자의 구성과 순서를 통제하며,
이는 곧 인자에 대한 제어권이 넘어간 것이다.
3. this에 대한 제어권
콜백 함수도 역시 함수이기 때문에 기본적으로 this 는 전역 객체를 참조한다.
하지만 map처럼 두 번째 인자로 thisArg 를 받는 메서드의 경우,
콜백 내부의 this 의 제어권도 수신 함수에게 넘어간다.
정리해보면 콜백 함수는 다음 세 가지 제어권을 넘긴다.
- 언제 실행될지
- 어떤 인자를 어떤 순서로 받을지
- 무엇을 this로 참조할지
여기서 한 가지 궁금증이 생겼다.
이런 제어권을 정말 다른 코드에게 맡겨도 괜찮을까?
대리운전 기사님께 차 키를 맡겼는데,
만약 그 기사가 미숙하거나 실수한다면 어떤 일이 벌어질까?
콜백을 실행하는 외부 코드가 버그를 가지고 있다면 상황은 심각해진다.
- 너무 빨리 호출되면 아직 준비되지 않은 상태에서 로직이 실행됨
- 너무 늦게 호출되거나 호출되지 않으면 프로그램은 영원히 대기 상태에 빠짐
- 여러번 호출되면 결제 시스템에서는 중복 결제 같은 치명적인 문제가 발생할 수 있음
즉, 콜백 패턴은 상대의 코드가 완벽하게 동작할 것이라는 낙관주의에 의존하는 구조다.
콜백 패턴의 가장 큰 문제는 비동기 실행 결과를 신뢰할 수 없다는 점이다.
콜백을 넘긴 순간 우리는 더 이상 다음을 보장받지 못한다.
- 콜백이 언제 실행될지
- 콜백이 몇 번 실행될지
- 콜백이 아예 실행되기는 할지
이 모든 것은 콜백을 호출하는 외부 코드의 구현에 달려 있다.
// 외부 라이브러리 (수정 불가)
function unsafePayment(amount, callback) {
callback(amount); // 정상 호출
callback(amount); // 버그: 중복 호출
}
이 함수는 내부에서 버그가 발생해,
콜백을 한 번만 호출해야 한다는 약속을 지키지 못할 것이다.
하지만 콜백 패턴에서는 이 약속이 지켜지지 않을 것임을 모른다.
unsafePayment(10000, (amount) => {
console.log(`${amount}원 결제!`); // 두 번 실행됨
});
사용자 입장에서 보면 이 코드는 매우 정상적으로 보인다.
하지만 실행 결과는 결제가 두 번 발생한다.
즉, 문제의 원인은 사용자 코드가 아니라 외부 코드에 있음에도 피해는 고스란히 사용자에게 돌아온다.
이것이 콜백 기반 비동기의 구조적 문제다.
비동기 작업의 성공 여부, 실행 횟수, 종료 시점을 사용자는 전혀 통제할 수 없다.
이러한 콜백의 불신뢰성을 해결하기 위해 등장한 개념이 Promise다.
Promise의 핵심은 "비동기 작업의 결과에 대한 계약"이다.
Promise는 다음 중 하나의 상태로만 결정된다.
- pending: 아직 결과가 정해지지 않음
- fulfilled: 성공적으로 완료됨
- rejected: 실패함
그리고 가장 중요한 규칙이 있다.
Promise는 단 한 번만 상태가 변경된다.
function safePayment(amount) {
return new Promise((resolve, reject) => {
unsafePayment(amount, (res) => {
resolve(res);
});
});
}
여기서 핵심은 unsafePayment가 아니라,
Promise를 생성하고 반환하는 주체가 사용자라는 점이다.
외부 라이브러리가 콜백을 몇 번 호출하든 Promise의 resolve는 최초 한 번만 의미를 갖고,
두 번째 호출부터는 무시된다.
즉, Promise는 외부 코드의 행동을 신뢰하지 않고, 결과 확정에 대한 주도권을 사용자 코드가 다시 가져오는 장치다.
safePayment(10000)
.then((amount) => {
console.log(`안전하게 ${amount}원 결제 완료!`);
});
이제 사용자는 다음을 확신할 수 있다.
- 결제 결과는 반드시 한 번만 전달된다.
- 성공과 실패가 명확히 구분된다.
- 비동기 작업이 끝났다는 시점을 신뢰할 수 있다.
이 신뢰성 확보가 Promise의 본질이다.
async / await는 Promise를 더 쉽게 다루기 위한 문법적 도구지만,
그 의미는 단순한 가독성 개선에 그치지 않는다.
async function processPayment() {
console.log("결제를 시작합니다...");
try {
const amount = await safePayment(10000);
console.log(`[확정] ${amount}원 결제가 안전하게 완료되었습니다.`);
updateUserInventory();
} catch (error) {
console.error("결제 중 오류 발생:", error);
}
}
async/await를 사용한다면 아래의 이점을 취할 수 있다.
① 동기적 사고의 회복
콜백 패턴에서는 코드의 흐름이 이벤트에 의해 잘게 분리된다.
"이 작업이 끝나면 이걸 실행해줘"라는 사고방식은 코드의 실행 순서를 머릿속에서 계속 추적해야 한다.
반면 await는 비동기 작업의 완료 시점을 코드 상에서 명시적으로 고정한다.
이 줄에서 실행은 잠시 멈추고, 결과가 확정된 뒤에 다음 줄로 이동한다.
이는 우리가 일반적인 동기 코드처럼 위에서 아래로 사고할 수 있게 만든다.
② 에러 처리의 일관성
콜백 기반 코드에서는 에러가 여러 위치에서 발생할 수 있다.
각 콜백마다 에러 인자를 확인해야 하고, 누락되기 쉽다.
async / await는 Promise의 실패를 예외로 전환한다.
그 결과, 동기 코드에서 사용하던 try-catch 패턴을 그대로 비동기 로직에 적용할 수 있다.
이는 에러 처리의 위치를 분산시키지 않고 한 지점으로 모은다.
③ 제어 흐름의 주도권
가장 중요한 변화는 제어권이다.
콜백 패턴에서는 외부 코드가 언제 콜백을 호출할지 결정한다.
하지만 await를 사용하는 순간,
사용자는 실행 흐름의 분기 시점을 본인이 결정하는 주도권을 행사할 수 있다.
이것이 async / await가 제공하는 완전한 주도권이다.
결론
콜백 함수에는 제어권을 상실한다는 구조적 한계가 존재한다.
Promise와 async / await는 단순히 코드를 예쁘게 만들기 위해 등장한 것이 아닌,
콜백 함수의 신뢰할 수 없는 비동기 실행을, 신뢰할 수 있는 구조로 바꾸기 위함이었다
이번 정리를 통해,
무심코 사용하던 async / await가 사실은 제어권을 되찾기 위한 도구였다는 점을 이해하게 되었다.
그리고 좋은 개발자는 제어권을 넘길 줄 아는 사람이 아니라,
제어권을 넘겼을 때 벌어질 최악의 상황까지 대비할 줄 아는 사람이라는 생각이 들었다.
'공부 > javascript' 카테고리의 다른 글
| 프로토타입의 메모리 효율성 vs 클로저의 데이터 은닉 (1) | 2026.01.22 |
|---|---|
| 클로저라는 기반 위에 쌓아 만든 useState (0) | 2026.01.21 |
| this는 왜 스코프 규칙을 따르지 않을까 (0) | 2026.01.15 |
| VariableEnvironment는 왜 ‘쓰이지 않도록’ 설계되었을까 (0) | 2026.01.13 |
| 왜 자바스크립트는 깊은 복사를 기본으로 제공하지 않을까? (0) | 2026.01.12 |