[JS] 비동기 처리 방법 (콜백,프로미스,async/await)

Muru·2024년 8월 26일
post-thumbnail

비동기 처리 패턴 선 요약

비동기 처리에 대한 전체적인 흐름과 맥락을 먼저 잡고 세부적인 내용으로 들어가겠습니다.

JS 에서는 여러가지 비동기 함수를 사용하고는 합니다.
1. setTimeout (지정된 시간 후에 특정 작업 실행)
2. setInterval (지정된 간격으로 반복적 작업 실행)
3. fetch ( 네트워크 요청을 보내고 'Promise'를 반환하는 함수)
4. XMLHttpRequest ( HTTP 요청을 보내는 엄청 오래된 API)


여기서 비동기 함수는 비동기 작업이 끝나고 어떻게 처리할 것인지에 대한 방법이 필요합니다.

비동기 작업 : 결과가 즉시 반환되지 않고 나중에 완료되어 결과를 처리하는 방식
비동기 처리 : 비동기 작업이 완료되고 나서 실행하는것

여기서 어떻게 처리할것인가에 대한 방법론이 대략 세가지로 구분되어 많이 사용됩니다.

1. 콜백 함수 패턴
2. 프로미스
3. async/await

  1. 콜백 함수 패턴 : 자바 스크립트에서 가장 기본적인 비동기 처리 방법입니다. 콜백 함수는 인수로 전달되어 비동기 작업이 완료된 후에 호출 됩니다.
    이 패턴은 단순하고, 콜백 헬이 염려되지 않을 경우 고려 할 수 있는 방법론입니다.
  2. 프로미스 : 콜백 함수의 한계인 콜백 헬과 에러처리가 완화 되어 나온 패턴입니다. 'then' , 'catch' , 'finally' 같은 메서드를 사용해 후속 작업을 정의 할 수 있습니다. 다만 여전히 체인이 길어지면 코드가 복잡해질 수 있는 여지가 존재 합니다.
  3. async/await : 비동기 코드를 마치 동기 코드처럼 작성 할 수 있게 합니다. 프로미스에서 문제의 여지가 있었던 체인이 길어질 경우를 완화 할 수 있습니다. 덕분에 코드의 가독성과 유지보수성이 크게 향상됩니다.

아래부터는 좀더 자세한 내용으로 이어집니다.

비동기 함수 사용시 유의사항

비동기 함수는 콜백함수의 처리 결과를 외부로 반환할 수 없습니다.

function get() {
  setTimeout(() => {
    const result = "비동기 작업 결과!!!";
    return result;
  }, 1000);
}

const output = get();

console.log(output); // undefined

get 함수는 setTimeout을 사용하여 1초후에 콜백함수가 실행이 됩니다.

하지만 get함수는 setTimeout을 호출한 직후 바로 종료되므로 , get함수는 undefiend을 반환합니다. get함수가 종료된 다음 콜백함수의 값을 리턴해봐야 이 시점에서는 값이 무의미하며 무시됩니다.

비동기 함수 내부의 처리 결과를 상위 스코프의 변수에 할당 할경우 기대한대로 동작하지 않습니다.

let g =0;

setTimeout( () => {
    g = 100;
}, 0);

console.log(g); // 0

비동기 처리 함수인 setTimeout이 태스크큐에 있는 상태이고
콜스택에 있는 console.log(g)문이 먼저 실행됩니다. g = 0;
console.log(g)문이 실행되고나서야 태스크큐에있는 비동기처리함수가 콜스택에 오고 실행되어 비로소 g가100으로 바뀝니다.
즉 할당이 안되는것은 아니지만, 예상치 못한 결과를 얻게됩니다.


그래서 일반적으로 사용하는 기법은 비동기 함수에 비동기 처리 결과에 대한 후속 처리를 수행하는 콜백함수를 전달하는것이 일반적입니다.

비동기 처리 패턴

1. 콜백 함수 패턴

비동기 함수에 비동기 처리 결과에 대한 후속처리를 수행하는 콜백함수를 전달 해봅시다.

비동기 함수의 처리 결과를 외부로 반환할 수 없음을 해결하기 위한 콜백 패턴 예시.
비동기 함수에 콜백 함수를 전달하여 해결합니다.

function get(callback) {
    setTimeout(() => {
        const result = "비동기 작업 결과!!!";
        callback(result);
    }, 1000);
}

get((output) => console.log(output));	// 1초뒤  => 비동기 작업 결과!!!

이런식으로 사용하면 비동기 함수 처리에 만능일까요?? 답은 아닙니다.
간단한 코드 or 레거시 코드에 사용시 아직도 유용한건 맞지만 코드가 복잡해질경우 유지보수 측면에서 좋지 않을 수 있습니다. 또한 콜백 헬의 여지가 있음을 유의해야 합니다.
또한 에러 처리의 한계가 존재 합니다.

콜백 헬 : 비동기 함수의 후속 처리를 수행할때 또 다시 비동기 함수를 호출할경우 이를 콜백 헬이라고 부른다.

콜백헬 발생

// 가정: 각 비동기 작업이 데이터를 서버에서 가져오는 것이라고 가정합니다.
function fetchData1(callback) {
    setTimeout(() => {
        console.log("데이터 1 가져오기 완료");
        callback("데이터 1");
    }, 1000);
}

function fetchData2(data1, callback) {
    setTimeout(() => {
        console.log("데이터 2 가져오기 완료");
        callback(data1 + " & 데이터 2");
    }, 1000);
}

function fetchData3(data2, callback) {
    setTimeout(() => {
        console.log("데이터 3 가져오기 완료");
        callback(data2 + " & 데이터 3");
    }, 1000);
}

function fetchData4(data3, callback) {
    setTimeout(() => {
        console.log("데이터 4 가져오기 완료");
        callback(data3 + " & 데이터 4");
    }, 1000);
}

// 콜백 헬 발생
fetchData1(function (data1) {
    fetchData2(data1, function (data2) {
        fetchData3(data2, function (data3) {
            fetchData4(data3, function (finalResult) {
                console.log("최종 결과:", finalResult);
            });
        });
    });
});

// result 

데이터 1 가져오기 완료
데이터 2 가져오기 완료
데이터 3 가져오기 완료
데이터 4 가져오기 완료
최종 결과: 데이터 1 & 데이터 2 & 데이터 3 & 데이터 4

콜백 패턴의 에러 처리 한계

콜백 패턴중 가장 심한 문제점은 에러 처리가 곤란하다는 점입니다.

try {
    setTimeout(() => {throw new Error("Error!")}, 1000);
} catch (e) {
    // 에러를 캐치하지못함
    console.error("캐치한 에러", e);
}

이러한 콜백 헬이나 에러 처리가 곤란한점을 극복하기 위하여 ES6에서 프로미스가 도입되었습니다.

2. 프로미스

2.1 : 프로미스란

프로미스는 비동기 처리상태와 비동기 처리결과를 관리하는 객체 입니다.
해당 객체를 가지고 후속처리를 할 수 있습니다.
프로미스를 사용한다면 로직이 훨씬 간결해지고 유지보수면에서 편리해집니다.

코드를 먼저 보고 설명 하겠습니다.

// 프로미스 생성 (비동기 처리할 콜백함수 인수로전달)
const promiseGood = new Promise((reslove, reject) => {

    setTimeout(() => {
        // 작업이 성공했다고 가정
        const succes = true;

        // 비동기 처리 
        if(succes) {
            reslove("작업 성공!")
        } else {
            reject("작업 실패..");
        }
    },2000); // 비동기 작업은 2초후에 완료
});

// 프로미스 결과 처리
promiseGood
    .then((message) => {
        console.log(message);
    })
    .catch((errorMessage) => {
        console.error(errorMessage);
    })
    .finally(() => console.log("또 만나요!"));


console.log(promiseGood);
/*
Promise { <pending> }
작업 성공!
또 만나요!
*/

프로미스는 비동기 처리가 어떻게 진행되고 있는지에 대한 상태 정보를 갖습니다.

1.pending => 프로미스가 생성된 직후 기본상태이며 비동기 처리가 아직 수행되지 않은 상태입니다.

2.fulfilled => resolve 함수가 호출된 상태이며 비동기 처리가 수행된상태를 말합니다(성공)

3.rejected => reject 함수가 호출된 상태이며 비동기 처리가 수행된상태를 말합니다(실패)

위 코드에서는 프로미스가 생성된 직후엔 pending상태였다가
비동기 작업이 완료되고나서(타이머) 비동기 처리를 하여(true) resolve 함수를 호출하여 프로미스를 fulfilled 상태로 변경해주었습니다.

프로미스 상태에서 fulfilled or rejected 상태를 settled 상태라고 말하는데요.
유의할점으로 pending 상태에서 settled 상태로 가는것은 가능하지만, 한번 settled 상태가 된다면 더는 다른 상태로 변화하는것은 불가합니다.

또한 프로미스 처리 결과 값도 가지는데요. fulfilled 상태면 프로미스 처리 결과값은 1이고,
rejected 상태면 프로미스 처리 결과값은 Error 객체를 값으로 가집니다.


프로미스는 보통 생성자 함수로 많이 사용되지만 함수도 객체이므로 메서드를 가질 수 있습니다. 실제로 6가지 정적 메서드가 존재합니다.

2.2 : 프로미스의 정적 메서드 (6가지)

1. Promise.resolve or Promise.reject

해당 메서드는 이미 존재하는 값을 래핑하여 프로미스를 생성하기 위해 생성합니다.
그런데 이해가 안갈 수 있습니다. 프로미스를 즉각적 생성하여 어디에 쓰는걸까요?
다양한 상황에서 쓰일수 있지만 한가지 상황으로 비동기 흐름임이 분명하고 해당 흐름에 동기코드를 삽입하는것 아니라 비동기 코드를 삽입하여 비동기를 유지하고싶을때 사용하는것이 좋아보입니다.

function fetchDataFromCache() {
  // 캐시된 데이터가 있다고 가정
  const cachedData = { user: "Alice", age: 25 };
  
  // 캐시된 데이터를 프로미스로 감싸서 반환
  return Promise.resolve(cachedData);
}

function fetchDataFromAPI() {
  // 실제 API 호출을 모방한 비동기 작업
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ user: "Bob", age: 30 });
    }, 2000); // 2초 후에 완료
  });
}

// 사용 예시
const useCache = true;

const fetchData = useCache ? fetchDataFromCache() : fetchDataFromAPI();

fetchData.then((data) => {
  console.log("받은 데이터:", data); // 캐시된 데이터가 즉시 반환되거나, 2초 후에 API 데이터가 반환
});

만약 fetchDataFromCache 함수가 프로미스를 반환하지 않는 동기적 코드를 가지면 어떨까요? 실행 흐름을 예측할수 없을 뿐더러 유지보수측면에서도 좋지 않아보입니다.

2. Promise.all

Promise.all 메서드는 비동기 처리를 모두 병렬 처리할때 사용합니다.
Promise.all 메서드는 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받습니다.
요소의 모든 프로미스가 모두가 fulfilled 상태가 되고나서야 처리 결과를 모두 배열에 저장하여 새로운 프로미스를 반환합니다. 만약 인수중 하나라도 rejected 된것이 있다면 나머지를 기다리지 않고 즉시 종료합니다.

const requestData1 = () => new Promise(resolve => setTimeout(() => resolve(1), 1000));
const requestData2 = () => new Promise(resolve => setTimeout(() => resolve(2), 3000));
const requestData3 = () => new Promise(resolve => setTimeout(() => resolve(3), 6000));

Promise.all([requestData1(),requestData2(),requestData3()])
.then(console.log)
.catch(console.error)

/* 6초뒤 [1,2,3] 출력*/

3. Promise.allSettled

Promise.allSettled 메서드는 프로미스를 요소로 갖는 배열등의 이터러블을 인수로 전달받습니다. 모든 프로미스가 완료될때까지 기다린 후, 각각의 프로미스 결과를 성공/실패 여부와 함께 반환합니다.
모든 프로미스가 성공과 실패 여부 상관없이 프로미스 완료 자체에 대해서 궁금할때 사용합니다.

const promise1 = Promise.resolve(1);
const promise2 = Promise.reject("에러 발생!");
const promise3 = Promise.resolve(3);

Promise.allSettled([promise1, promise2, promise3]).then((results) => {
  console.log(results);
  // 출력:
  // [
  //   { status: 'fulfilled', value: 1 },
  //   { status: 'rejected', reason: '에러 발생!' },
  //   { status: 'fulfilled', value: 3 }
  // ]
});

4. Promise.race

Promise.race 메서드는 프로미스를 요소로 갖는 배열등의 이터러블을 인수로 전달받습니다. 주어진 프로미스중에서 가장 먼저 완료된(성공이나 실패) 프로미스의 결과를 반환하고 나머지 프로미스 요소들은 무시됩니다. 비동기 작업중에 가장 빠르게 실행되는것만 관심이 있을경우 유용 할 수 있습니다.

const promise1 = new Promise((resolve) => setTimeout(resolve, 100, "빠른 작업"));
const promise2 = new Promise((resolve) => setTimeout(resolve, 200, "느린 작업"));

Promise.race([promise1, promise2]).then((value) => {
  console.log(value); // 출력: 빠른 작업
});

5. Promise.any

Promise.any 메서드는 프로미스를 요소로 갖는 배열등의 이터러블을 인수로 전달받습니다. 주어진 프로미스중에서 가장 먼저 성공한 프로미스의 결과를 반환합니다.
만약 모든 프로미스의 결과가 실패를 반환했을경우에만 에러를 반환합니다.
비동기 작업중에 가장 먼저 성공한것에 관심이 있을경우 유용 할 수 있습니다.

const promise1 = Promise.reject("에러 1");
const promise2 = new Promise((resolve) => setTimeout(resolve, 200, "성공한 작업"));
const promise3 = new Promise((resolve) => setTimeout(resolve, 300, "성공한 작업2"));
const promise4 = Promise.reject("에러 2");

Promise.any([promise1, promise2, promise3,promise4])
  .then((value) => {
    console.log(value); // 출력: 성공한 작업
  })
  .catch((error) => {
    console.error(error);
  });
  
  출력 결과 : 성공한 작업

2.3 : 태스크큐 vs 마이크로태스크 큐

비동기함수의 콜백함수는 태스크큐로 이동한다고 했습니다.
프로미스의 후속처리메서드도 동일하게 태스크큐로 갈까요? 아닙니다. 프로미스의 후속처리메서드들은 마이크로태스크 큐 라고 하는 자료구조로 이동하는데요.
그렇다면 콜백함수도 비동기고, 프로미스의 후속처리메서드들도 비동기면
어느것이 먼저 실행되는걸까요??

// 콜백함수는 태스크큐로 이동한다.
setTimeout( () => console.log(1),0);

// 프로미스의 후속처리메서드는 마이크로태스큐로 이동한다.
Promise.resolve()
.then(() => console.log(2))
.then(() => console.log(3));

/* 출력 결과 : 2 => 3 => 1*/

정답은 프로미스의 후속처리메서드가 먼저 실행됩니다.
우선순위가 마이크로태스크 큐가 태스크큐보다 높기 때문입니다.

2.4 : fetch 함수

우리가 무심코 사용했던 fetch 함수는 프로미스를 반환 합니다.

// 1. fetch 함수를 통하여 Response 객체를 래핑한 프로미스를 반환합니다.
fetch("https://jsonplaceholder.typicode.com/todos/1")
  .then((response) => {
    //  Response 객체의 응답몸체(body)를 통해 역직렬화 과정을 수행 합니다. 
    // 역직렬화 과정을 수행하여 자바스크립트 객체로 변환 됩니다.
    return response.json(); // 역직렬화 과정이 수행됨
  })
  .then((data) => {
    // 두 번째 then: 변환된 자바스크립트 객체를 사용
    console.log("데이터:", data); // 출력: 역직렬화된 자바스크립트 객체
  })
  .catch((error) => {
    console.error("에러 발생:", error);
  });
  
  출력 결과 : 데이터: { userId: 1, id: 1, title: 'delectus aut autem', completed: false }
  

그러니까 fetch 함수는 다음과같은 흐름을 가집니다.
1. fetch 함수를 통해 Response 프로미스 객체를 얻습니다.
2. Response 객체의 본문(body)를 통해 역직렬화를 수행하여 자바스크립트 객체로 변환합니다.
3. 역직렬화된 객체는 자바스크립트 코드에서 사용 될 수 있습니다.



이런 프로미스도 한계점이 존재합니다. 후속처리메서드의 체인이 길어질수있어 코드의 복잡성과 가독성이 저하됩니다.

function getUserData(userId) {
  return fetch(`/api/users/${userId}`)
    .then(response => response.json())
    .then(user => {
      console.log('User:', user);
      return fetch(`/api/posts/${user.id}`);
    })
    .then(response => response.json())
    .then(posts => {
      console.log('Posts:', posts);
      return fetch(`/api/comments/${posts[0].id}`);
    })
    .then(response => response.json())
    .then(comments => {
      console.log('Comments:', comments);
      return processComments(comments);
    })
    .then(processedComments => {
      console.log('Processed Comments:', processedComments);
    })
    .catch(error => {
      console.error('Error:', error);
    });
}

getUserData(1);

위 코드처럼 사용자의 정보, 게시글, 댓글을 가져오는데 필요한 then 체인이 매우 많습니다. 한눈에 보기에도 코드를 읽기에 난잡해보입니다. 여기서 여러가지 상황에서 에러관리가 필요하다면 catch도 여러번 써야하는데 그떄는 정말 보기 힘들어질것이 자명해보입니다.

이럴때 사용해 볼 수 있는것이 async/await 문법입니다.

3. async/await

async/await는 비동기작업을 매우 쉽게 관리할 수 있게 하는 기능입니다.

먼저 async 키워드는 async 키워드를 사용해여 정의하며 언제나 프로미스를 반환합니다. awiat 키워드없이 async 키워드를 통해 함수를 정의해볼까요??

async function fetchData() {
  return "안녕 반가워";
}

fetchData().then(data => console.log(data));
// 출력 결과 : 안녕 반가워

async는 프로미스를 반환한다고 했습니다. 프로미스 단원에서 학습했듯이 프로미스를 반환한것들은 후속처리 메서드 then, catch, finally 를 사용할 수 있습니다.
async도 프로미스를 반환하므로 프로미스의 후속처리메서드를 사용할 수 있는것 입니다.


await 키워드는 async 함수 내부에서만 사용 할 수 있습니다.
await 키워드 뒤에 오는 표현식이 프로미스만 올 수있고 해당 프로미스가 반환할때까지(settled) 뒷 코드들의 실행을 일시 중지합니다.
만약 표현식이 프로미스의 settled 상태가 된다면 프로미스가 반환한 값(resolved or rejected)을 즉시 반환 합니다.


async function fetchData() {
  try {
    // fetch를 통해 프로미스를 반환하는데
    // await fetch는 프로미스가 resolved되면 프로미스의 처리결과를 할당한다.
    // 요청이 실패한다면, rejected 상태가되어 catch 블록으로 이동한다.
    const response = await fetch(
      "https://jsonplaceholder.typicode.com/todos/1"
    );

    // response.json()은 응답 본문을 JSON으로 파싱하는 비동기 작업을 수행하며,
    // 이 작업도 프로미스를 반환한다.
    // await response.json()은 프로미스가 resolved되면,
    // JavaScript 객체로 변환된 데이터를 반환한다.
    const data = await response.json();
    return console.log(data);

    // fetch나 response.json()에서 발생한 에러를 처리한다.
  } catch {
    console.error("에러발생", error);
  }
}

fetchData();

프로미스의 한계에서 보여준 코드를 개선하여 async/await 코드로 바꿔볼까요??


async function getUserData(userId) {
  try {
    // 1. 사용자 데이터 가져옵니다.
    const userResponse = await fetch(`/api/users/${userId}`);
    const user = await userResponse.json();
    console.log("User:", user);
    // 2. 사용자의 게시물을 가져옵니다.
    const postsResponse = await fetch(`/api/posts/${user.id}`);
    const posts = await postsResponse.json();
    console.log("Posts:", posts);

    // 3. 첫 번째 게시물의 댓글을 가져옵니다.
    const commentsResponse = await fetch(`/api/comments/${posts[0].id}`);
    const comments = await commentsResponse.json();
    console.log("Comments:", comments);

    // 4. 댓글을 처리합니다.
    const processedComments = await processComments(comments);
    console.log("Processed Comments:", processedComments);
  } catch (error) {
    console.error("Error:", error); // 에러를 처리합니다.
  }
}

getUserData(1);

비동기 작업을 순차적으로 처리하여, 작업이 완료된다면 다음 단계로 넘어가는 구조입니다.
동기적 흐름처럼 읽히면서 비동기 작업의 장점을 그대로 가져 갈 수 있습니다.

비동기 작업의 장점이란 무엇일까요? 위 코드를 그냥 동기코드로 쓰면안될까요?
동기적코드로 작업한다면 당장 UI에서 한계가 큽니다. 위 함수를 동기로직으로 흘러간다면 예를들면..

function fetchData() {
  // 이 함수가 실행되는 동안, 브라우저는 이 작업이 완료될 때까지 멈춥니다.
  const response = fetchSync('https://example.com/data'); // 동기 방식 가정
  console.log(response);
}

fetchData(); // 이 함수가 끝날 때까지 다른 작업을 할 수 없습니다.

주소를 fetch할때까지 다른 작업을 할 수 없습니다. 이는 효용성이 매우 적은게 분명 해보입니다.

그러므로 비동기 로직을 가지면서도 동기적흐름을 가져 가독성과 유지보수면에 훌룡한 성능을 가지게해주는것이 async/awiat라고 정리 해 볼수 있겠습니다.

profile
Developer

0개의 댓글