프론트엔드의 절반은 눈에 보이는 유저 UI고, 나머지 절반은 데이터 통신이라고 한다. 프론트엔드, 그중 React를 사용한 데이터 통신의 기초에 대해 정리해본다. HTTP 통신, API의 기초 개념에 대해서는 생략한다.
개인적으로 프로젝트를 하는 와중 만든 백엔드 API도 모두 RESTful API였지만, 트렌드에 맞춰 GraphQL에 대한 이해도 해보고 넘어간다. 우선 둘의 차이를 보면:
REST API
→ example.com/class
→ example.com/class/{반 index}
→ example.com/class/{반 index}/students
→ example.com/class/{반 index}/students/{학생 index}
GraphQL
→ example.com/graphql
(하나의 엔드포인트에 다른 쿼리를 사용해 요청)
예시 출처: 링크
위와 같은 방식이다.
이를 통해 GraphQL을 사용하면 엔드포인트의 수를 줄이고, 필요한 데이터만 골라 받아 효율적인 통신을 할 수 있다. 이런 장점으로 Facebook, Airbnb, Github등 서비스들이 GraphQL을 채택하고 있다고 한다.
리액트에서 GraphQL API와 통신하기 위해선 apollo-client
를 사용한다.
인턴을 처음 하기 시작하면서, 장장 하루를 써서 겨우 개념을 이해 할락 말락 했던 중요하지만 이해하긴 어려울 수 있는 개념이다.
동기 (Synchronous) 방식은 앞의 작업이 완료되고 응답이 돌아올때까지 뒤 작업은 기다리고 완료되어야 다음 작업이 진행된다.
비동기 (Asynchronous) 방식에선 응답 상태와 상관 없이 다음 동작 수행이 가능하고, A작업이 시작되면 B 작업도 동시에 실행된다.
항상 좋은 비유라 생각하는데, 보통 세탁기에 빨래를 던져넣고 빨래가 끝나기 전까지 하던 모든것을 멈추고 대기하진 않는다. 그 사이에 우리는 다른 할 일을 하고, 세탁기도 세탁이 끝나면 알아서 결과값으로 세탁된 빨래를 뱉는다. 이게 비동기 실행의 좋은 예시 중 하나다.
보통 웹사이트를 보면 모든것이 로딩될때까지 화면을 보여주지 않지는 않는다. 로딩되는대로 바로바로 보여주는 방식인데, 이게 비동기 실행이 응용되고 있는 방식 중 하나다.
자바스크립트는 싱글 스레드 런타임을 가진 동기식 언어이고, 모든 코드들이 동기적으로 실행된다. 다른 싱글 스레드 작업들처럼 JS도 하나의 힙과 콜 스택만을 가지고, 한번에 한가지 일밖에 하지 못한다. 다만 웹개발에 쓰이는 언어인만큼 브라우저나 Node.js 등 런타임의 도움으로 비동기적으로도 작업을 실행할 수 있다.
밑의 사진들의 출처는 모두 이 비디오다.
위의 코드와 사진을 보면서 보자. 우선 코드가 실행되면, 런타임 스택에는 main()
이 먼저 들어가고, 첫줄인 console.log()
가 그 다음에 들어가 실행되고 출력된다.
그후 setTimeout
이 스택에 들어가고, Web API (브라우저, NodeJS등)이 타이머를 실행해준다. 그리고 그 타이머가 API에서 돌아가는 동안, setTimeout
은 스택에서 빠진다. 그 후 마지막 console.log()
까지 실행 되고, 스택은 텅 비게 된다.
그 상태에서 타이머가 종료되고, 그 안의 콜백을 task queue로 보낸다. 그 후 event loop가 활약하는데, event loop의 역할은 스택과 task queue를 확인하고, 스택이 만약 비어있다면 task queue에 있는 첫번째 작업을 스택으로 이동시킨다. 그래서 위처럼 cb가 큐에서 스택으로 이동하게 된다. 그 후 마지막으로 cb가 실행되어 there가 프린트되고 작업이 종료된다. JS에서의 비동기 처리 원리는 위와 같다고 보면 된다.
보통 비동기 처리를 위해 비동기 요청의 응답이 돌아오고 난 후 처리할 콜백 함수를 함께 알려준다.
하지만 콜백 패턴을 사용하면 처리 순서 보장을 위해 여러개의 콜백 함수를 중첩해야 하고, 이는 악명높은 콜백 지옥으로 이어진다. 이를 해결하기 위해 Promise가 ES6에 등장한다.
비동기 통신에서 get 요청이 끝나지도 않았는데 해당 요청에 대한 쿼리가 먼저 실행되어 버리면 오류가 발생할 수도 있다. Promise는 이를 해결하기 위해 JS 비동기 처리에 사용되는 객체이고, 세가지 상태가 있다.
const data = axios.get('https://someapi.com/posts/1');
console.log(data); // Promise 출력
이를 사용한 위 코드를 살펴보자. axios는 비동기 통신을 실행해준다. 데이터를 받아온 후, axios 요청은 Promise를 data 변수에 넣어준다. 우린 Promise 말고 데이터가 보이면 좋겠는데. 그래서 이 코드는 동기 통신으로 변경될 필요가 있다.
일반적인 Promise의 사용법을 보자.
const promise = new Promise((resolve, reject) => {
// 비동기 작업 실행
if (/* 비동기 작업 성공 */) {
resolve('result');
}
else { /* 비동기 작업 실패 */
reject('실패한 이유');
}
});
비동기 작업이 성공적으로 실행되었을 경우 resolve()
를, 실패했을 경우 reject()
를 호출한다. 이 두 메서드는 이행/거부 상태의 Promise 객체를 반환한다.
생성된 Promise 객체는 then(), catch(), finally()
메서드를 연결하여 사용 가능하다.
then()
은 두 개의 콜백 함수를 인자로 받고 Promise 객체를 리턴한다. 첫번째 콜백 함수는 이행 (fulfilled) 되었을 때의 결과를 다루고, 두번째는 거부 (rejected) 되었을 떄의 에러를 다루는 콜백 함수다. 성공한 경우만 다루고 싶을땐 두번째 콜백을 생략하고, 반대의 경우엔 첫 콜백 함수 인자를 null로 넘긴다.
promise.then(
(result) => {
// 결과를 다루는 콜백
},
(error) => {
// 에러를 다루는 콜백
}
);
형식이다.
catch()
메서드는 에러를 처리하며, promise.then(null, callback)
과 같은 동작을 수행한다.
finally()
은 이행/거부에 상관없이 실행되는, ES2018에 처음 등장한 개념이고, 작업이 종료되었을때 필요한 공통적인 처리 로직을 수행한다.
그래서 Promise가 어떻게 우리를 콜백 지옥에서 구원해줄수 있을까?
우선 콜백 지옥 코드를 보자.
a ((resultFromA) => {
b (resultFromA, (resultFromB) => {
c (resultFromB, (resultFromC) => {
console.log(resultFromC);
});
});
})
보기만 해도 엄청 복잡하단 걸 알 수 있다. Promise 체이닝을 통해 한번 개선해보자.
a().then((resultFromA) => b (resultFromB))
.then((resultFromB) => c (resultFromB))
.then((resultFromC) => console.log(resultFromC))
훨씬 간결해졌다!
resolve(), reject()
외에도 정적 메서드가 몇가지 더 있다.
all()
메서드를 사용하여 여러개의 Promise를 동시에 실행시키고, 모든 Promise가 완료되면 결과값을 배열로 반환 가능하다. 인자로 받은 여러 Promise들의 시작, 종료 순서는 알 수 없지만 결과값만큼은 인자로 넘긴 순서로 반환된다. 예시를 보자.
Promise.all([doA(), doB(), doC()).then((result) => {
values = result;
return result;
}).then(() => {
return sum();
}).then((total) => {
console.log(total);
});
보고있는 책의 예시인데, doA...doC는 각각 500의 타임아웃 이후에 각각 1, 2, 3을 반환하는 함수고, sum 함수는 이 값들을 push받은 배열의 모든 원소를 더하는 방식이다. then 형식으로 코드를 작성하면 너무 길었지만, all()
을 사용하여 효율적으로 여러개의 Promise를 한번에 실행 시킬 수 있다. 배열에 담긴 Promise중 하나라도 실패하면 즉시 거부되고, 첫번째로 발생한 실패한 이유를 반환한다.
만약 각각의 Promise를 별도로 처리하고 싶다면, allSettled()
메서드를 사용하여 인자로 들어온 모든 Promise에 대한 결과를 볼 수 있다.
마지막으로 race()
메서드로 가장 먼저 처리되는 Promise 결과를 반환하고, 첫 결과가 나오면 나머지 모든 결과는 무시할 수도 있다.
Promise 체이닝도 길어지면 여전히 가독성이 떨어지는데, ES2017에서 위 친구들은 비동기를 동기로 바꿔줄 수 있다.
async function 함수이름() {
const data = await axios.get('https://someapi.com/posts/1')
console.log(data) // 원하는 데이터 추력!
}
위 코드는 아까의 예시에서 await가 붙었다. 위처럼 코드를 작성하면 await가 붙은 코드 (Promise) 의 실행/이행이 완료되기 전까진 밑의 코드로 실행이 넘어가지 않는다. await를 붙히려면 함수 앞에 async를 붙혀야 하고, async가 붙으면 Promise를 반환한다는 의미가 된다.
async의 힘을 볼수 있는 다른 예제를 보자.
function getResult() {
a().then((resultFromA) => b (resultFromB))
.then((resultFromB) => c (resultFromB))
.then((resultFromC) => console.log(resultFromC))
}
async function getReulst() {
const resultFromA = await a();
const resultFromB = await b(resultFromA);
const resultFromC = await c(resultFromB);
console.log(resultFromC);
}
눈물날 정도로 감동적이게 간단하고 읽기 쉽게 바뀌고, 우리가 원하는 순서대로 코드도 구현되었다.