콜백 함수란? 다른 함수에 건네주는 함수
자바스크립트에서 함수는 하나의 값입니다. 변수에 담을 수도 있고, 배열에 넣을 수도 있죠. 그리고 지금 우리에게 가장 중요한 점은 — 함수를 다른 함수의 인자로 넘길 수 있다는 것입니다. 이렇게 나중에 호출해 달라고 다른 함수에 넘겨주는 함수를 바로 콜백(callback) 함수 라고 부릅니다.
greet 함수는 formatter가 뭘 하는지 알지도 못하고 신경 쓰지도 않습니다. 그냥 이름을 넘겨서 호출하고 결과를 받아 쓸 뿐이죠. 어떤 동작을 할지는 어떤 콜백을 넘기느냐에 따라 달라집니다. 콜백 함수가 존재하는 이유가 바로 이 유연성 때문입니다.
동기 콜백은 지금 당장 실행된다
모든 콜백이 비동기인 건 아닙니다. 이미 자주 쓰고 있는 배열 메서드들 상당수가 콜백 기반인데, 이때 콜백은 동기적으로 실행됩니다. 즉, 바깥쪽 함수 호출이 끝나기 전에 콜백이 먼저 다 돌아간다는 뜻이죠:
map, filter, reduce는 모두 콜백을 받아서 각 요소마다 한 번씩, 그 자리에서 바로 호출합니다. map이 반환되는 시점에는 이미 모든 콜백 호출이 끝나 있죠. 나중에 실행하려고 어딘가에 쌓아두는 게 아닙니다.
이건 그냥 평범한 고차 함수 패턴입니다. "할 일은 이거고, 처리 방법은 이건데, 결과만 돌려줘" 같은 식이죠. 이벤트 루프 같은 건 전혀 끼어들지 않습니다.
비동기 콜백은 나중에 실행된다
보통 사람들이 "콜백"이라고 말할 때 떠올리는 건 비동기 콜백입니다. 시간이 걸리는 API — 타이머, 네트워크 요청, 파일 읽기 같은 것들 — 에 함수를 넘겨두면, 그 작업이 끝났을 때 API가 여러분의 함수를 다시 호출해 주는 방식이죠.
출력 순서는 이전, 이후, 그리고 1초 뒤의 타이머 실행됨입니다. setTimeout은 프로그램을 멈추지 않아요. 콜백을 런타임에 넘겨주고 바로 리턴한 뒤, 나머지 스크립트는 계속 실행됩니다. 1초가 지나면 이벤트 루프가 그 콜백을 집어 들어 실행하는 거죠.
이 "지금은 그냥 리턴하고, 나중에 다시 불러준다"는 흐름이 자바스크립트의 모든 비동기 콜백 API를 이해하는 기본 모델입니다. addEventListener부터 예전 Node.js의 파일 API까지 전부 같은 패턴이에요.
error-first 콜백 컨벤션 (Node.js)
프로미스가 등장하기 전, Node.js는 콜백의 형태를 하나로 통일했습니다. 첫 번째 인자는 에러(없으면 null), 나머지가 실제 결과값이라는 규칙이죠. 오래된 코드나 일부 라이브러리에서는 지금도 이 방식을 심심찮게 만날 수 있습니다.
호출하는 쪽에서는 err를 먼저 확인해서 값이 있으면 바로 빠져나가고, 그게 아닐 때만 결과값을 사용합니다. 언어 차원에서 강제하는 건 아니지만 일종의 관례인데, (err, result) => ... 시그니처를 한 번 눈에 익혀두면 어디서든 금방 알아볼 수 있을 거예요.
콜백 지옥(Callback Hell)
문제는 비동기 작업이 앞 단계의 결과에 줄줄이 의존하기 시작할 때 생깁니다. 콜백이 또 다른 콜백 안에 들어가고, 그게 다시 안으로 들어가면서 코드가 계단처럼 점점 안쪽으로 밀려 들어가게 되죠:
이것이 바로 그 유명한 "둠의 피라미드(pyramid of doom)", 즉 콜백 지옥(callback hell) 입니다. 몇 가지 이유로 다루기가 까다롭죠.
- 제어 흐름이 위에서 아래로 쭉 읽히지 않고 지그재그로 꺾입니다.
- 단계마다
if (err) return ...보일러플레이트가 반복됩니다. - 안쪽 콜백에서 예외가 터져도 바깥 콜백으로 전파되지 않아, 각 계층마다 따로 에러를 처리해야 합니다.
- 리팩터링하려면 전체 블록을 다시 들여쓰기해야 합니다.
이름 있는 함수로 분리해 어느 정도 평평하게 만들 수는 있지만, 근본 원인 — 콜백만으로 비동기 흐름을 조합하기가 어색하다 — 는 그대로 남습니다. 바로 이 문제를 풀기 위해 등장한 것이 프로미스(Promise)입니다.
알아두면 좋은 두 가지 함정
콜백을 실수로 호출하지 마세요. 콜백을 전달할 때는 함수 자체를 넘기는 것이지, 함수를 호출한 결과를 넘기는 게 아닙니다.
this를 조심하세요. 콜백이 this를 사용하는 일반 함수라면, this의 값은 콜백이 어떻게 호출되는지 에 따라 결정되지 어디서 정의됐는지와는 무관합니다. 화살표 함수를 쓰면 주변 스코프의 this를 그대로 물려받기 때문에 이 문제를 깔끔하게 피할 수 있습니다:
인라인 콜백에 화살표 함수를 기본으로 쓰는 이유가 바로 이것이다.
콜백 vs 프로미스
콜백은 여전히 동기 API(map, forEach, sort), 이벤트 리스너(element.addEventListener("click", ...)), 그리고 저수준 런타임 훅 같은 곳에서 자주 등장한다. 하지만 단일 결과를 내는 비동기 작업에서는 생태계 전반이 거의 프로미스 쪽으로 넘어왔다.
간단히 비교하면 이렇다.
- 콜백 — 직관적이고 군더더기가 없지만, 조합이 어렵다. 단계마다 에러 처리를 직접 해줘야 한다.
- 프로미스 — 미래의 결과값을 나타내는 객체다.
.then()으로 체이닝하고.catch()로 에러를 한 번에 처리하면, 그 피라미드 구조가 평평하게 펴진다.
그래도 콜백은 꼭 이해하고 있어야 한다. 프로미스 자체가 콜백 위에 세워진 구조이고, 이벤트 기반 코드에서는 콜백이 사방에 깔려 있기 때문이다. 다만 이제는 날것의 콜백으로 새로운 비동기 API를 직접 짜는 일은 거의 없다.
다음: 프로미스
프로미스는 "준비되면 이걸 실행해줘"라는 아이디어를, 전달하고 체이닝하고 조합할 수 있는 객체로 감싼 개념이다. 다음 페이지에서 다룰 주제이자, 최신 자바스크립트가 비동기 처리를 다루는 표준 방식인 async/await로 이어지는 다리이기도 하다.
자주 묻는 질문
자바스크립트에서 콜백 함수가 정확히 뭔가요?
콜백은 다른 함수에 인자로 넘겨서 나중에 호출되도록 하는 함수를 말합니다. 예를 들어 setTimeout(() => console.log('hi'), 1000)는 화살표 함수를 콜백으로 넘긴 건데, setTimeout이 이걸 보관하고 있다가 타이머가 끝나면 호출해 주죠. 자바스크립트에서 "뭔가 준비되면 이걸 실행해 줘"라는 패턴을 구현한 가장 원초적인 방식이 바로 콜백입니다.
동기 콜백과 비동기 콜백은 어떻게 다른가요?
동기 콜백은 자기를 받은 함수가 리턴되기 전에 그 자리에서 바로 실행됩니다. [1, 2, 3].map(x => x * 2)는 map이 끝나기 전에 콜백을 세 번 호출하죠. 반면 비동기 콜백은 일단 저장해 뒀다가 어떤 이벤트가 일어난 뒤에 실행됩니다. setTimeout, fs.readFile, DOM 이벤트 리스너가 모두 이 방식이고요. 비동기 콜백은 뒤에 있는 코드를 막지 않는다는 게 핵심 차이입니다.
콜백 지옥(callback hell)은 뭐고 어떻게 피하나요?
콜백 지옥은 비동기 콜백들이 서로 의존하면서 몇 단계씩 중첩돼 코드가 피라미드(삼각형) 모양으로 들여쓰기되는 상황을 말합니다. 흐름도 읽기 힘들고 에러 처리도 엉망이 되기 쉽죠. 해결책은 Promise의 .then() 체이닝으로 평탄하게 펴는 것, 더 좋게는 async/await를 쓰는 겁니다. 둘 다 피라미드를 눕혀서 동기 코드처럼 읽히게 만들어 줍니다.