나는 그동안 콜백 함수를 이 정도로 이해하고 있었다.
- 다른 함수의 인자로 넘길 때 사용하는 함수
- 서버 응답이나 비동기 처리 결과를 받아서 활용할 때 쓰는 함수
하지만 '컴퓨터 밑바닥의 비밀'이라는 책을 읽으면서 콜백 함수의 본질에 대해 더 깊이 이해할 수 있었고, 다른 주니어 개발자에게도 도움이 될 것 같아 글로 남겨본다.
문제의 시작
한 도넛 회사에서 도넛을 만들 수 있는 API를 제공하려고 한다. 고객사는 make_donut이라는 함수를 호출해 도넛을 만들 수 있다.
void make_donut()
{
formed(); // 도넛이 형성된다
...
}
고객사가 점점 많아지면서 이런 문제가 생겼다. A 고객사는 네모난 모양의 도넛을 만들고 싶고, B 고객사는 별 모양, C 고객사는 동그란 도넛을 만들고 싶었다. 도넛 회사는 API를 이렇게 수정했다.
void make_donut()
{
if (CustomerA) {
formed_A();
}
else if (CustomerB) {
formed_B();
}
else if (Customer) {
formed_C();
}
...
}
만약 수천 개의 고객사가 생긴다면 계속 if else 문을 사용할 수 있을까? 고객사가 생길 때마다 make_donut 함수를 매번 수정해야 할 것이다. 뭔가 다른 설계가 필요하다.
콜백이 필요한 이유
변수를 생각해보자. 숫자 10을 직접 사용하는 대신 a 변수를 사용하면, 숫자 10을 다른 숫자로 바꿔야 할 때도 다른 코드를 변경할 필요가 없다.
int a = 10;
그렇다면 매번 달라지는 formed 함수를 변수처럼 사용하면 어떨까?
void make_donut(func f)
{
...
f();
...
}
이렇게 하면 도넛 회사는 매번 고객사의 요구에 맞춰 코드를 바꿀 필요가 없다. 이제 make_donut 함수를 사용하고 싶은 고객사는 자신이 원하는 도넛 모양을 만들어 전달만 하면 된다. 고객사 A는 이렇게 자체 함수를 정의해 넘긴다.
void formed_A()
{
...
}
make_donut(formed_A);
우리는 이것을 콜백 함수라고 부른다.
비동기 콜백
도넛 사업이 너무 잘되다보니 주문량이 많아지면서 make_donut 함수의 실행 시간이 점점 길어졌다. 고객사 C는 도넛을 만든 후 중요한 작업을 해야 하는데 마냥 기다리고 있어야 한다.
...
make_donut(customer_C); // 30분 대기
something_important(); // 중요한 작업
다른 부서와 통화를 한 뒤 남은 업무를 처리하려고 하는데, 30분간 통화가 안 끝나는 상황이다.
사실 make_donut 함수를 다음과 같이 조금 수정해 함수 내부에서 스레드를 생성하고 해당 스레드가 실제로 도넛을 형성하게 할 수 있다.
void real_make_donut(func f)
{
...
f();
...
}
void make_donut(func f)
{
thread t(real_make_donut, f);
}
고객사가 많다면 스레드 풀 등 다른 방법을 사용하겠지만, 상세 구현은 다루지 않는다. 자바스크립트에선 개발자가 스레드를 다루지 않으므로 async/await로 생각하자.
여기에서 주의할 점은 something_important 함수가 실행될 때 실제 도넛 생성 작업은 아직 시작되지 않았을 수 있다. 바로 이것이 비동기(asynchronization) 다.
이렇게 하면 고객사는 make_donut 함수를 호출하고 나서 30분 동안 기다릴 필요가 없고, 각자의 스레드에서 작업이 병렬로 실행될 수 있다. 이와 같이 호출 스레드가 콜백 함수 실행에 의존하지 않는 것을 비동기 콜백이라고 한다. A 고객사는 formed_A 함수가 언제 실행되는지 몰라도 된다.
새로운 프로그래밍 사고방식
우리는 함수를 호출하고 결과를 획득한 뒤, 이 결과를 처리하는 동기적인 사고방식에 익숙하다.
res = request();
// wait
handle(res);
하지만 정보 관점에서 보면, 함수는 사실 호출자가 정보를 채워 넣기 전까지는 매개변수 정보가 무엇인지 알 수 없다.
void make_donut(func f) // f가 뭔데?
{
}
여기서 매개변수 정보는 정수, 포인터, 구조체, 객체 등의 데이터가 될 수도 있고, 함수가 될 수도 있다.
따라서 handle 함수를 직접 호출하는 대신 다음과 같이 request 함수의 매개 변수로 전달할 수 있다.
request(handle);
이제 우리는 handle 함수가 언제 실행될지는 아예 신경 쓸 필요가 없다.
TIP) 자바스크립트에서 함수는 일급 객체다. 따라서 함수 '객체'의 참조를 넘기고, 함수 객체에는 클로저에 대한 정보들도 들어있기 때문에 외부 함수를 참조할 수 있다.
하지만 C의 함수는 일급 객체가 아니며, 함수 '코드'가 있는 메모리 주소를 넘긴다. 따라서 외부 변수를 캡처할 수 없다. C++는 람다로 이 문제를 해결했다.
프로그래밍 관점에서 보면, 비동기 호출과 동기 호출은 매우 큰 차이가 있다. 동기 호출은 함수를 호출한 스레드에서 전체 작업이 처리되는 반면, 비동기 호출은 작업 처리가 두 부분으로 나뉜다. 따라서 두번째 부분은 우리가 제어할 수 있는 범위를 벗어난다.
이제 콜백 함수의 본질을 이렇게 정리할 수 있다.
'우리는 어떤 일을 해야 하는지 알지만, 이 일을 언제 하게 될지는 정확히 알 수 없다. 반면에 다른 모듈은 언제 해야 할지는 알지만 무엇을 해야 하는지는 모르기 때문에 우리가 알고 있는 정보를 콜백 함수에 담아 다른 모듈에 전달한다.'
콜백 함수와 호출자가 서로 다른 계층에 존재한다는 것이다. 고객사의 프로그램에는 주요 코드와 도넛 모양 레시피인 formed_A라는 콜백 함수가 있으며, 도넛 회사의 프로그램에는 이 콜백 함수를 받아 실행하는 make_donut 함수가 있다.
이 콜백 함수는 주로 이벤트가 발생될 때 호출된다.
button.addEventListener('click', openModal);
네트워크 데이터 수신, 파일 전송 완료처럼 관심 대상인 이벤트가 발생하면 이를 처리할 수 있는 코드를 호출한다.
비동기 콜백의 문제, 콜백 지옥
특정 작업을 처리하려면 서비스 네 개를 호출해야 하며, 각 서비스는 앞서 호출한 서비스의 결과를 사용하여 처리한다고 가정해보자. 이를 동기 콜백 방식으로 구현하면 다음과 같다.
a = GetServiceA();
b = GetServiceB(a);
c = GetService(b);
d = GetService(c);
그래도 명확하다. 하지만 비동기 콜백 방식으로 작성하면 어떨까?
GetServiceA((a) => {
GetServiceB(a, (b) => {
GetServiceC(b, (c) => {
GetServiceD(c, (d) => {
...
})
})
})
})
이처럼 좀 더 복잡한 비동기 콜백 코드는 주의를 기울이지 않으면 콜백 함정에 빠질 수 있다. 자바스크립트는 Promise와 async/await를 통해 이 문제를 해결한다.
동기 호출과 비동기 호출
익숙한 웹 서버를 예로 들어 동기와 비동기의 차이를 살펴보자. 사용자 요청을 처리하기 위해 A, B, C 세 단계를 거친 후 데이터 베이스를 요청하고, 요청 처리가 완료되면 다시 D, E, F 세 단계를 거쳐야 작업이 완료된다고 해보자.
A;
B;
C;
데이터베이스 요청; // 입출력 작업 필요
D;
E;
F;
차이가 있을 수 있지만 일반적으로 이런 형태의 웹 서버에는 주 스레드와 데이터베이스 처리 스레드라는 전형적인 스레드 두 개가 있다.
동기 방식의 코드를 살펴보자.
main_thread()
{
while (1)
{
요청 수신;
A;
B;
C;
데이터베이스 요청 전송 후 결과 반환될 때까지 대기;
D;
E;
F;
}
}
database_thread()
{
while (1)
{
요청 수신;
데이터베이스 처리;
결과 반환;
}
}
데이터베이스 요청 후 메인 스레드는 블로킹되어 일시 중지되며, 데이터베이스 처리가 완료된 시점에서 D, E, F가 계속 실행된다. 이렇게 결과를 기다리는 시간을 유휴 시간(idle time)이라고 한다.
비동기 방식은 어떨까? 비동기 구현에서는 메인 스레드가 데이터베이스 처리가 완료될 때까지 기다리는 대신, 데이터베이스 처리 요청을 전송하자마자 바로 다음에 넘어온 새로운 사용자 요청을 처리한다.
그럼 이전 요청의 나머지 D, E, F는 어떻게 처리해야 할까? 이때 두 가지 상황이 존재한다.
첫 번째 상황: 메인 스레드가 데이터베이스 처리 결과를 전혀 신경쓰지 않을 때
이때는 데이터베이스 스레드가 다음 D, E, F 세 단계를 직접 처리하면 된다. 하지만 데이터베이스 스레드가 D, E, F 세 단계에 대한 처리 방법을 모르기 때문에 콜백을 넘겨 해결할 수 있다.
void handle_DEF_after_DB_query()
{
D;
E;
F;
}
이제 주 스레드는 데이터베이스 처리 요청을 보낼 때 이 함수를 매개변수로 전달한다.
DB_query(request, handle_DEF_after_DB_query);
이것이 바로 콜백 함수가 하는 일이다. D, E, F 세 작업을 콜백 함수에 담아 전달하는 이유는 소프트웨어 조직 구조 관점에서 볼 때 데이터베이스 스레드에서 해야 할 작업이 아니기 때문이다. 데이터베이스 스레드는 이 작업이 뭔지 알 필요도 없고, 호출해야 하는 특정 시점에 호출하면 자신의 임무를 완료한 것이다. 이렇게 하면 다양한 사용자 요청에 유연하게 대응할 수 있다.
두 번째 상황: 메인 스레드가 데이터베이스 처리 결과에 관심을 가질 때
이 경우에는 데이터베이스 스레드가 작업을 마무리하면, 메인 스레드에 완료 알람을 보내고, 메인 스레드는 메시지를 수신해 이전 사용자 요청의 후반부를 계속 처리한다.
첫 번째 상황만큼 극단적으로 효율적이진 않지만, 이 경우에도 유휴 시간이 없기 때문에 동기 호출에 비하면 여전히 효율적이다. 물론 비동기 호출에도 오버헤드가 있기 때문에 구체적인 상황에 따라 분석해야 한다.
마치며
콜백 함수가 왜 필요한지 더 근본적으로 생각해볼 수 있었고, 스레드와 함께 계층적인 관점에서도 바라볼 수 있었다. CS를 공부할수록 운영체제 같은 로우 레벨의 개념들이 프로그래밍 패턴에 그대로 반영되는 있다는 걸 느낀다. CS 잘하고 싶다 !!