Callback 함수

(콜백 함수에서 넘어옴)

1 개요

콜백 함수라고 부른다. 일반적인 함수는 파라메터를 받아 함수 내부로 진입하고 결과값을 돌려줄 때는 return키워드를 사용해 호출자에게 값을 돌려주는 구조로 되어 있다. 또는 파라메터 자체를 Call by reference 로 전달(포인터 전달)해 호출자가 참조하는 값 자체를 함수 내부에서 변경하기도 한다. 이 경우 리턴값은 없거나 에러 여부를 전달하는 데 쓰이게 된다.

콜백 함수는 파라메터를 통해 다음 실행지점을 지시하는 함수를 전달한다. 함수가 일급 객체로 취급되는 언어에서는 함수 자체, 그렇지 않은 곳에서는 인터페이스나 함수 포인터를 파라메터로 넘긴다. 콜백 함수를 전달받은 함수는 실행 지점 마지막에 호출자 측으로 반환되는 대신 이 콜백 함수로 다시 한 번 진입한다. 즉 재귀함수와 비슷하게 동작한다.

콜백 함수는 실행에 오랜 시간이 걸리는 함수, 예를 들어 네트워크로부터 데이터를 다운로드받는 함수 등에서 다운로드 완료시 다음 행동을 지정하는 등의 분야에서 멀티스레드와 함께 사용된다. 멀티스레딩이 불가능한 자바스크립트는 이 콜백 함수 사용이 필수적이다.[1] 콜백 함수를 사용하는 프로그램은 비동기로 동작할 수 있는데 비동기로 동작하는 함수는 자신의 작업이 다 끝나지 않았더라도 즉시 호출자 측으로 반환되는 특성이 있다.

콜백 함수를 사용하는 함수는 콜백의 끝 부분에서 해당 단위 작업이 완전히 종료돼야 한다. 호출자 측에서 처리 결과를 동기적으로 받아볼 방법은 없기 때문에 여러 단위 작업을 동기화하기에는 매우 불편하다. 예를 들어 A와 B작업에 의존성이 존재하는 C작업이 있다고 한다면 A와 B작업이 둘 다 끝날 때까지 C작업을 대기시키는 처리가 필요하다. 콜백을 사용하지 않는 일반적인 동기화 프로그래밍에서는 어차피 A,B 작업이 끝나기 전까지는 코드의 다음 줄이 실행되지 않기 때문에 C작업은 실행되지 않고 대기하지만 콜백 방식으로 구현된 비동기 프로그래밍에서는 A,B 작업이 시작되는 즉시 호출자 측으로 제어가 되돌아오므로 C작업을 시작하는 명령이 A,B작업의 완료 여부와 상관없이 동작해서 버그를 만들어낼 수 있다.

이런 동기 처리를 위해 자바스크립트에서는 Promise를, Java에서는 Future 클래스를 제공하며 다른 언어들도 비슷한 동기화 매커니즘을 제공한다. 작동하는 방식은 동기화 대상이 되는 함수의 마지막 콜백 함수가 메인 이벤트 루프에 '작업 완료' 이벤트를 발신(emit)하고 수신자가 이 이벤트를 받아 적절히 처리한다. 자바스크립트의 경우에는 Promise.all 함수에 C 작업에 대한 콜백을 등록하는 형태로 사용한다.

일단 콜백을 사용하는 함수로 진입한 그 순간부터 해당 함수는 메인 스레드로부터 동기화가 풀려 버린다. 이를 다시 동기화하기 위해서는 언어나 라이브러리에서 제공하는 동기화 매커니즘을 사용해서 메인 스레드를 대기시켜야 한다.

거의 모든 이벤트 드리븐 프로그래밍에서 콜백 함수가 필수적으로 사용된다. 이벤트 드리븐 프로그래밍에서는 단위 작업이 동기화돼야 할 필요성이 적기 때문에 마지막 콜백 함수는 실행이 끝난 후에 그냥 조용히 사라진다. 물론 이들 콜백이 사라져도 콜백 함수가 시스템에 준 영향, 예를 들어 특정 변수값이나 파일의 내용 등은 남는다. 메인 스레드는 이들 콜백이 시스템에 가한 '부수 효과'를 읽어들여 활용한다.

콜백 함수는 반드시 '부수 효과'를 일으켜야 하므로, 콜백으로 '순수 함수'를 사용하면 아무 의미가 없다. 함수의 반환값을 호출자가 돌려받을 방법이 없기 때문이다. 아이러니하게도 함수형 언어가 명령형 언어보다도 콜백을 더 자주 사용하는데 마지막 콜백 함수만이 시스템에 '부수 효과'를 일으키도록 설계해서 함수를 외부 상태로부터 격리시키는 데 유리하기 때문이다. 쉽게 말해 어느 함수에서 버그가 났다면 그 버그가 시스템 외부로 번져나갈 수 있는 지점이 마지막 콜백 함수 딱 한군데로 좁혀져 디버깅하기가 쉬워진다.

2 예시

Node.js 는 싱글스레드 기반에서 병행성 프로그래밍을 지원하기 위해 콜백을 밥먹듯이 사용한다. 외부 자원에 의존하는 거의 모든 함수는 콜백 스타일을 지원하거나 콜백 방식으로만 사용할 수 있게 되어 있다.

예를 들어 문자열의 해시를 계산하는 pbkdf2 함수는 동기 버전과 비동기 버전이 있는데 사용법은 아래와 같다.
(pbkdf2 함수는 외부 자원에 의존하지 않는 순수 함수이나 실행 시간이 매우 오래 걸리는 함수이기 때문에 두 방식을 모두 지원한다. Node.js에서 외부 자원에 의존하는 함수는 콜백 방식으로만 실행할 수 있다)

- 동기 버전

const crypto = require('crypto');
const key = crypto.pbkdf2Sync('secret', 'salt', 100000, 512, 'sha512');
console.log(key.toString('hex'));  // 'c5e478d...1469e50'
console.log("main ended.");

- 비동기 버전

const crypto = require('crypto');
crypto.pbkdf2('secret', 'salt', 100000, 512, 'sha512', (err, key) => {
if (err) throw err;
console.log(key.toString('hex'));  // 'c5e478d...1469e50'
});
console.log("main ended.");

동기 버전은 한 줄씩 아래로 내려가면서 반환값을 받아 최종 값을 출력하지만 비동기 버전은 콜백 함수 내부에서 콘솔 출력을 처리한다. 동기 버전에서는 "main ended"라는 출력이 해시 값 출력 이후에 표시되나 비동기 버전은 main ended 다음 줄에 해시값이 출력된다. 더 구체적으로 동기 버전은 실행 후 잠깐 딜레이 이후에 해시값과 main ended를 출력하지만 비동기 버전은 실행 즉시 main ended가 뜨고 잠깐 딜레이 이후에 해시값이 출력된다. pbkdf2의 비동기 버전은 호출되자마자 반환되어 호출자측의 다음 줄 코드를 계속해서 실행하기 때문이다.

3 비슷하지만 콜백이 아닌 것

함수를 파라메터로 받지만 콜백이 아닌 것이 있다. 함수형 프로그래밍을 하다 보면 헷갈릴 수 있는데 PHP의 array_filter 함수를 예로 들어보자.

array_filter는 필터 조건을 걸어 배열 안에서 원하는 값을 추출할 수 있게 해주는 함수이다. 함수의 생김새 및 사용법은 다음과 같다.

array_filter($input, $callback)

$input의 자리에는 자신이 원하는 배열을 넣고 $callback 자리에는 필터 역할을 해주는 사용자 정의 함수를 넣어주게 돼 있다. 그러면 이 array_filter함수가 $input 배열의 각 요소(값)를 $callback함수에 전달하고 그 반환값이 true인 요소만으로 이루어진 배열을 생성해서 리턴해주는 일을 한다.

예시를 들자면 이렇게 된다.

function is_odd($v) {
if($v % 2 == 0) { return false; }
else { return true; }
}
$a = array(1,2,3,4,5,6,7,8,9);
$b = array_filter($a, "is_odd");

print_r($b); // 1,3,5,7,9

근데 array_filter함수의 두 번째 파라메터는 콜백이 아니다! 저 함수는 '순수 함수'이고, 동기적으로 작동한다. php.net의 레퍼런스 자체에서 저걸 콜백이라고 써놨지만 레퍼런스에서 용어를 잘못 쓰고 있다. 정확한 명칭은 filter function 또는 predicate이다.

진짜 콜백과의 결정적인 차이는 바로 호출자가 결과값을 반환받는다는 것. 콜백 함수는 해당 함수가 실행하는 그 순간에 호출자로부터의 동기화가 끊긴다. 그러니까 메인 스레드 M이 F라는 함수에 C라는 콜백 함수를 등록해 호출한다고 하면,

function C() {
// ...
}

function F(callback) {
callback();
}

F(C);
메인 스레드 M은 물론이고, 함수 F도 콜백 함수 C를 호출하는 그 순간부터 C와의 동기가 끊어져 버렸다. 즉 M, F, C 모두 서로에 대해서 동기화가 풀려 버린다.[2] 반면 array_filter의 $callback 함수는 호출자인 array_filter함수를 대기시키고 자신의 실행 결과를 반환값으로 되돌려준다. ᅟarray_filter 함수 자체도 $b변수에 결과값을 다 쓰기 전에는 반환되지 않는다. 즉 동기화를 유지한다. 이것이 콜백과 필터 함수의 결정적인 차이이다.
  1. 기술적으로 불가능한 게 아니라 언어의 스펙 자체가 싱글 스레드이다.
  2. 콜백 함수 C에서 return을 하면 F와는 동기화가 유지되지만 어차피 메인 스레드 M과의 동기가 풀렸기 때문에 반환값은 버려진다.