JUINTINATION

Node.js와 Express.js 가볍게 입문해보기 - 4 본문

StudyNote

Node.js와 Express.js 가볍게 입문해보기 - 4

DEOKJAE KWON 2024. 1. 19. 23:29
반응형

지난 도커(Docker) 가볍게 입문해보기 글에서 도커의 가벼운 입문 말고 Express.js 기본적인 CURD API 만들어보기, Middleware에 대한 개념 이해하기 등의 과제를 받았다고 언급했었다. 이 글에서는 Node.js의 비동기 처리 관련 내용을 적을 것이다.

지난 Node.js와 Express.js 가볍게 입문해보기 - 3에서 이어지는 내용이며 해당 글은 이 링크로 들어가면 확인할 수 있다.

 

Node.js와 Express.js 가볍게 입문해보기 - 3

지난 도커(Docker) 가볍게 입문해보기 글에서 도커의 가벼운 입문 말고 Express.js 기본적인 CURD API 만들어보기, Middleware에 대한 개념 이해하기 등의 과제를 받았다고 언급했었다. 이 글에서는 Express.j

juintination.tistory.com

콜백

말 그대로 나중에 실행되는 코드로 지난 글에서 언급했다싶이 자바스크립트에서 함수는 객체이기 때문에 함수를 매개변수로 사용할 수 있다. 어떤 함수의 모든 명령을 실행한 후 마지막으로 넘겨받은 매개변수인 callback을 실행하는데 여기서 매개변수로 들어가는 함수를 콜백 함수라고 부른다.

이벤트 중심 언어

자바스크립트는 이벤트 중심 언어로 어떤 이벤트의 결과를 기다리지 않고 다음 이벤트를 계속 실행한다. 다음 코드를 살펴보자.

setTimeout(() => {
    console.log('todo: 1st work!');
}, 3000);

setTimeout(() => {
    console.log('todo: 2nd work!');
}, 2000);

코드만 읽었을 때 당연히 todo: 1st work! 가 프로그램을 실행한지 3초가 지난 후에 출력되고 이후 2초 뒤에 todo: 2nd work! 가 출력될 것 같다. 하지만 실제 코드의 실행 결과는 아래와 같다.

프로그램을 실행한지 2초가 지난 후에 todo: 2nd work! 가 출력되고 이후 1초 뒤에 todo: 1st work! 가 출력된다. 그러면 어떻게 이 함수를 동기적으로 실행하기 위한 비동기 처리를 할 수 있을까? 다음 코드와 같이 2번째로 실행되기를 원하는 부분을 콜백 함수로 사용하는 방법이 있다.

setTimeout(() => {
    setTimeout(() => {
        console.log('todo: 2nd work!');
    }, 2000);
    console.log('todo: 1st work!');
}, 3000);

그렇다면 다음 코드는 어떻게 실행될까?

setTimeout(() => {
    console.log('todo: 1st work!');
}, 0);
console.log('todo: 2nd work!');

setTimeout()의 인자로 0을 넣었으니 바로 실행되니까 순서대로 출력될 것 같다. 하지만 실제 코드의 실행 결과는 아래와 같다.

이 코드에서의 setTimeout() 함수는  콜 스택에 들어가있다가 실행되는 것이 아닌 0초 뒤에 콜백 큐에 todo: 1st work! 를 출력하는 함수를 넣게 된다. 콜 스택에 있는 작업인 console.log(’todo: 2nd work!’); 의 실행이 모두 끝난 후 이벤트 루프가 콜백 큐에 있는 작업을 살펴보고 작업을 콜 스택에 올려 실행한다.

그림으로 표현된 쉬운 예시가 딱히 안 보이기도 하고 그림을 직접 그릴 시간은 살짝 촉박한 느낌이라 콜백 스택과 콜백 큐에 대한 이해는 다음 블로그를 참고하면 좋을 것 같다. 참고로 setTimeout() 함수는 이 블로그에서 표현하는 Web API 중 하나이다.

Web API의 실행은 콜백 큐를 거쳐 늦게 실행된다는 사실을 이해했다면 다음 코드를 보자.

function fakeSetTimeout(callback) {
    callback();
}

fakeSetTimeout(function () {
    console.log('todo: 1st work!');
})

console.log('todo: 2nd work!');

이 코드의 실행 결과는 어떨까? fakeSetTimeout() 함수는 Web API가 아니라 js 내부에서 처리하는 연산이므로 다음과 같이 동기적으로 실행된다.

setTimeout() 함수와 같은 서버에서 데이터 가져오기, 타이머 등의 외부 API는 외부에서 처리되는 연산이므로 비동기적으로 처리된다는 사실을 알았다.

이처럼 콜백 함수를 잘 사용하면 비동기 처리의 장점을 극대화할 수 있지만 다음과 같이 잘못 사용하면 ‘콜백 지옥’에 빠져 코드가 난잡해지고 그에 따라 유지보수가 힘들어지고 로직 파악 또한 힘들어질 것이다.

step1(function (value1)) {
    step2(function (value2)) {
        step3(function (value3)) {
            step4(function (value4)) {
                step5(function (value5)) {
                    step6(function (value6)) {
                        // Do something with value6
                    }
                }
            }
        }
    }
}

이런 코드의 중첩이 많아지는 콜백 지옥을 벗어날 수 있게 해주는 객체인 Promise를 사용해보자.

Promise

단어 그대로 약속을 도와주는 기능을 한다. ETRI에서 대여한 Node.js로 서버 만들기 책에서 이해를 돕기 위한 예시는 아이폰 사전 예약이다. 이 책을 통해 Node.js 관련 간지러운 부분이 많이 해결됐는데 개인적으로 이제 Node.js, Express.js 공부하기 시작한 분들에게 추천한다.

 

Node.js로 서버 만들기 | 박민경 - 교보문고

Node.js로 서버 만들기 | 빠르게실무형 Node.js 개발자가 될 수 있도록 도와주는 실습형 입문서다.5줄로 만드는Node.js 서버로 핵심 개념을 파악하고,데이터베이스 연동,실시간 통신 실습을 통해 실무

product.kyobobook.co.kr

새로 출시되는 아이폰을 구매하고 싶은데 매일 새로운 아이폰이 출시됐는지 확인하면 시간과 에너지를 낭비하게 될 것이니 새로운 아이폰이 출시되면 문자를 보내주는 서비스를 가입했다. 이 때 이 서비스가 하는 약속과 같은 역할을 하는 것이 바로 Promise이다.

이렇게 Promise는 보낸 요청에 대해 응답이 준비됐을 때 알림을 주는 알리미 역할을 한다. 더 확실한 이해를 위해 다음 코드를 보자.

function work(sec, callback) {
    setTimeout(() => {
        callback(new Date().toISOString());
    }, sec * 1000);
}

work(1, (result) => {
    console.log('todo: 1st work!', result);
});

work(1, (result) => {
    console.log('todo: 2nd work!', result);
});

각 작업마다 1초의 시간이 걸리게 했고 1st 다음에 1초에 시간이 흐른 뒤에 2nd 작업 순으로 출력할 것 같다. 순서대로 출력되긴 하지만 다음 실행 결과에서 알 수 있듯이 각 work 함수의 출력 사이에 1초의 시간은 흐르지 않고 프로그램이 실행되고 1초 후에 동시에 출력된다.

원하는대로 1초마다 출력되게 하기 위해서 다음과 같이 콜백 함수를 사용하여 수정할 수 있다.

function work(sec, callback) {
    setTimeout(() => {
        callback(new Date().toISOString());
    }, sec * 1000);
}

work(1, (result) => {
    console.log('todo: 1st work!', result);

    work(1, (result) => {
        console.log('todo: 2nd work!', result);
    });
});

그렇다면 다음 코드를 보자.

function work(sec, callback) {
    setTimeout(() => {
        callback(new Date().toISOString());
    }, sec * 1000);
}

work(1, (result) => {
    console.log('todo: 1st work!', result);

    work(1, (result) => {
        console.log('todo: 3th work!', result);
    });

    console.log('todo: 2nd work!', result);
});

눈으로 코드를 읽었을 때 todo: 1st work! , todo: 3th work! , todo: 2nd work! 순으로 출력될 것 같다. 하지만 실제 코드 실행 결과는 다음과 같다.

이렇게 콜백 함수를 사용하면 어떤 에러가 발생했을 때 눈으로 코드를 보면서 어떻게 수정해야 하는지, 또는 어떤 결과가 출력될지 확인하기 어려워진다. 이 때 이런 문제를 해결하기 위해 Promise 객체를 사용한다.

function workP(sec) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(new Date().toISOString());
        }, sec * 1000);
    })
}

workP(1).then((result) => {
    console.log('todo: 1st work!', result);
    return workP(1);
}).then((result) => {
    console.log('todo: 2nd work!', result);
    return workP(1);
}).then((result) => {
    console.log('todo: 3th work!', result);
});

workP()라는 함수는 new 키워드를 통해 Promise 객체를 반환한다. Promise를 생성할 때 resolve와 reject를 생성하게 되는데 resolve는 콜백 함수와 비슷하게 workP()의 요청이 성공하면 resolve 함수를 호출하고 실패하면 reject 함수를 호출하게 된다.

아래에서 workP(1)의 결과를 .then((result) 부분에서 result로 받아 아래에서 함께 출력하는 것을 Chaining 방식으로 반복한다. 그 아래에 return workP(1)를 하게 되는데 해당 부분을 주석처리하면 다음과 같이 출력된다.

반환되는 객체가 없기 때문에 result에는 빈 객체가 들어가게 되고 undefined로 출력되게 되는 것이다.

하지만 이런 Promise 조차도 가독성이 그렇게 좋지는 않은 것 같다.

async/await

ES7.6부터 사용할 수 있는 문법으로 Promise를 사용하여 Promise의 단점을 보완해주는 패턴이다. Node.js는 8버전부터 async/await를 완벽하게 지원하기 시작했다고 한다.

async/await를 사용하면 new Promise로 Promise 객체를 선언하고 resolve, reject를 넘겨주는 부분 또한 숨겨주기 때문에 코드 양도 줄일 수 있으며 try/catch를 통해 오류를 다룰 수도 있고 중첩 현상도 해결해준다.

function workP(sec) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(new Date().toISOString());
        }, sec * 1000);
    })
}

function justFunc() {
    return 'Just Function';
}

async function asyncFunc() {
    return 'Async Function';
}

console.log(justFunc());
console.log(asyncFunc());
console.log(workP(0));

일반 함수인 justFunc()는 반환값인 문자열 ‘Just Function’이 그대로 출력되지만 asyncFunc()와 workP()는 둘 다 Promise 객체를 반환하는 것을 확인할 수 있다. 또한 Promise를 사용하기 때문에 Promise 패턴에서처럼 함수를 호출할 때 then()을 사용하여 Promise { <pending> } 을 출력하지 않게 다음과 같이 코드를 수정할 수 있다.

function workP(sec) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(new Date().toISOString());
        }, sec * 1000);
    })
}

function justFunc() {
    return 'Just Function';
}

async function asyncFunc() {
    return 'Async Function';
}

console.log(justFunc());

asyncFunc().then((result) => {
    console.log(result)
});

workP(0).then((result) => {
    console.log(result)
});

await 사용법은 async 키워드를 붙인 함수 안에 lock을 걸어 놓고 싶은 부분에 붙이기만 하면 된다.

function workP(sec) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(new Date().toISOString());
        }, sec * 1000);
    })
}

function justFunc() {
    return 'Just Function';
}

async function asyncFunc() {
    const result_workP = await workP(3);
    console.log(result_workP);
    return 'Async Function';
}

console.log(justFunc());

asyncFunc().then((result) => {
    console.log(result)
});

위의 코드를 보면 workP()를 호출하는 부분에 await를 붙였는데 원래 workP() 함수는 setTimeout() 함수를 사용했기 때문에 비동기적으로 처리되지만 await를 붙여 workP(3) 함수가 완료되기 전까지 그 밑은 실행되지 않는다.


결론

이렇게 다 정리한 이후에도 아직 이 비동기라는 개념이 헷갈리긴 한다. 하지만 이 과정을 통해 Promise 객체가 반환하는 resolve, reject가 어떻게 쓰이는지 알 수 있게 되어서 좋은 경험이었던 것 같다. 특히 내가 실험을 하는 과정에서 처음으로 workP(0)의 결과가 제대로 나왔을 때 뿌듯했던 것 같다. 잠은 죽어서 자는 걸로.. 정진 또 정진이다!

728x90
Comments