-
자바스크립트 비동기의 원리와 비동기 제어 사례코어 자바스크립트(정재남 지음) 스터디 모임 공부 2021. 7. 7. 01:28
**본 페이지는 책 코어 자바스크립트(정재남 지음)에 나오지는 않지만, 그 책의 내용을 이해하기 위해 필요한 개념인 비동기를 설명합니다.
자바스크립트에서 초심자들을 가장 애먹이는 개념인 비동기, 왜 이해해야 할까?
비동기는 자바스크립트로 웹서비스를 만든다면 반드시 이해해야 하는 개념이다. 특히 클라이언트(프론트)가 서버로부터 데이터를 받아오는 작업은 자바스크립트 전체로 보면 비동기 작업인데, 이를 이해하고 코드를 짜야 서버로부터 받아온 데이터가 화면에 문제없이 표출된다. 그런데 비동기를 모르고 서버에서 데이터를 받아오는 코드를 짠다면, 이해할 수 없고 해결하기에도 견적이 나오지 않는 에러가 나올 위험이 크다(내가 부트캠프에서 프로젝트 하면서 몸소 겪음). 그리고 SPA를 만들 수 있는 라이브러리인 React는 비동기 제어를 알아서 해주지 않기 때문에 React 프로그래머라면 비동기 제어 코드를 작성할 능력을 갖춰야 한다. 그러니 비동기가 취업 면접의 단골 질문인 이유가 있는 것이다!!
그럼 비동기-동기의 기본 개념부터 알고 가자.
나는 비동기 공부를 인프런의 동기-비동기 무료 강좌를 시청하는 것으로 시작했다. 무료이고 짧고 굵으며 설명도 잘 해주니 비동기 공부에 입문한다면 꼭 보고 가자. 다만 이 강좌에서는 멀티스레드 환경에서 비동기와 동기를 설명하므로, 싱글스레드 환경인 자바스크립트의 비동기 원리와는 차이가 조금 있다는 것을 알고 듣도록 하자.
먼저 이 강좌에서 나오는 비동기 개념을 설명하겠다.
멀티스레드 환경에서 스레드1에 작업들이 줄을 지어 대기중이다.
여기서 가장 첫번째인 task1을 다른 스레드인 스레드2로 보내 작업을 시키려 한다.
여기서 즉시 리턴이란, 스레드1 입장에서는 스레드2로 보낸 task1은 스레드2가 알아서 하게 두고 자신은 task2 작업을 시작하겠다는 의미이다.
그래서 비동기는 자기 작업의 일부를 다른곳에서 수행하게 하고, 다른곳에서 그 작업이 끝나지 않아도(= 안 기다린다) 자신의 작업을 수행하는 작업 방식이라고 보면 된다. 비동기는 한 작업이 너무 오래 걸리는 경우 분산처리로 작업의 효율성을 높일 때 사용한다. 네트워크 작업(위에서 언급한 서버에서 데이터를 받아 오는 등의 작업)은 시간이 오래 걸리므로, 주로 서버 통신 같은 작업에서 비동기 작업이 등장하게 된다.
이제 동기를 이해해보자.
여기서도 task1을 스레드2에다 맡겼다.
동기에서는 스레드2에 task1을 맡겼음에도, task1이 끝날때까지 기다린다. 그래서 task1이 끝나고 나서야 task2를 시작한다. 그래서 동기는 작업의 일부를 다른 곳에 맡기더라도, 그 작업이 끝나고 나서야(=기다린다) 다음 taks를 실행시키는 작동방식이다. 동기는 작업의 순서가 중요한 경우에 사용이 된다.
그래서 정리하자면
그러면 싱글스레드 환경인 자바스크립트에서 비동기는 어떻게 일어날까?
그런데 위의 설명대로라면 한 스레드에서 다른 스레드로 작업을 이전시킬 수 있는 멀티스레드 환경이라야 비동기가 일어나는 것 같아서, 코드가 실행되는 환경이 싱글스레드인 자바스크립트에서는 비동기가 원천적으로 일어나지 않을것 같다. 하지만 자바스크립트 구동 환경의 특성으로 인해 자바스크립트에서도 비동기가 일어난다.
위는 자바스크립트가 작동하는 환경으로, 크게 보아 '코드가 실행되는 싱글스레드의 콜스택이 있는 자바스크립트 엔진 + web APIs + 콜백큐 + 이벤트루프' 이 네가지로 이루어져 있다. 이 환경으로 대략적으로 자바스크립트 비동기가 일어나는 원리를 설명하면 다음과 같다.
1. 콜택에서 코드가 하나씩 동기적으로 순서대로 처리되는 와중에 브라우저가 제공하는 API(setTimeout)이나 네트워크 통신(ajax) 같이 시간이 오래 걸리는 코드들은 처리하지 않고 web APIs로 빼둔다.
2. 콜스택의 코드들의 작업이 모두 끝나고 지워져 콜스택이 텅텅 비게 된다.
3. 그러면 webAPIs에 모인 코드들의 콜백함수들을 콜백큐에 넣는다.
3. 그러면 이벤트 루프가 콜백큐의 콜백함수들을 콜백큐에 하나씩 넣어서 콜백함수들의 결과값을 반환한다.
이 설명은 유튜버 우리밋의 [이벤트 루프(1/2)] JS로 개발하는데 내부 동작을 모르면 곤란합니다 | 코드 실행 과정에 잘 설명되어 있으므로 이 영상을 캡쳐해서 설명하겠다. (감사합니다 우리밋!!!)
왼쪽에 보이는 코드에서 가장 위에 있는 console.log가 콜스택에 들어가서 실행이 되고, 그 다음 코드인 setTimeou이 콜스택에 올라가서 실행이 되고 싶어하지만,
setTimeout은 브라우저 제공 API이므로 Web APIs로 들어가고, 그 다음 코드인 console.log가 실행된다.
setTimeout에서 지정한 3초가 지나면 web APIs에 있던 setTimeout의 콜백함수가 콜백큐에 들간다. 그리고 콜스택이 비어 있어야 콜백큐에 있는 콜백함수들이 콜스택에 입장할 수 있으므로, 이벤트루프가 콜스택이 비어 있는 걸 확인하면이벤트루프는 콜백큐의 콜백함수를 콜스택에 입장시킨다.
콜백큐에 있던 콜백함수가 콜스택에 올라가 실행이 되고 끝난다.
이런 원리로 자바스크립트에서는 비동기가 실행이 된다. 내가 보기에는 동시에 작업이 일어나게 한다기 보다 미뤄뒀다가 나중에 작업이 일어나게 한다고 보는게 정확한 해석일듯 싶지만, 어쨌든 이렇게 비동기 작업이 일어나게 된다.
(자바스크립트에서 동기 작업은 이렇게 web APIs로 빼내는 작업이 없이 콜스택의 코드가 위에서부터 아래로(스택이니까) 실행되기만 한다고 보면 된다.)
부트캠프 프로젝트에서 내가 겪었던 비동기 문제들
부트캠프 프로젝트를 하던 때는 비동기를 제대로 모르던 시절이었다. 그래서 비동기 제어가 되지 않아서 생긴 에러에 원인도 모르고 헤매다가 튜터님들의 도움으로 겨우 겨우 해결했던 적이 있다. 비동기를 이해한 지금에 와서 보면 스스로 해결이 가능한 문제였다. 그럼 어떤 문제들이었는지 이제 알게 된 비동기 원리로 사례를 분석해보자. 모든 사례들은 순수 자바스크립트가 아니라 React 환경의 자바스크립트에서 일어난 비동기 제어 문제들이다.
1. 서버로부터 응답으로 받아온 데이터가 변수에 할당되는 과정에서 일어난 비동기 문제
```Detail.js : 제품 상세페이지 컴포넌트 파일에 있는 서버로부터 받아온 데이터를 처리하는 코드다``` React.useEffect(() => { dispatch(postActions.getPostAPI(detail_id)); }, []); // useSelector에서 문제가 생김 const post_list = useSelector((state) => state.post.list) const target_idx = post_list.findIndex((p) => p.id == detail_id); const post_target = post_list[target_idx]
이 문제는 오후 낮 시간을 모두 날려버리게 한 업적을 세운 문제로, 초빙한 React 고수들도 해결하지 못한 난제였다. 결국 경력이 많은 풀스택 튜터님의 도움으로 겨우 해결했던 문제다.
외쳐 갓병관!문제 분석
1. useEffect 안에서 dispatch가 마운트 되면서, Redux 모듈 함수에서 정의된 서버에서 데이터를 받아오는 미들웨어 함수(함수명 getPostAPI)가 실행이 된다.
2. 이 미들웨어 함수의 코드는 다음과 같이 서버와 통신하는 코드이므로 비동기 처리가 된다.
// 서버에 있는 상품 데이터를 가져온다. // 상세페이지의 여러 데이터를 가져온다. // 메인페이지에 보이는 상품의 정보와는 달리 // 한 상품에 관한 더 상세하고 방대한 정보를 받아 온다. const getPostAPI = (boardId) => { return function (dispatch, getState) { const _token = localStorage.getItem("Authorization");//로그인한사람만접근 let token = { headers : { Authorization: `${_token}` }, } const API = `http://seungwook.shop/boards/${boardId}/details`; axios.get(API) .then((response) => { console.log(response.data); let detail_post = []; let post = { // id: _post.id, 설명용. 이게 아니라 아래것을 쓸 것이다. id: boardId, seller_id: response.data.userId, // 수정할 때 변경할 데이터는 아래 네가지 email: response.data.userEmail, image_url: response.data.imgUrl, title: response.data.title, price: response.data.price, content: response.data.content, } detail_post.unshift(post); // 최신순으로 포스트가 정렬되게 unshift로 한다. console.log(detail_post); dispatch(setDetailPost(detail_post)); // 리덕스의 값 변경 }).catch((error) => { window.alert("상품게시물을 가져오지 못했습니다."); console.log(error); }) } }
3. 위 코드에서 주석으로 '// 리덕스의 값 변경'이라고 된 부분의 코드로 서버에서 받아온 데이터가 Redux store에 저장이 된다.
4. 그런데 이것은 비동기 작업이므로, 서버로부터 데이터를 받아와서 Redux에 저장이 된 데이터를 useSelector를 이용해 post_list라는 변수에 할당하기 전에 post_list에 데이터가 할당 되지 않고 undefined 상태로 코드가 실행된다. 이어서 post_list를 이용해서 값이 할당되는 변수 target_idx, post_target도 값이 undefined 상태가 된다.
React.useEffect(() => { dispatch(postActions.getPostAPI(detail_id)); }, []); // useSelector에서 문제가 생김 const post_list = useSelector((state) => state.post.list) const target_idx = post_list.findIndex((p) => p.id == detail_id); const post_target = post_list[target_idx]
결론 : 서버와 통신하는 getPostAPI 함수는 비동기로 작동해서 나중에 처리 되므로, post_list 변수에 서버에서 온 데이터가 할당이 되지 않은채로 코드가 실행되면서 에러가 발생하는 것.
해결책 : 서버로부터 받은 데이터가 할당이 된 다음에 코드가 실행이 되게 코드를 제어해준다.
해결코드
React.useEffect(() => { dispatch(postActions.getPostAPI(detail_id)); }, []); const post_list = useSelector((state) => { // return을 넣어주면, post_list에 return값이 할당이 되고 나서야 다음번 코드가 실행이 된다. return state.post.detail_list }); const target_idx = post_list.findIndex((p) => p.id == detail_id); const post_target = post_list[target_idx]
이전에는 useSelector는 return없는 화살표 함수를 콜백함수 인자로 받았지만, 이제는 엄격하게 return으로 결과값을 반환하는 것을 명시해주면, 변수에 반환값이 할당이 되고 난 다음에야 다음 코드가 실행되게 된다. 다시말해, 이렇게 해주면 getPostAPI 함수로 받아온 서버 데이터가 변수에 할당되고 난 다음에야 다음 코드가 실행되게 강제로 순서를 조정해주는 효과가 생긴다. 이렇게 함으로써 비동기로 인해 데이터가 할당되기 전에 코드가 실행되어 발생하는 에러가 생기지 않도록 방지해줄 수 있다.
2. 지도페이지에서 장소 검색을 했을 때 query 에러가 나는 경우
부트캠프의 실전프로젝트에서 만든 서비스에 지도페이지 서비스가 있고, 검색창에서 장소를 검색할 수 있게 했다.
// 키워드로 검색하기!!!!!! // 장소 검색 객체를 생성합니다 var ps = new kakao.maps.services.Places(); // 키워드로 장소를 검색합니다 ps.keywordSearch(search, (data, status, pagination) => { if (status === kakao.maps.services.Status.OK) { // 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해 var bounds = new kakao.maps.LatLngBounds(); // LatLngBounds 객체에 좌표를 추가합니다 // console.log(data); // console.log(bounds); for (var i = 0; i < data.length; i++) { // displayMarker(data[i], bounds); bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x)); // 검색된 장소 위치를 기준으로 지도 범위를 재설정합니다. _map.setBounds(bounds); } } else if (status === kakao.maps.services.Status.ZERO_RESULT) { // window.alert("검색결과가 존재하지 않습니다."); return; } else if (status === kakao.maps.services.Status.ERROR) { Swal.fire({ text: "검색 결과 중 오류가 발생했습니다.", confirmButtonColor: "#ffb719", }); return; } } );
그런데 이상하게도 개발자도구의 콘솔창에는 이런 에러메시지가 뜬다. 그럼에도 장소는 잘 찾아주니 기능상에는 문제는 없긴 했다.
이 메시지를 잘 보면 query= 이 부분 뒤에 아무것도 없다. qurey= 뒤에는 위 코드의 5번째 줄에 나오는 함수의 첫 부분에 인자로 입력되는 search가 들어가야 하는데 그러지 않고 있다. 그래서 저런 오류가 생기는 것이고, search는 검색창에 입력되는 입력값이 할당이 되는데, 할당되기 전 undefined 상태로 함수가 실행되어 버리는 것이 이 오류의 원인이다. 따라서 search에 값이 할당 된 다음, 할당된 조건하에서만 저 함수가 실행되게 하면 된다.
해결 코드는 다음과 같이 if 문으로 search가 할당된 상태에서만 실행되게 처리해주면 된다.
// 키워드로 검색하기!!!!!! // 장소 검색 객체를 생성합니다 var ps = new kakao.maps.services.Places(); // 키워드로 장소를 검색합니다 if (search) { //search가 빈 string일때 검색이 되어서 오류가 뜨는 경우를 없애기 위해 if문으로 분기한다. ps.keywordSearch(search, (data, status, pagination) => { if (status === kakao.maps.services.Status.OK) { // 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해 var bounds = new kakao.maps.LatLngBounds(); // LatLngBounds 객체에 좌표를 추가합니다 // console.log(data); // console.log(bounds); for (var i = 0; i < data.length; i++) { // displayMarker(data[i], bounds); bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x)); // 검색된 장소 위치를 기준으로 지도 범위를 재설정합니다. _map.setBounds(bounds); } } else if (status === kakao.maps.services.Status.ZERO_RESULT) { // window.alert("검색결과가 존재하지 않습니다."); return; } else if (status === kakao.maps.services.Status.ERROR) { Swal.fire({ text: "검색 결과 중 오류가 발생했습니다.", confirmButtonColor: "#ffb719", }); return; } }); }
3. 지도페이지가 로드 되어도 마커가 뜨지 않는 경우
지도페이지가 로드되면 마커가 떠야 하는데 그렇지 않았다.
이 문제의 원인은 다음과 같다.
// 모든 게시물의 데이터들을 받아 온다. const map_post_list = useSelector((state) => { return state.post.map_post_list; });
위 코드처럼 redux store로부터 받아오는 데이터를 map_post_list에 할당하고 그 데이터를 가공해서 마커를 띄운다. 그리고 마커를 띄우는 코드는 useEffect안에 있다. 그런데 map_post_list에 데이터가 할당되는 과정은 비동기라서 데이터가 미처 할당 되기 전에 useEffect안의 마커를 띄우는 함수가 실행되어 버리니 마커가 뜨지 않는 것이다. 그러면 useEffect의 dependency에 map_post_list를 넣어줘서 map_post_list에 데이터가 할당되어 state가 변한다면 리렌더링이 일어나게 한다. 그렇게 하면 데이터 할당이 끝난 시점에 마커가 뜨게 되므로 문제가 해결된다.
}, [ startLat, startLng, is_mypost, is_mylike, // 이 부분!!!! map_post_list, is_all, is_cafe, is_night, is_ocean, is_mountain, is_flower, is_alone, is_couple, is_friend, is_pet, is_city, is_park, is_exhibition, ]);
4. 데이터를 지우고 새 데이터로 채워야 하는데, 데이터 지우는 코드도 새 데이터를 넣는 코드도 비동기로 작동해서 데이터가 중구난방이 된다면?
const initializeApp = () => { // 데이터를 삭제 시키는 위의 dispatch들 dispatch(profileActions.resetProfile([])), dispatch(storyPostActions.resetStory([])), // 새 데이터를 채우는 dispatch들 dispatch(profileActions.getUserInfoAPI(userId)); dispatch(storyPostActions.getUserPostAPI(userId)); dispatch(storyPostActions.getUserLikeAPI(userId)); };
위의 함수처럼 내부에 데이터를 삭제시키고 새로 채우는 코드(dispatch로 서버와 통신하는 미들웨어를 발동시키는 코드) 가 같이 있다. 그런데 저 코드들은 서버와 통신하는 코드들이므로 비동기라 작업이 끝나는 순서가 뒤죽박죽이라 결과 데이터도 중구난방이 된다. 이런 경우는 원하는 작업이 먼저 일어난 다음 그 다음 작업이 일어나게 해줘야 한다. 원하는 작업 순서는 데이터 삭제 -> 새 데이터로 채우기 이다. 해결방안은 promise나 async-await를 이용해서 데이터를 삭제하는 코드가 먼저 일어나게 하고, 그 다음에 데이터를 채우는 코드는 기다렸다가 실행되게 해주는 것이다.
해결코드
// Promise로 처리 const initializeApp = async () => { Promise.all([ dispatch(profileActions.resetProfile([])), dispatch(storyPostActions.resetStory([])) ]); dispatch(profileActions.getUserInfoAPI(userId)); dispatch(storyPostActions.getUserPostAPI(userId)); dispatch(storyPostActions.getUserLikeAPI(userId)); };
여기서는 삭제하는 코드가 2개 이므로 Promise의 all 메서드로 그 코드들을 묶어서 그 두가지가 먼저 일어나게 해준다.
// async, await 만 const initializeApp = async () => { await dispatch(profileActions.resetProfile([])); await dispatch(storyPostActions.resetStory([])); dispatch(profileActions.getUserInfoAPI(userId)); dispatch(storyPostActions.getUserPostAPI(userId)); dispatch(storyPostActions.getUserLikeAPI(userId)); };
이렇게 async, awit로 해도 되고 Promise.all 메서드를 쓴 것과 같은 효과가 있다. 어차피 async - awit는 Promise의 문법적 설탕이기 때문이다.
참고자료
유튜버 얇팍한 코딩사전 [병맛코딩만화] 비동기 프로그래밍이 뭔가요?
'코어 자바스크립트(정재남 지음) 스터디 모임 공부' 카테고리의 다른 글
프로토타입 체인(feat. __proto__ 무한 꼬리물기) (0) 2021.07.11