Promise 2020. 04. 23. 00:00 study/gardenist#Javascript#Promise#ES5 일반적인 동기 함수는 함수의 결과를 return을 통해 전달 받을 수 있습니다. 인자로 a, b를 받아 합계를 반환하는 sum 함수를 살펴보겠습니다. function sum(a, b) { return a + b; } let result = sum(1, 2); console.log(result); sum 함수가 호출되어 return 을 통해 값을 반환하기까지의 흐름은 코드가 쓰여지고 호출되는 그대로입니다. 그 결과로 아래에 있는 result 변수에 sum 함수가 호출되고 그 반환 값을 받아 result 변수에 저장하게 됩니다. sum 함수가 동기 함수이므로 결과를 반환할 때까지 let result = sum(1, 2); 이 부분에서 멈춰있게 됩니다. 물론 여기에서의 sum 함수는 계산을 하는데 사람이 인지하기에 너무 빠른 시간 안에 끝나버리므로 순식간에 지나가긴 하지만, sum 함수가 5초가 걸린다고 해도 let result = sum(1, 2); 부분에서 5초 동안 리턴 결과를 기다린 후 console.log(result); 부분이 호출됩니다. <동기 흐름> sum(1, 2) 호출 ↓ a + b 계산 ↓ 계산 결과 반환 ↓ result 변수에 할당 ↓ console에 출력 위와 같이 하나의 흐름으로 처리되고 앞쪽이 실행되어야 뒷쪽도 실행되고, 어느 단계에서 시간이 오래 걸리는 작업을 수행한다면 뒷쪽은 그만큼 지연됩니다. 만약 sum 함수가 비동기 함수라면 위와는 다른 흐름으로 호출됩니다. <비동기 흐름> sum(1, 2) 호출 --(별도의 흐름이 생김)--> a + b 계산 | | (원래의 흐름은 별도의 흐름을 기다리지 않고 바로 진행됨) ↓ (결과 반환이 없음) - 변수에 할당할 수 없음 ↓ console에 출력할 수 있는 계산 값이 없음 비동기 함수의 경우 새로운 흐름이 생성되어 sum 함수 안의 내용은 별도의 흐름이 생성됩니다. 그에 따라 result 변수가 할당된 흐름과 달라져서 return으로 값을 반환해서 전달할 수 없습니다. 이 때 비동기 흐름에서 결과를 전달받기 위해서는 return 대신 콜백 함수를 활용합니다. 동기 함수에서 return으로 전달 받는 값을 콜백 함수를 전달해서 콜백 함수의 파라미터로 받도록 합니다. 비동기 버전의 sum 함수는 setTimeout 함수를 활용해 만들어 보겠습니다. return이 없어졌으므로 콜백을 sum에 전달할 수 있도록 파라미터를 추가해 줍니다. function sum(a, b, callback) { // setTimeout을 이용해 비동기적으로 수행합니다. setTimeout(function() { let result = a + b; // 결과를 return 하는 대신 콜백 함수를 호출하면서 인자로 전달합니다. callback(result); } } sum(1, 2, function(result) { // sum 함수가 계산을 한 후 이 콜백을 실행하게 됩니다. console.log(result); }); 물론 sum 함수를 위해 이런 코드를 굳이 작성할 필요는 없지만 여기서는 비동기의 예시를 위해서 sum을 비동기로 작성해 보았습니다. 콜백 함수를 전달하여 a와 b를 더한 값을 계산하고 이를 콜백함수를 호출하여 전달합니다. 여기까지는 크게 문제 될 것이 없어 보이지만 콜백 함수를 활용할 때의 문제점은 콜백 함수 내에 중첩해서 콜백 함수를 받는 함수를 호출하는 경우입니다. 예를 들어 동기 함수에서는 아래와 같이 호출할 수 있습니다. let result = sum(1, sum(2, sum(3, sum (4, 5)))); 같은 값을 계산하는 비동기 버전의 sum 함수는 아래와 같습니다. sum(1, 2, function(first) { sum(first, 3, function(second) { sum(second, 4, function(third) { sum(third, 5, function(fourth) { console.log(fourth); } } } } 동기 함수에 비해서 비동기 함수는 콜백 함수를 작성해야 하므로 코드의 양이 늘어날 수 밖에 없습니다. 기본적으로는 동기 함수보다 읽기 어려울 수 밖에 없습니다. 이런 콜백 함수가 중첩된다면 위 코드와 같이 한 번에 읽기 어려워지게 되고, 콜백의 깊이가 깊어질 수록 코드를 파악하기도 어려워집니다. 이렇게 콜백의 콜백의 콜백의 콜백… 같은 코드를 콜백 지옥이라고 합니다. 물론 ES6의 화살표 함수를 활용하면 코드는 간결해지지만 코드 구조에는 변함이 없습니다. 위는 다른 동작 없이 sum 함수만 연쇄적으로 호출한 것이지만 실제로는 다른 동작을 하는 여러 코드들이 함께 포함되므로 더 복잡해지기 쉽습니다. Node.JS 교과서 4.3절에서는 수정 시 AJAX를 통해 PUT /users API를 비동기적으로 호출하고, 수정이 정상적으로 완료되면 다시 GET /users API를 호출하는 것도 비슷한 사례입니다. Promise 객체는 파라미터로 콜백을 전달하는 대신 Promise의 then 메서드에 콜백을 전달하게 됩니다. 연쇄적인 작업 역시 계속해서 then을 이용합니다. 먼저 비동기 sum 함수를 Promise로 감싸보겠습니다. // 파라미터의 콜백이 없어집니다! function sum(a, b) { // 값이 아닌 프로미스 객체를 반환합니다. return new Promise(function(resolve, reject) { setTimeout(function() { // 정상적으로 처리한 경우에는 resolve 함수에 값을 전달하여 호출합니다. // 만약 오류가 발생한다면 reject를 호출합니다. resolve(a + b); } }; } // 프로미스의 then에 콜백을 호출합니다. sum(1, 2).then(function(result) { console.log(result); }); Promise는 비동기 작업을 콜백으로 전달하는데, 프로미스가 해당 콜백에 resolve와 reject라는 함수를 전달해 줍니다. resolve는 작업이 정상적으로 처리되었을 때 인자로 처리 결과를 전달하며 호출해주면 되고(값이 없을 때는 그냥 resolve()로 호출해주면 됩니다.), reject는 오류가 나거나 잘못된 경우에 호출하면 됩니다. reject 까지 쓰도록 수정해보면 아래와 같습니다. 만약 sum 함수의 a, b 파라미터가 number 타입이 아닌 경우에는 reject를 호출하도록 해봅시다. function sum(a, b) { return new Promise(function(resolve, reject) { setTimeout(function() { if("number" === typeof a && "number" === typeof b) { // a와 b가 모두 number 타입인 경우에는 정상적으로 resolve를 호출 resolve(a + b); } else { reject('a, b는 모두 number 타입이어야 합니다.'); } }); }); } // reject가 호출되었을 때는 then 대신 catch를 호출합니다. sum('1', '2').catch(function(result) { console.log(result); }); 프로미스 내부에서 오류가 발생하거나, reject가 호출되는 경우 then 대신 catch를 호출합니다. 작업이 정상적으로 처리될 지 정상적으로 처리되지 않을 지 예측할 수 없으므로, Promise → then → catch 순서로 호출하여, 정상적일 때도 처리될 수 있게 하고, 비정상적일 때도 처리할 수 있도록 합니다. sum(1, 2) .then(function(result) { console.log('결과', result); }) .catch(function(error) { console.log('처리되지 않음'); }); 이렇게 하여 정상/비정상 케이스를 모두 처리하게 만들 수 있습니다. 위의 경우에는 a, b가 모두 number 타입이므로 then만 수행되고, catch 부분의 콜백은 호출되지 않습니다. 이 전의 콜백 지옥 케이스를 프로미스 버전으로 다시 작성해 보겠습니다. sum(1, 2) .then(function(result) { return sum(result + 3); }) .then(function(result) { return sum(result + 4); }) .then(function(result) { return sum(result + 5); }) .then(function(result) { console.log('1 + 2 + 3 + 4 + 5 = ' + result); }); 계속해서 깊이가 깊어지던 콜백 방식과 달리 프로미스에서는 then을 통해 구조를 선형적으로 작성할 수 있습니다. 위의 코드에서 보듯 then의 콜백 함수에서 반환한 값은 프로미스인 경우 그대로 프로미스, 프로미스가 아닌 경우에는 프로미스 객체로 감싸게 되어 계속해서 연쇄적으로 비동기 작업을 처리할 수 있게 됩니다. then 콜백의 반환 값은 Promise 객체여도 되고(sum 함수가 Promise를 반환하고 있습니다.) 일반적인 값은 자동으로 프로미스로 감싸지게 됩니다. 비동기 함수 작성 시 Promise를 사용할 때는 코드가 조금 더 복잡해질 수는 있지만 작성한 Promise 함수를 사용할 때는 간결하게 작성할 수 있습니다. Promise 객체는 ES7에 추가된 async/await 구문을 이용해 더 간결하고 동기적으로 처리할 수 있습니다. 아래는 async/await의 예시입니다. async function asyncAwaitSum() { let first = await sum(1, 2); let second = await sum(first, 3); let third = await sum(second, 4); let fourth = await sum(third, 5); console.log('결과는 ' + fourth); }; asyncAwaitSum(); Promise를 처음에 작성했던 동기 방식의 sum 처럼 사용할 수 있게 됩니다. 이 후에 async/await에 대해 더 자세히 다뤄보도록 하겠습니다. Promise 좀 더 살펴보기 프로미스는 내부적으로 프로미스의 상태를 가지고 있습니다. 프로미스가 생성되어 resolve나 rejected 함수가 불려지기 전까지는 결과가 정해지지 않은 <pending(보류)>상태, 정상적으로 처리되어 resolve를 호출하는 경우 <fulfilled(이행)> 상태 그리고 오류가 발생하거나 reject 함수를 호출했을 때의 <rejected(거부)> 상태입니다. 프로미스는 위의 세 가지 상태를 가지며, fulfilled, rejected는 결과가 정해진 상태입니다. 따라서 프로미스를 사용할 때는 정상적으로 처리를 하든 처리를 못하든 항상 resolve나 reject 함수를 호출해주어야 pending 상태에서 넘어가게 됩니다. 크롬 콘솔에서 프로미스 객체를 생성한 후 resolve나 rejected를 호출하지 않으면 계속 pending 상태에 머물게 됩니다. 프로미스 객체를 생성자를 통해 만들면 pending 상태에서부터 시작하지만, 이미 fulfilled, rejected인 상태로 생성할 수도 있습니다. let resolved = Promise.resolve("resolved"); console.log(resolved); // Promise {<resolved>} 가 콘솔에 출력됨 let rejected = Promise.reject("rejected"); // 객체 생성 시 Promise가 reject할 때 오류를 발생시켜 오류 메시지가 출력됨 console.log(rejected); // Promise {<rejected>} 가 콘솔에 출력됨 이미 resolved/rejected로 결정된 상태의 프로미스 객체는 어떻게 쓰일까요? 만약 어떤 함수나 라이브러리에서 프로미스 객체를 필요로 하는 경우에 결과를 이미 알고 있다면 위와 같이 resolved, rejected 상태의 프로미스 객체를 생성해서 전달할 수 있습니다. 또한 프로미스는 한 번에 여러 개의 프로미스를 실행할 수 있는 all 함수를 가지고 있습니다. function dicePromise() { // 1 ~ 6까지의 값 만들기 let dice = Math.floor(Math.random() * 6) + 1; console.log('결과 : ' + dice); return new Promise(resolve => { // dice * 초 만큼 지연되도록 setTimeout에 두 번째 인자로 전달 setTimeout(() => { // 주사위 결과를 랜덤으로 만들기 resolve(dice); }, dice * 1000); // 밀리 세컨드 단위이므로 1000을 곱함 }); } // 주사위 네 번 던지기 (프로미스 배열) let dicePromises = [ dicePromise(), dicePromise(), dicePromise(), dicePromise() ]; Promise.all(dicePromises) .then(results => console.log(results)); Promise.all([프로미스 배열])의 형식으로 사용합니다. all 함수는 모든 프로미스가 작업을 마칠 때까지 기다렸다가 then에 전달한 콜백 함수를 호출합니다. 위의 예시에서 주사위의 값이 4,2,5,3이라면 각각 4초, 2초, 5초, 3초 후에 resolve 함수를 호출하므로, 이 때 then에 전달한 콜백이 호출되는 시간은 가장 오래 걸리는 5초가 걸린 주사위 작업이 완료된 후 호출됩니다. 즉 가장 오래 걸리는 작업만큼 시간이 소요됩니다. (4 + 2 + 5 + 3 초가 아니라 5초입니다.) --------> 4초 OK ----> 2초 OK ----------> 5초 OK < 이 시점에 then의 콜백 호출 ------> 3초 OK then에 전달되는 값은 all 함수에 전달한 배열의 순서대로 값을 배열로 전달합니다. 5, 3, 6, 3인 경우의 출력 결과는 아래와 같습니다. all 함수가 모든 작업을 기다린 후에 then에 전달한 콜백을 호출한다면, 여러 개의 프로미스를 모두 기다리지 않고 가장 먼저 완료된 프로미스를 처리하는 race 함수가 있습니다. 프로미스들끼리 경주(race)를 하고 1등의 결과만 처리합니다. let dicePromises = [ dicePromise(), dicePromise(), dicePromise(), dicePromise() ]; Promise.race(dicePromises) .then(winner => console.log(winner)); all은 then에 전달한 콜백에 모든 프로미스의 결과를 배열로 전달했지만 race는 가장 먼저 작업이 완료된 하나의 결과만 전달됩니다. 3, 4, 1, 2의 경우 실행 결과는 아래와 같습니다. 1은 1초가 소요되었으므로 다른 3, 4, 2보다 빠르므로, race 로 프로미스 배열의 작업을 수행하면 프로미스 배열 중 가장 빨리 처리된 1의 경우만 then을 통해 처리하게 됩니다. Promise에 관한 더 상세한 내용은 아래 링크에서 읽어주세요. https://joshua1988.github.io/web-development/javascript/promise-for-beginners/ https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise 아래는 숙제 링크입니다. 실행해보고 어떻게 동작하는지 소스 코드를 읽어 본 후 페이지에 표시된 가이드대로 Promise를 적용해 봅시다. https://codesandbox.io/s/try-promise-74hu5?file=/index.html