Home
All Posts
🏭

콜백 함수는 불닭볶음면 공장에서 시작된다

Created
2024/05/23
Tags
JavaScript
Category
Knowledge
2 more properties
평소 콜백 함수, 프로미스, 비동기 동기 개념을 따로따로 이해하고 있어서 머릿속에 잘 정리되지 않는 느낌이었는데요. 다시 보니 이들은 꽤나 촘촘하게 엮여있더라고요. 이번 포스팅에서는 위 개념들을 비유를 들어 최대한 쉽게 설명해 보았습니다. 책 <컴퓨터 밑바닥의 비밀>의 내용을 참고했습니다.

필요에 의해 생긴 불닭

삼양에서 불닭볶음면을 만들기 위해 공장을 차렸다. 혹시 모르니 판매처를 늘리진 않고 쿠팡에만 팔아보기로 한다.
그런데 출시 직후부터 인기가 심상치 않다. 쏟아지는 물량을 대비하기 위해 삼양에서는 불닭볶음면을 만드는 핵심 모듈을 개발하고, 쿠팡에서는 이를 호출만 할 수 있도록 세팅해 두었다.
출처: 헤럴드 경제
이 핵심 모듈은 makeBuldak 이라는 이름의 함수에 담겨(encapsulation)있다. 이 함수의 핵심 역할은 불닭볶음면을 만들어내는 것이다. makeBuldak 함수가 빠르게 실행되고 즉시 반환된다면 쿠팡에서는 아래와 같이 간단히 해당 함수를 호출하기만 하면 된다. 생산 속도가 더욱 빨라져 쏟아지는 물량을 대비할 수 있을 것으로 기대된다.
function makeBuldak() { formed(); // 불닭볶음면 만들기 }
JavaScript
복사
SNS 등을 통해 불닭볶음면 챌린지가 유행하면서 이곳저곳에서 불닭볶음면을 팔고 싶어 한다. 이에 삼양은 판매처를 확장하고자 한다.
지금까지는 쿠팡에서만 makeBuldak 함수를 사용했지만, 이제는 컬리에서도 이 함수를 사용하고 싶어 한다. 하지만 컬리는 기존 불닭볶음면은 너무 맵다며 치즈 불닭볶음면을 팔고 싶어한다. 그럼 불닭볶음면을 만드는 기존 makeBuldak 함수는 사용하지 못한다.
따라서 formed 함수는 쿠팡과 컬리 모두 사용할 수 있도록 아래와 같이 수정되어야 한다.
function makeBuldak(store) { if (store === '쿠팡') { formedBuldak(); // 불닭볶음면 만들기 } else if (store === '컬리') { formedCheeseBuldak(); // 치즈 불닭볶음면 만들기 } }
JavaScript
복사
불닭볶음면 챌린지가 국내를 넘어 전세계로 진출했다. 전 세계 사람들이 불닭볶음면을 먹고 싶어하는 것을 지켜본 아마존도 makeBuldak 함수를 사용하고 싶어 한다. 하지만 외국인들의 입맛에 맞추어 까르보 불닭볶음면을 판매하고자 한다.
아마존 역시 기존 makeBuldak 함수는 사용하지 못하므로 아래와 같이 if문을 추가했다.
function makeBuldak(store) { if (store === '쿠팡') { formedBuldak(); // 불닭볶음면 만들기 } else if (store === '컬리') { formedCheeseBuldak(); // 치즈 불닭볶음면 만들기 } else if (store === '아마존') { formedCarboBuldak(); // 까르보 불닭볶음면 만들기 } }
JavaScript
복사
그런데 삼양에서 글로벌 진출 뿐만 아니라 소스류, 스낵류, HMR 카테고리까지 확장하려고 한다. 잠시만.. 그럼 if문을 몇 개를 써야 하는거지?
출처: 삼양식품 홈페이지
이렇게 가다간 if문이 가득한 어지럽고 잘못된 코드를 작성할 것이 뻔하다.
이 문제를 어떻게 해결해야 할까?

필요에 의해 생긴 콜백

보통 코드를 작성할 때 아래와 같이 변수를 자주 사용한다.
let a = 10;
JavaScript
복사
코드에서 숫자 10을 직접 사용해 프로그래밍을 하는 것은 비효율적이다. a라는 변수를 사용하면 숫자 10을 다른 숫자로 변경해야 할 때, 해당 변수를 사용하는 다른 코드를 다 뒤져서 수정할 필요 없이 변수 값만 변경하면 된다.
이렇듯, 함수도 변수처럼 사용할 수 있다!
function makeBuldak(formed) { formed(); }
JavaScript
복사
위와 같이 하면 아마존 등 외국에 있는 불닭 판매처들은 현지화 요구에 맞추어 코드를 계속 바꿀 필요가 없다. 이제 삼양의 개발자는 makeBuldak 함수를 사용하고 싶은 곳들에게 현지화된 함수를 전달하기만 하면 된다.
예를 들어 아마존은 까르보 불닭볶음면을 만드는 함수인 formedCarboBuldak을 아래와 같이 사용하면 된다. 이렇게 하면 각 판매처의 요구에 맞추어 다양한 불닭볶음면을 손쉽게 만들 수 있다.
function formedCarboBuldak() { // ... } makeBuldak(formedCarboBuldak);
JavaScript
복사
이렇듯, 콜백 함수는 다른 코드에 매개변수로 전달되는 실행 가능한 코드다.

비동기 콜백

불닭볶음면이 전 세계적으로 워낙 잘 팔리다보니 주문량이 많아질수록 makeBuldak 함수의 실행 시간도 더 길어지고 있다.
예를 들어 마라 불닭볶음면의 판매처인 알리의 코드가 아래와 같이 작성되어 있다.
makeBuldak(formedMaraBuldak); important_code(); // 중요한 코드
JavaScript
복사
makeBuldak 함수가 호출되고 나서 무려 30분동안 important_code()가 포함된 줄이 실행되지 못하는 상황이 생겼다. 그런데 알리에서 워낙 주문량이 늘어나 이 30분이라는 시간을 기다릴 수 없다고 아우성이다.
어떻게 이 문제를 해결해야 할까?
이럴 때는 makeBuldak 함수를 수정하면 된다. 이 함수 내부에서 스레드를 생성하고, 이 스레드가 실제로 불닭볶음면을 만들게 할 수 있다.
만약 C++ 코드라면 아래와 같이 thread 타입을 사용하여 스레드를 직접 생성하면 된다. 이렇게 makeBuldak 함수를 호출하면 해당 함수는 새로운 스레드를 생성하고 나서 즉시 반환되고, 이후 important_code() 줄을 실행할 수 있다.
void real_make_buldak(func formed) { formed(); } void make_buldak(func formed) { thread t(real_make_buldak, formed); }
C++
복사
여기서 주의할 점은, important_code() 줄의 코드가 실행될 때 실제 불닭 생산 작업은 아직 시작되지 않았을 수도 있다는 것이다.
이것이 바로 ‘비동기’다.
하지만 자바스크립트는 C++의 스레드 처리 방식과 차이가 있다.
C++은 멀티 스레드 언어이기에 스레드를 쉽게 생성하고 관리할 수 있다. 반면 자바스크립트는 싱글 스레드 언어이기에 한 번에 하나의 작업만 처리할 수 있다. 다만, 자바스크립트에서는 비동기 함수(setTimeout, Promise, async/await 등)을 활용하여 비동기 작업을 처리할 수 있다.
즉, 코드를 한 줄씩 차례대로 실행하는 동기식 요청은 비효율적이므로 하나의 요청이 완료될 때까지 기다리지 않고 동시에 다른 작업을 실행하는 비동기 호출로 이를 극복하는 것이다.
따라서 자바스크립트라면 아래와 같이 setTimeout을 사용하여 비동기 작업을 처리해 주면 된다.
function realMakeBuldak(formed) { formed(); } function makeBuldak(formed) { setTimeout(() => realMakeBuldak(formed), 0); }
JavaScript
복사
야호! 이제는 makeBuldak 함수를 호출하고 30분을 기다리지 않아도 되니 생산 속도가 훨씬 빨라졌다.
출처: 헤럴드 경제
이처럼 호출 스레드가 콜백함수 실행에 의존하지 않는 것을 ‘비동기 콜백’ 이라고 한다.

동기와 비동기

함수를 호출할 때 우리에게 가장 익숙한 사고방식은 이렇다.
1. 함수를 호출하고 → 결과를 획득한다. 2. 획득한 결과를 → 처리한다.
이것이 바로 함수의 동기 호출(synchronous call)이다.
동기 호출은 함수 A가 반환한 결과를 획득해야 함수 B를 호출하여 처리를 진행할 수 있다. 이때, 함수 A가 반환되는 것을 무조건 기다려야 하는데, 이것이 바로 동기 호출이다.
반면 앞서 언급했던 함수 호출이 비동기 콜백이라면 함수 A는 즉시 반환될 수 있으며, 실제로 결과를 받아 처리하는 프로세스는 다른 스레드와 프로세스, 심지어 다른 시스템에서 완료될 수 있다.
이렇듯 프로그래밍 관점에서 동기 호출과 비동기 호출은 매우 큰 차이가 있다. 동기 호출 프로그래밍 방식에서는 함수를 호출한 스레드에서 전체 작업이 처리되는 반면, 비동기 호출 프로그래밍 방식에서는 작업 처리가 두 부분으로 나뉘는 것이다.
작업이 두 부분으로 나뉘어 있기 때문에 두 번째 호출은 우리가 제어할 수 있는 범위를 벗어난다. 동시에 호출자만이 무엇을 해야 할지 알고 있다. 때문에 이러한 상황에서 콜백 함수가 꼭 필요한 작동 방식이다.

콜백함수의 본질과 정의

따라서 콜백 함수의 본질은 아래와 같이 이야기 할 수 있겠다.
우리는 어떤 일을 해야 하는지 알지만, 이 일을 언제 하게 될지는 정확히 알 수 없다. 반면, 다른 모듈은 언제 해야 할지는 알지만, 무엇을 해야 하는지는 모르기 때문에 우리가 알고 있는 정보를 콜백 함수에 잘 담아 다른 모듈이 전달해야 한다.
풀어서 설명하면, 앞서 비동기 호출 프로그래밍에서는 작업 처리가 두 부분으로 나뉜다고 했다. 첫 번째 부분은 어떤 일을 해야 하는지(TODO)를 가지고 있지만, 언제 해야 하는지 모른다.
반면 우리의 제어 범위를 벗어나는 두 번째 호출은 언제 해야 하는지 알고 있다. (e.g. setTimeout) 하지만 무엇을 해야 하는지는 모르기 때문에, 그들이 해야 할 TODO를 콜백 함수라는 체크리스트에 담아서 주어야 한다는 것이다.
출처: unsplash
앞서 콜백 함수를 아래와 같이 정의했다.
콜백 함수는 다른 코드에 매개변수로 전달되는 실행 가능한 코드다.
위에서 언급한 콜백 함수의 본질과 엮어서 생각해 보자. 콜백 함수는 작업 처리가 두 부분으로 나뉘니 다른 코드에 그가 해야 할 일을 잘 담아서 전달해 주면 실행이 가능하다는 것이다.
이제 콜백 함수에 대해 조금씩 이해가 되고 있다.

두 가지 콜백 유형

지금까지 동기 콜백과 비동기 콜백, 두 가지 콜백 유형을 소개 했다.
다시 한 번 개념을 정리해 보겠다.
먼저 동기 콜백은 가장 익숙한 콜백 유형으로, ‘블로킹 콜백’ 이라고도 한다. 함수 A를 호출할 때 콜백 함수를 매개변수로 전달한다면, 함수 A가 반환되기 전에 콜백 함수가 실행된다.
반면, 비동기 콜백은 함수 A를 호출하고 콜백 함수를 매개변수 형태로 전달한다고 하자. 이번에는 함수 A의 호출이 ‘즉시’ 완료되고, 일정 시간이 지나면 콜백 함수가 실행된다. 이때, 주 프로그램은 다른 작업을 하고 있을 수도 있다. 따라서 콜백 함수가 나중에 실행되는 동안 주 프로그램은 다른 작업을 계속할 수 있다.
비유하자면 내(콜백 함수)가 일을 하는 동안 팀장님(주 프로그램)은 나를 계속 지켜보는 것이 아니라, 나는 내 일을 하고 있더라도 팀장님은 팀장님만의 일을 하고 있는 상황인 것이다.
출처: unsplash
이와 같이 주 프로그램과 콜백 함수의 실행이 독립적으로 이루어질 수 있으므로 이 둘은 주로 다른 스레드나 프로세스에서 각각 독립적으로 실행된다. 이것이 바로 비동기(asynchronous), 즉 동시에 일어나지 않는다는 것이다.
그런데 여기서 드는 생각.
비동기 → 동시에 일어나지 않는 거라며. 근데 주 프로그램과 콜백 함수의 실행이 함께(동시에) 실행되는데 왜 ‘비동기’ 라고 하는거지?
자, 다시 정리해 보자.
비동기는 특정 작업이 다른 작업의 완료를 기다리지 않고 시작될 수 있다는 것을 의미한다. 즉, 작업들이 동시에 실행될 수 있지만, 하나의 작업이 완료될 때까지 다른 작업이 블로킹되지 않는다는 점에서 비동기적인 것이다.
여기서 ‘동시에’라는 표현은 실제로 동시성(concurrency)이나 병렬성(parallelism)을 의미할 수 있다.
다만, 핵심은 비동기 프로그래밍에서는 주 프로그램이 특정 작업을 비동기적으로 시작하고, 그 작업이 완료되기를 ‘기다리지 않고’ 다른 작업을 계속할 수 있다는 것이다. 이후 비동기 작업이 완료되면 콜백 함수가 호출되어 결과를 처리한다.
비록 비동기 작업과 주 프로그램이 동시에 실행될 수는 있지만, 비동기의 핵심은 블로킹되지 않는다는 점을 기억하자.

콜백 지옥

서버에서 작업을 처리할 때 사용자에게 전달되는 데이터 서비스(downstream)는 하나씩 호출되는 것이 아니라 수십 개 또는 수백 개씩 호출될 때가 많다.
예를 들어, 고객에게 불닭볶음면 레시피를 만들어 배포한다고 가정하자.
출처: 삼양식품 홈페이지
조리 순서는 면 → 김 → 깨 → 스프 순이며, 각 단계에서는 앞서 호출한 함수의 결과를 사용하여 처리된다. 이를 동기 콜백 방식으로 나타내면 아래와 같다.
let noodle = cookBuldakNoodle(); let seaweed = cookBuldakSeaweed(noodle); let sesame = cookBuldakSesame(seaweed); let sauce = cookBuldakSauce(sesame);
JavaScript
복사
동기 방식에서는 함수가 순차적으로 실행되며, 각 함수의 결과가 다음 함수의 입력으로 사용된다. 게다가 코드가 직관적이고 이해하기 쉽다.
하지만 비동기 콜백 방식으로 구현하면 어떤 모습일까?
비동기 콜백 방식에서는 각 함수가 비동기로 실행되며, 콜백 함수가 다음 단계의 작업을 수행한다. 하지만 이는 콜백 지옥을 초래할 수 있다.
function cookBuldakNoodle(callback) { setTimeout(() => { let noodle = "cooked noodle"; callback(noodle); }, 1000); // 1초 후 콜백 호출 } function cookBuldakSeaweed(noodle, callback) { setTimeout(() => { let seaweed = "cooked seaweed with " + noodle; callback(seaweed); }, 1000); // 1초 후 콜백 호출 } function cookBuldakSesame(seaweed, callback) { setTimeout(() => { let sesame = "cooked sesame with " + seaweed; callback(sesame); }, 1000); // 1초 후 콜백 호출 } function cookBuldakSauce(sesame, callback) { setTimeout(() => { let sauce = "cooked sauce with " + sesame; callback(sauce); }, 1000); // 1초 후 콜백 호출 } // 콜백 지옥의 예시 🔥 cookBuldakNoodle(function(noodle) { cookBuldakSeaweed(noodle, function(seaweed) { cookBuldakSesame(seaweed, function(sesame) { cookBuldakSauce(sesame, function(sauce) { console.log("Final Buldak: " + sauce); // 최종적으로 모든 단계의 결과가 누적된 불닭볶음면이 조리됨 }); }); }); });
JavaScript
복사
매우 복잡하다. 일명 ‘콜백 지옥’에 빠져버렸다.
이렇듯 비즈니스 구성이 갈수록 복잡해지는데 서비스 호출을 비동기로 콜백을 처리한다?
비동기 작업을 연속적으로 처리할 때 발생하는 콜백 지옥에 빠질 가능성이 높다.
이렇듯 복잡한 비동기 콜백 코드는 주의를 기울이지 않으면 콜백의 구덩이에 빠질 수 있다.

프로미스의 역할

Promise가 콜백지옥을 해결하기 위해 나온 것이라는 말이 있다.
물론 이 말이 아주 틀린 것은 아니지만, Promise는 콜백지옥을 해결하기 위해 나온 것이 아니다.
Promise의 역할은 콜백 지옥이 왜 생길수 밖에 없는지 이유에서 알 수 있다.
그래서 Promise를 아래와 같이 한 문장으로 설명해 볼 수 있다.
Promise는 ‘결과를 갖고 있는 (나중에 까볼 수 있는) 상자’ 다.
출처: unsplash
아까 위에서 언급한 불닭볶음면의 조리 순서가 면 → 김 → 깨 → 스프 순이라고 했다.
fetchCallback('https://www.samyangfoods.com', (data) => { console.log('김 넣기'); // 다음에 해야 할 일 }); console.log('면 끓이기'); // 이것부터 먼저 하고
JavaScript
복사
네트워크 요청을 보내는 함수가 있다고 했을 때, 콜백 함수의 최대 단점은 요청을 보내면서 나중에 할 일을 그 자리에서 정의해야 한다는 것에 있다. 실제로는 당장 할 일이 실행되는 때에 나중에 할 일이 실행되는데도 코드 순서가 실제 실행 순서와 다르기 때문에 매우 불편하다.
그런데 Promise라는 것이 나오면서 우리는 이러한 비동기 작업의 결과를 변수에 담을 수 있게 되었다.
const box = fetch('https://www.samyangfoods.com'); // box는 결과값을 담고 있는 상자 console.log('면 끓이기'); // 이것부터 먼저 하고
JavaScript
복사
당장 할 일들을 계속 해나가다가 나중에 우리가 원할 때 그 상자를 then으로 까보면 되는 것이다.
box.then((data) => { console.log('김 넣기'); // 다음에 해야 할 일 });
JavaScript
복사
Promise 이전에는 나중에 할 일과 당장 할 일이 하나로 뭉쳐 있어서 불편했다면, Promise는 ‘결과값을 갖고만 있어 나중에 까볼 수 있는 상자’ 이기 때문에 코드의 순서를 실행 순서와 맞출 수 있는, 조금 더 읽기 쉬운 코드를 작성할 수 있는 존재라고 보면 된다.
그래서 콜백 대신 Promise를 쓰는 게 눈에 더 잘 들어오는 것이다.

참고 자료