[Vue 02] JS의 비동기처리

예준천
  1. Vue

문제상황

유지보수 중 새로 수정한 코드에서 다음과 같은 문제가 생겼다. <select>태그에서 연도를 고르고, 검색을 누르면 검색은 되는데 매 요청마다 <select>태그의 선택된 값은 기본값을 보고 있다. 요약하자면 <select>태그의 selected 옵션을 정확히 지정하지 못하는 문제이다.
내가 원하는 로직의 진행 순서는 다음과 같았다.
1.
(창 새로고침 시)백엔드로 연도리스트를 요청
2.
(응답 시) <select> 태그 밑에 appendChild로 <option> 추가.
3.
(위 작업 종료 시) <select> 태그의 value 값을 현재 param값으로 설정.
이를 위해서 다음과 같이 코드를 추가했다.(xhr방식을 사용하고 있어서 그대로 유지하기 위해서 조금만 수정했다.)
const loadYear = async function(){
    // xhr settings....

    xhr.onload = function() {
        // data 가공...
        // list순회하며 <select> 태그에 각각 <option> appendChild...
    }
    await xhr.send();
}

$(document).ready(async function () {
    const selectElementId = document.getElementById('');
    await loadYear();
    selectElementId.value = param;
});

async await 은 Promise 확장판

다음과 같은 두가지 기본 전제를 잊고 있었다.
async 함수는 Promsie를 반환해야한다.
await은 Promise 를 반환하는 함수에 대해서만 사용할 수 있다.
초기코드에서 문제점은 다음과 같다.
1.
loadYear함수는 async를 붙였는데 Promise를 반환하지 않는다.
보통 fetch를 사용하면 자동으로 Promise를 반환해준다. 하지만 xhr은 그런거 없다. 직접 Promise를 만들어야한다.
2.
그러니까 당연히 xhr.send도 await되지 않고 loadYear도 await이 될 리가 없다.
뒤에 오는 함수가 Promise를 반환하지 않으니까!

async await 복습하기.

Promise의 기본

**Promise**는 JavaScript에서 비동기 연산을 나타내는 객체입니다. 비동기 연산의 결과(성공 또는 실패)를 추후에 처리할 수 있도록 해줍니다. **Promise**는 세 가지 상태를 가집니다:
1.
Pending (대기 중): 초기 상태, 연산이 완료되지 않음.
2.
Fulfilled (이행됨): 연산이 성공적으로 완료됨.
3.
Rejected (거부됨): 연산이 실패함.

async 함수

async 키워드는 함수를 비동기 함수로 만들어 줍니다. async 함수는 항상 **Promise**를 반환합니다. 함수 내에서 반환하는 값은 **Promise.resolve()**를 통해 이행(fulfilled) 상태의 **Promise**로 변환됩니다. 예외가 발생하면, 이는 **Promise.reject()**를 통해 거부(rejected) 상태의 **Promise**가 됩니다.
async function asyncFunction() {
  return '결과값';
  // 이는 Promise.resolve('결과값')과 동일하게 작동합니다.
}

await 키워드

await 키워드는 async 함수 내에서만 사용할 수 있으며, **Promise**의 해결을 기다립니다. **await**는 **Promise**가 이행되거나 거부될 때까지 함수의 실행을 일시 중지합니다. **Promise**가 이행되면, **await**는 **Promise**의 결과값을 반환합니다. **Promise**가 거부되면 예외를 발생시킵니다.
async function asyncFunction() {
  let value = await someAsyncOperation();
  // someAsyncOperation이 Promise를 반환하며, 그 해결을 기다립니다.
  console.log(value); // Promise가 이행된 후에 실행됩니다.
}

에러 핸들링

**async/await**에서 에러 핸들링은 try/catch 블록을 사용하여 처리할 수 있습니다. await 표현식에서 발생한 예외는 catch 블록에서 잡을 수 있습니다.
async function asyncFunction() {
  try {
    let value = await someAsyncOperation();
    console.log(value);
  } catch (error) {
    console.error('에러 발생:', error);
  }
}

그래서

**async/await**는 **Promise**를 더 쉽고 직관적으로 사용할 수 있도록 해주는 강력한 구문입니다. 기존의 Promise 체이닝 방식에 비해 코드의 가독성을 크게 향상시키며, 비동기 로직을 마치 동기 로직처럼 쉽게 작성할 수 있게 해줍니다. 그러나 내부적으로는 여전히 **Promise**를 사용하고 있으며, 이를 이해하는 것은 중요합니다.

해결책

loadYear를 Promise를 반환하게 만들고 resolve되었을 때, select의 value값을 변경하면 된다.
const loadYear = function() {
  return new Promise((resolve) => {
    var xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://example.com/data', true);
    xhr.onload = function() {
            // data 가공...
            // list순회하며 <select> 태그에 각각 <option> appendChild...
            resolve();
    };
    xhr.send();
  });
};

loadYear().then(()=>{
    // option들이 모두 추가된 뒤 해야하는 행동들
    selectElementId.value = param;
})
결국 내가 async await을 잘못 이해한 잘못이었다. 이 코드를 보니 새삼 자동으로 Promise를 리턴해주는 fetch가 감사하게 느껴진다.
const useXhr = (method, url) =>
    new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
    xhr.open(method, url, true);
        xhr.onload = () => { 
            if (xhr.status >= 200 && xhr.status < 300) {
        resolve(xhr.responseText);
      } else {
        reject(new Error(xhr.statusText));
      }
        };
        xhr.onerror = () => { 
            reject(new Error("Network Error")); 
        };
    });

// 사용
useXhr('GET', url)
.then(data => JSON.parse(data))
.then(res => 후처리)
.catch(err => console.log(err));
이런 식으로 fetch와 비슷한 형태로 사용할 수 있도록 useXhr hook을 미리 만들어 놓는다면 좀더 깔끔하게 쓸 수 있을 것 같다.

변명1 : 아니 console.log가 맞다고 했다니까??? (아님)

착각

이게 사실 변명할 요소가 좀 있다. 나도 분명 비동기 처리에 문제가 있다고 생각해서 원래 없던 async await 슬쩍 끼워 넣어본거였다. 그리고 나서 디버깅용으로 중간중간 console.log 찍어봤는데 잘 되는 것 같았다.
const loadYear = async function(){
    // xhr settings....

    xhr.onload = function() {
        // data 가공...
        // list순회하며 <select> 태그에 각각 <option> appendChild...
    }
    await xhr.send();
}

$(document).ready(async function () {
    const selectElementId = document.getElementById('');
    await loadYear();
    console.log(selectElementId);
    selectElementId.value = param;
});
이러니까 저기 console.log는 모든 option이 다 들어간(비동기 처리가 완료된) 엘리먼트를 보여줬다. 그래서 이제 다른길로 좀 샜다. 다른 친구들 불러서 '아니 봐봐. select태그에 옵션은 다 잘들어갔어. 그러니까 비동기 문제는 이제 해결되었고, 다른 문제인 듯' 이런 헛소리를 한거다.

진실

나도 믿을 수 없었는데 console.log는 참 친절하다.^^ 객체에 대한 로그에 대하여 'Lazy Evaluation'이라는 기능을 지원한다. 간단히 말해 로그한 객체에 나중에 변화가 생기면, 옛날에 로그한것도 쓱싹쓱싹 고친다는 이야기다.

객체의 "늦은" 평가 (Lazy Evaluation)

브라우저 콘솔은 로그된 객체를 "늦게" 평가(lazy evaluation)하는 방식을 사용할 수 있다. 즉, **console.log**를 호출하는 시점의 객체 상태가 아니라, 콘솔에서 해당 로그를 확장하여 보는 시점의 객체 상태를 보여준다. 이는 다음과 같은 시나리오에서 혼동을 야기할 수 있다.
1.
비동기 작업 전에 객체를 로그합니다.
2.
비동기 작업이 객체를 수정합니다.
3.
콘솔에서 로그된 객체를 나중에 확인할 때, 비동기 작업에 의해 변경된 객체의 상태를 보게 됩니다.
아래 글을 참고하면 이해가 빠를 수 있다.
그래서 내 selectElementId는 처음에는 option이 들어가지 않은채로 보여줬을 것이다. 그리고 비동기 처리가 완료되고 append되자마자 이 console.log를 고쳐서 내가 눈치채지도 못하게 모든 option이 들어간 select 태그를 보여준 것이다.
이를 피하기 위해서는 동일한 객체가 아니면 된다. 스프레드 연산자나 JSON 메소드를 이용해 복사된 객체를 사용할 수 있다.
console.log(obj); // obj의 최신상태!!
console.log(JSON.parse(JSON.stringify(obj))); // 호출시점의 obj
console.log({...obj}); // 호출시점의 obj
이 LazyEvaluation은 항상 일어나지는 않는다. 객체가 DOM요소처럼 복잡할수록 일어날 확률이 높고, 브라우저별로도 차이가 있다.

변명2 : 아니 비동기가 문제면 setTimeout으로 걸어보면 되지않냐??? (아님)

착각

아무리봐도 비동기가 문제인것 같아서 비동기 요청이 완료된 시점(1초뒤면 끝나겠지ㅋㅋ)에 param으로 value값을 설정해보는 테스트를 해봤다.
const loadYear = async function(){
    // xhr settings....

    xhr.onload = function() {
        // data 가공...
        // list순회하며 <select> 태그에 각각 <option> appendChild...
    }
    await xhr.send();
}

$(document).ready(async function () {
    const selectElementId = document.getElementById('');
    await loadYear();
    setTimeout(()=>{selectElementId.value = param}, 1000);
});
여전히 문제는 해결되지 않았다. 더더욱이 비동기 문제가 아니라고 확신하게 되었다.

진실

setTimeout에 들어가는 ()=>{selectElementId.value = param} 이 부분은 콜백함수이다. 콜백함수는 자신이 호출될때가 아닌 선언될 당시의 클로져를 기억한다. 그러니까 selectElementId는 1초뒤에 호출되는 시점의 Element( 비동기 처리가 완료되어 모든 연도 option들 이 append된)가 아니라 선언되는 시점의 Element(비동기 처리가 진행중이고 아직 연도option이 append되지 않은) 를 기억하고 있기 때문에 이 또한 오류가 나는 것이다.
Yejun Cheon
S’abonner à 'Yejun Cheon'
En vous abonnant à ce site, vous recevrez en avant-première les dernières mises à jour, comme les nouveaux articles, par notification et par e-mail.
Inscrivez-vous à Slashpage et abonnez-vous à 'Yejun Cheon' !
S’abonner
👍