프론트엔드/Javascript

비동기 - 콜백

syleemomo 2024. 3. 13. 16:05
728x90

자바스크립트는 다양한 함수를 사용하여 원하는 시점에 동작하는 비동기 처리를 할 수 있다. setTimeout 과 setInterval 메서드가 비동기 스케쥴링의 대표적인 예시이다. 

1. 서버에서 데이터를 가져오는 경우
2. 고화질의 이미지를 로딩하는 경우
3. 스크립트 파일을 로딩하는 경우

실무에서 경험하는 비동기 처리는 위와 같이 다양하다. 

function loadScript(src) {
    // <script> 태그를 만들고 페이지에 태그를 추가(마운트)합니다.
    // 태그가 페이지에 추가되면 src에 있는 스크립트를 비동기적으로 로딩하고 실행합니다.
    const script = document.createElement('script')
    script.src = src
    document.head.append(script)
}

해당 함수는 script 태그를 동적으로 생성하고, html 문서에 추가한다. 함수가 실행되면 브라우저는 자동으로 script 태그에 설정한 src 의 경로를 찾아서 파일을 읽기 시작한다. 파일읽기(로딩)가 완료되면 해당 파일의 스크립트를 실행한다. 

function loadend(){
    console.log("스크립트 로딩이 완료되었습니다!")
}

사용법은 우선 프로젝트 폴더에 로딩할 스크립트를 생성한다. 예를 들면 script.js 파일을 생성하고 loadend 함수도 작성한다. 그런 다음 아래와 같이 loadScript 함수를 호출하여 실행한다. 이때 아래 코드는 app.js (script.js 가 아닌 다른 파일)에 작성한다.

function loadScript(src) {
    // <script> 태그를 만들고 페이지에 태그를 추가(마운트)합니다.
    // 태그가 페이지에 추가되면 src에 있는 스크립트를 비동기적으로 로딩하고 실행합니다.
    const script = document.createElement('script')
    script.src = src
    document.head.append(script)
}
loadScript('script.js') // 스크립트 로딩
// 해당 위치의 코드는 스크립트 로딩이 완료되기 전에 실행된다.

loadScript 함수를 호출하면 비동기적으로 실행된다. 다시말해, 로딩(파일읽기)은 지금 당장 시작되더라도, 로딩이 끝나고 스크립트 파일(script.js)에 대한 실행은 loadScript 함수 실행이 종료된 이후에나 가능하다. 따라서 loadScript 함수호출 아래쪽의 코드는 스크립트 로딩이 완료되기 전에 실행된다. 

function loadScript(src) {
    // <script> 태그를 만들고 페이지에 태그를 추가(마운트)합니다.
    // 태그가 페이지에 추가되면 src에 있는 스크립트를 비동기적으로 로딩하고 실행합니다.
    const script = document.createElement('script')
    script.src = src
    document.head.append(script)
}
loadScript('script.js')
loadend()

예를 들어 script.js 파일 안의 loadend 함수를 실행하고 싶다고 해보자! 해당 코드와 같이 loadScript 호출 이후에 loadend 함수를 실행하면 아래와 같은 에러가 발생한다. 문제의 원인은 자바스크립트 엔진이 script.js 파일읽기(로딩)가 완료되기 전에 loadend 함수에 접근하려고 하기 때문이다. 

reference 에러발생

 그래서 script.js 파일읽기(로딩)가 완전히 끝나고, loadend 함수를 사용하기 위해서는 아래와 같이 코드를 작성해야 한다.

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(script)
    document.head.append(script)
}

loadScript 함수를 위와 같이 수정하도록 하자! 해당 함수의 두번째 파라미터로 로딩이 끝났을때 호출할 콜백함수가 들어간다.  script 의 onload 프로퍼티는 해당 script 의 파일읽기(로딩)가 끝났을때 처리할 이벤트핸들러 함수를 등록할 수 있다. 즉, script.js 파일읽기가 완료되면 주어진 callback 함수를 실행한다. 

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(script)
    document.head.append(script)
}
loadScript('script.js', function() {
    // 콜백 함수는 스크립트 로드가 끝나면 실행됩니다.
    loadend() // 이제 함수 호출이 제대로 동작합니다.
})

app.js 파일을 위와 같이 수정하자! 콜백 함수가 실행되는 시점은 script.js 파일읽기가 끝났을때이므로, 콜백함수 안에서는 loadend 함수가 존재한다. 그렇기 때문에 콜백함수 안에서 loadend 함수를 호출하면 아래와 같이 원하는대로 외부 스크립트의 함수를 사용할 수 있다. 

외부 스크립트(script.js)의 함수 실행결과

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(script)
    document.head.append(script)
}
loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', function(script) {
    // 콜백 함수는 스크립트 로드가 끝나면 실행됩니다.
    console.log(`${script.src}가 로드되었습니다.`)
    console.log(_)
})

해당 코드는 프로그램 외부에 위치한 라이브러리 파일을 로딩하고, 로딩이 끝나면 해당 라이브러리의 전역변수를 출력한다. 이렇게 어떤 작업이 완료되고 나서 콜백함수를 비동기적으로 실행하는 것을 콜백기반(callback-based) 비동기 프로그래밍이라고 부른다. 

 

* 중첩콜백

만약 로딩할 스크립트가 두개인 경우에 어떻게 하면 스크립트를 순서대로 불러올수 있을까? 다시말해, 두번째 스크립트는 첫번째 스크립트 로딩이 완료되었을때 불러오려고 한다.

function executeMain(){
    console.log('메인 프로그램 로딩이 완료되었습니다!')
}

프로젝트 폴더에 main.js 파일을 생성하고 위와 같이 작성하도록 한다. 

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(script)
    document.head.append(script)
}
loadScript('script.js', function() {
    // 첫번째 스크립트 로딩이 완료된 시점
    loadend() 
    loadScript('main.js', function(){
        // 두번째 스크립트 로딩이 완료된 시점
        executeMain()
    })
})

app.js 파일을 위와 같이 수정하자! 제시한 문제를 해결하기 위해서는 위와 같이 첫번째 콜백함수 안에서 두번째 스크립트(main.js)를 로딩하는 것이다. 첫번째 콜백함수는 첫번째 스크립트 파일(script.js)의 로딩이 끝난 시점으로 여기에서 두번째 스크립트를 로딩하는 것은 자연스럽다. 

외부 스크립트 두 개를 순차적으로 로딩한 결과

function buildSomething(){
    console.log('빌드 파일의 로딩이 완료되었습니다!')
}

프로젝트 폴더에 build.js 파일을 생성하고, 위와 같이 작성하자!

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(script)
    document.head.append(script)
}
loadScript('script.js', function() {
    // 첫번째 스크립트 로딩이 완료된 시점
    loadend() 
    loadScript('main.js', function(){
        // 두번째 스크립트 로딩이 완료된 시점
        executeMain()
        loadScript('build.js', function(){
            // 세번째 스크립트 로딩이 완료된 시점
            buildSomething()
        })
    })
})

app.js 파일을 위와 같이 수정하자! 해당 코드는 build.js 라는 세번째 외부 스크립트를 동적으로 로딩하고 있다. 이렇게 콜백기반 비동기 프로그램은 콜백이 몇개 뿐이라면 나쁘지 않지만, 갯수가 많아지면 코드 가독성이 떨어지고 복잡하게 느껴진다. 

외부 스크립트 3개를 순차적으로 로딩한 결과화면

 

* 콜백기반 비동기 프로그래밍에서의 에러처리

현재까지 살펴본 예제들은 스크립트 로딩이 실패하는 경우는 고려하지 않고 작성하였다. 그러나 실무에서는 스크립트 로딩이 실패할 가능성은 언제나 존재한다. 그러므로 콜백함수는 에러처리가 가능해야 한다. 

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(null, script)
    script.onerror = () => callback(new Error(`${src} 파일을 로딩하는 중에 에러가 발생하였습니다.`))
    document.head.append(script)
}

app.js 파일의 loadScript 함수를 위와 같이 수정하자! 이제 loadScript 함수는 스크립트 로딩에 성공하면 callback(null, script)를 실행하고, 실패하면 callback(error)를 실행한다. 

loadScript('script.js', function(error, script){
    if(error){
        // 에러가 발생한 경우
        alert(error)
    }else{
        // 스크립트 로딩에 성공한 경우 
        loadend() 
    }
})

개선된 loadScript 함수의 사용법은 위와 같다. 이렇게 첫번째 파라미터로 에러객체를 전달받아 에러를 먼저 처리하는 패턴을 "오류 우선 콜백(error-first callback)" 이라고 한다. 오류 우선 콜백의 첫번째 인자는 에러를 위해 남겨둔다. 에러가 발생되면 첫번째 인자를 이용하여 callback(error)와 같이 실행한다. 두번째 인자부터는 에러가 발생하지 않은 경우를 위해 존재한다. 비동기 처리가 성공적으로 완료되면 callback(null, argument1, argument2, argument3, ...) 처럼 호출한다. 오류 우선 콜백을 사용하면 단일 콜백함수 안에서 에러 케이스와 성공 케이스를 모두 처리할 수 있다. 

 

* 콜백지옥 (멸망의 피라미드)

콜백기반 비동기 처리는 꽤 쓸만해보이지만, 아래와 같이 처리해야 할 콜백함수가 많아질수록 코드가 복잡해진다. 즉, 콜백함수가 중첩될수록 코드가 깊어진다. 이러한 패턴을 콜백지옥(callback hell) 혹은 멸망의 피라미드(pyramid of doom)이라고 한다. 

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(null, script)
    script.onerror = () => callback(new Error(`${src} 파일을 로딩하는 중에 에러가 발생하였습니다.`))
    document.head.append(script)
}
loadScript('script.js', function(error, script){
    // 첫번째 스크립트 로딩이 완료된 시점
    if(error){
        alert(error)
    }else{
        loadend() 
        loadScript('main.js', function(error, script){
            // 두번째 스크립트 로딩이 완료된 시점
            if(error){
                alert(error)
            }else{
                executeMain()
                loadScript('build.js', function(error, script){
                    // 세번째 스크립트 로딩이 완료된 시점
                    if(error){
                        alert(error)
                    }else{
                        buildSomething()
                    }
                })
            }
        })
    }
})

해당 코드는 다음과 같이 동작한다. 첫번째 스크립트 로딩시 에러가 있으면 에러를 처리한다. 에러가 없으면 두번째 스크립트를 로딩한다. 두번째 스크립트 로딩시 에러가 있으면 에러를 처리한다. 에러가 없으면 세번째 스크립트를 로딩한다. 세번째 스크립트도 로딩시 에러가 있으면 에러를 처리하고, 없으면 세번째 스크립트에 있는 함수를 실행한다. 

function loadScript(src, callback) {
    const script = document.createElement('script')
    script.src = src
    script.onload = () => callback(null, script)
    script.onerror = () => callback(new Error(`${src} 파일을 로딩하는 중에 에러가 발생하였습니다.`))
    document.head.append(script)
}
loadScript('script.js', callback1)

function callback1(error, script){
    // 첫번째 스크립트 로딩이 완료된 시점
    if(error){
        alert(error)
    }else{
        loadend() 
        loadScript('main.js', callback2)
    }
}

function callback2(error, script){
    // 두번째 스크립트 로딩이 완료된 시점
    if(error){
        alert(error)
    }else{
        executeMain()
        loadScript('build.js', callback3)
    }
}

function callback3(error, script){
    // 세번째 스크립트 로딩이 완료된 시점
    if(error){
        alert(error)
    }else{
        buildSomething()
    }
}

콜백지옥을 해결하기 위하여 app.js 파일을 위와 같이 수정해보자! 해당 코드는 loadScript 함수 안에 위치한 콜백함수(익명함수)를 잘라내서 외부로 빼냈다. 이렇게 하면 각 동작을 분리하고, 깊은 중첩을 피할수 있다. 그리고 기존과 동일하게 동작한다. 

이렇게 작성하면 실행은 동일하지만, 코드 가독성이 떨어지고, 함수 호출과 프로그램 실행흐름을 이해하기 위하여 눈을 이리저리 굴려야 한다. 더군다나 callback1, callback2, callback3 는 콜백지옥을 피하기 위한 용도일뿐, 함수 재사용이 불가능하다. 그래서 뭔가 더 나은 해결책이 필요하다. 

728x90

'프론트엔드 > Javascript' 카테고리의 다른 글

비동기 - 프로미스 체이닝  (0) 2024.03.15
비동기 - 프로미스 (Promise)  (0) 2024.03.14
프로토타입 상속  (0) 2024.03.08
요소의 좌표 계산하기  (0) 2024.02.26
요소 사이즈와 스크롤  (0) 2024.02.25