Ajax의 발전 과정

AJAX는 자바스크립트를 사용하여 브라우저가 서버에게 비동기 방식으로 데이터를 요청하고, 서버가 응답한 데이터를 수신하여 웹페이지를 동적으로 갱신하는 프로그래밍 방식입니다.

전통적인 웹페이지는 html 태그로 시작하여 html로 끝나는 완전한 HTML 문서를 서버로부터 전송받아 웹페이지 전체를 처음부터 다시 렌더링하는 방식으로 동작했습니다. 따라서 화면이 전환되면 서버로부터 새로운 HTML 문서를 전송받아 웹페이지를 처음부터 다시 렌더링 했습니다.


전통적 웹페이지의 단점

  1. 이전 웹페이지와 차이가 없어서 변경할 필요가 없는 부분들까지 새롭게 완전한 HTML을 전송받아야하기 때문에 불필요한 리소스가 발생합니다.

  2. 변경할 필요가 없는 부분을 처음부터 렌더링하기 때문에 페이지에 깜빡임 현상이 발생합니다.

  3. 클라이언트와 서버와의 통신이 동기 방식으로 동작하기 때문에 서버로부터 응답이 있을 때까지 추가적인 처리가 불가능합니다.

AJAX의 등장으로 서버로부터 웹페이지에 변경이 필요한 부분만 데이터를 전송받아 렌더링하는 것이 가능해졌고, 이로 인해 데스크탑에서 애플리케이션과 유사한 빠른 퍼포먼스와 부드러운 화면 전환이 가능해졌습니다.


XMLHttpRequest 객체를 활용한 Http 요청

자바스크립트의 전통적인 Http 요청 방식은 XMLHttpRequest 객체를 이용한 방법이었습니다. XMLHttpRequest를 이용한 방식에서는 비동기 처리를 위한 패턴으로 콜백 함수를 사용해야 했습니다. 이러한 콜백 함수를 이용한 패턴은 콜백 헬로 인해 가독성이 나쁘고 비동기 처리 중 발생한 에러의 처리가 곤란하며, 여러 개의 비동기 처리를 한번에 처리하는 데에도 한계가 있었습니다.

아래 코드를 통해 전통적인 AJAX 이용에서 콜백 패턴이 필수적이었던 이유를 확인해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let todos;

const get = (url) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();

xhr.onload = () => {
if (xhr.status === 200) {
console.log(JSON.parse(xhr.response));
todos = JSON.parse(xhr.response);
} else {
console.error(xhr.status);
}
};
};

get("https://jsonplaceholder.typicode.com/posts/1");
console.log(todos); // output: undefined

서버로부터 응답이 도착하면 xhr 객체에서 load 이벤트가 발생합니다. 이때 xhr.onload 핸들러 프로퍼티에 바인딩한 이벤트 핸들러가 즉시 실행되지 않습니다. xhr.onload 이벤트 핸들러는 load 이벤트가 발생하면 일단 태스크큐에 저장되어 대기하다가, 콜 스택이 비면 이벤트 루프에 의해 콜 스택으로 푸쉬되어 실행됩니다.

따라서 기존 콜스택이 전부 비워져야만 우리가 원하는 코드 (todos에 값을 할당하는 코드)가 실행되기 때문에 기대한 대로 동작하지 않습니다.

이처럼 비동기 함수는 비동기 처리 결과를 외부에 반환할 수 없고, 상위 스코프의 변수에 할당할 수도 없습니다. 따라서 비동기 함수의 처리 결과에 대한 후속처리는 비동기 함수 내부에서 수행되어야 합니다. 이를 위해 비동기 함수 내부에 비동기 처리 결과에 대한 후석 처리를 수행하는 콜백 함수를 전달하는 것이 일반적입니다.

전통적 Http 요청 방식의 문제점

1) 콜백 헬

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const get = (url, callback) => {
const xhr new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();

xhr.onload = () => {
if (xhr.status === 200) {
callback(JSON.parse(xhr.response));
} else {
console.error(xhr.status);
}
};
};

const url = 'https://jsonplaceholder.typicode.com';
get(`${url}/post/1`, ({userId})=>{
console.log(userId); // output: 1

get(`${url}/users/${userId}`, userInfo => {
console.log(userInfo); // output: {id: 1, name: jongmin, username: Eric}
})
})

위 코드를 보면 GET요청을 통해 서버로부터 id가 1인 post를 취득하고 이 데이터를 사용하여 또 다시 GET 요청을 합니다. 이처럼 비동기 통신 내부에서 후속 처리를 위한 코드를 사용하는 콜백 패턴은 가독성을 저해하며 실수를 유발하는 원인이 됩니다.

만약 비동기 통신으로 취득한 단계적 데이터들에 의존한 코드가 몇겹씩 쌓이다보면 정말 말 그대로 지옥같은 코드가 생성됩니다.

1
2
3
4
5
6
7
8
9
get('step1', a => {
get(`step2/${a}`, b => {
get(`step3/${b}`, c => {
get(`step4/${c}`, d =>{
...
});
});
});
});

2) 에러 핸들링

1
2
3
4
5
6
7
try {
setTimeout(() => {
throw new Error("Error");
}, 1000);
} catch (e) {
console.error(e); // 에러 캐치 불가
}

전통적 Http 요청 방식에서 사용되는 콜백 패턴의 가장 큰 문제점은 에러처리가 곤란하다는 점입니다. 비동기 코드의 동작 원리를 다시 생각해보면 setTimeOut 함수의 콜백 함수는 런타임 환경에서 모든 코드가 실행된 이후 콜 스택이 완전히 비워진 이후에야 이벤트 루프를 통해 태스크 큐에서 콜 스택으로 푸쉬됩니다. 이 때 try catch 문은 종료된 시점이기 때문에 에러가 캐치되지 않습니다.


Promise의 등장

전통적 Http 요청을 위한 콜백 패턴은 콜백 헬이나 에러 처리의 문제가 있었습니다. 이를 극복하기위해 ES6에서 Promise가 도입되었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const promiseGet = url => {
return new Promise((resolve, reject) => {
const xhr new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();

xhr.onload = () => {
if (xhr.status === 200) {
callback(JSON.parse(xhr.response));
} else {
console.error(xhr.status);
}
};
})
};

promiseGet('http://jsonplaceholder.typicode.com/posts/1')
.then(v => console.log(v), e => console.log(e));

Promise는 후속처리 메서드를 통해 에러 핸들링을 할 수 있습니다. then의 첫번 째 콜백 함수로 비동기 처리 결과가 성공했을 때의 후속 조치를 할 수 있고, 두 번째 콜백 함수로 비동기 처리 결과가 실패했을 때의 후속 조치도 가능합니다. 또한 catch메서드를 통해 전체적인 에러 핸들링또한 가능합니다.

또한 Promise는 체이닝을 통해 전통적 통신 방식에서의 콜백 헬도 해결이 가능합니다.

1
2
3
4
promiseGet(`${url}/posts/1`)
.then(({ userId }) => promiseGet(`${url}/users/${userId}`))
.then((userInfo) => console.log(userInfo))
.catch((err) => console.error(err));

then, catch finally 후속 처리 메서드는 콜백 함수가 반환한 Promise를 반환합니다. 또한 콜백 함수가 Promise가 아닌 값을 반환하더라도 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성하여 반환합니다. 그렇게 반환한 Promise를 이용하여 체이닝을 통해 후속 처리를 하기 때문에 전통적 방식의 콜백 헬을 방지했다는 점은 긍정적이지만 결국 Promise도 콜백 패턴을 사용하기 때문에 가독성을 저해할 수 있다는 점에서 문제가 있습니다.


async / await를 이용한 후속 처리

기존 콜백 패턴을 이용한 비동기 통신의 단점을 ES8에 도입된 async/await를 통해 해결할 수 있습니다. async/await를 사용하면 Promise의 후속 처리 메서드 없이 동기 처리처럼 프로미스가 처리 결과를 반환하도록 구현할 수 있습니다.

1
2
3
4
5
6
7
8
9
(async () => {
try {
const { userId } = await promiseGet(`${url}/posts/1`);
const userInfo = await promiseGet(`${url}/users/${userId}`);
console.log(userInfo);
} catch (err) {
console.error(err);
}
})();

async/await는 Promise를 가독성 좋게 사용할 수 있는 방법입니다. 비동기 코드를 동기 코드 처럼 동작하도록 구현할 수 있습니다. 이것은 에러처리에서 기존 비동기적 특성 때문에 사용하지 못했던 try/catch 문 또한 사용할 수 있다는 것을 의미합니다.