동기 vs 비동기
JS에서 작업을 처리하는 방법은 동기와 비동기로 크게 두 가지가 존재합니다.
둘의 차이점은 작업을 직렬적으로 처리하느냐 병렬적으로 처리하느냐입니다.
직렬적으로 처리하는 동기는 작업이 끝나지 않으면 다음 작업을 수행할 수 없는 방식을 말합니다.
즉, 이전 처리가 끝나지 않는다면 다음 작업은 끝날 때가지 기다려야하는 것을 의미합니다.
이렇게 말하면 안 좋아보일 수 있지만 작업의 흐름을 한 눈에 파악하기 쉽다는 장점을 가지고 있습니다.
반대로 병렬적으로 처리하는 비동기는 작업이 처리중이든 상관하지 않고 다음 작업을 처리합니다.
코드가 끝날 때까지 코드의 실행을 멈추지 않고 다음 코드를 먼저 실행하는 것을 의미합니다.
아래는 동기와 비동기의 이해를 돕기위한 자료입니다.
callback
JS에서 비동기 처리를 하기 위해 callback 함수를 사용하는 것을 아시나요?
callback 함수란 어떤 함수의 인자로 들어가는 익명함수를 의미합니다.
function ex(callback) {
setTimeout(() => {
callback("작업이 완료되었습니다.");
}, 1000);
}
ex((message) => {
console.log(message);
});
callback으로 비동기를 처리하는 데에는 여러가지 이유가 있습니다.
비동기는 작업 흐름을 방해하지 않고 백그라운드에서 실행되기 때문에 작업이 완료됐을 때 처리를 할 수도 있고
여러 작업이 동시에 수행되기 때문에 작업이 완료된 순서를 관리해야 하는데 callback 함수를 이용하면
각 작업의 완료 시점에 특정 로직을 수행할 수 있기 때문에 비동기 처리에 유용합니다.
하지만 이러한 callback은 흔히 callback 지옥이라고 불리는 현상이 일어날 가능성이 있습니다.
callback 지옥이란 callback이 무수히 많이 체이닝 되는 것을 의미합니다. 잠깐 지옥에 다녀와 볼까요?
step1(function(value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
step5(value4, function(value5) {
// ...
});
});
});
});
});
이렇듯 함수의 인자에 익명함수가 정말 많이 반복되고 있습니다.
어떤가요? 한 눈에봐도 유지보수에 좋지 않고 가독성 또한 많이 떨어집니다.
JS는 이러한 callback 지옥을 개선하기 위해 다른 비동기 처리 방법 Promise를 내놓았습니다.
Promise
Promise는 callback이 가진 단점을 보완하여 비동기 처리 시점을 명확히 표현할 수 있습니다.
Promise 생성자 함수를 이용해 만들어 낼 수 있습니다. Promise는 비동기 작업을 실행하는 callback 함수를 인자로 받습니다.
그리고 이 callback 함수는 요청이 성공했을 때인 resolve와 실해팼을 때인 reject를 인자로 받습니다.
간단히 사용법을 알아볼까요?
const exPromise = new Promise((resolve, reject) => {
if (true) {
resolve('success'); // 작업 수행 성공
} else {
reject('faild'); // 작업 수행 실패
}
});
또한 Promise는 비동기 처리의 대한 여러 상태 정보를 담고 있습니다.
1. pending
비동기 처리가 수행되지 않은 상태
2. fulfilled
비동기 처리가 수행된 상태 -> 성공
3. rejected
비동기 처리가 수행된 상태 -> 실패
4. settled
비동기 처리가 수행된 상태 -> 성공 or 실패
이러한 상태들은 Promise의 생명주기를 나타내는 상태들입니다.
callback hell 개선
Promise는 callback hell을 개선하기 위해 나온 문법이라고 하였습니다.
사실 위 코드만 보면 어떻게 개선했는지 알 수 없습니다.
Promise는 성공이든 실패든 처리 결과를 resolve 또는 reject의 인자로 전달해주어야 합니다.
그리고 이 처리 결과는 후속 처리 메서드로 전달됩니다.
여기서 이 후속 처리 메서드가 callback hell을 개선할 수 있는 방법입니다.
바로 then-catch입니다.
then
2개의 callback 함수를 인자로 받습니다.
첫 번째 인자는 요청에 대한 성공( fullfilled, resolve )할 시에 호출됩니다.
두 번째 인자는 요청에 대한 실패 ( rejected, reject )할 시에 호출됩니다.
then 메서드는 Promsie를 반환합니다.
catch
비동기 처리에서 발생한 오류 또는 then 메서드에서 오류가 발생하면 호출됩니다.
catch 메서드도 then과 마찬가지로 Promise를 반환합니다.
위 2개의 후속 처리 메서드로 callback hell을 어떻게 해결할까요?
바로 Promise 체이닝을 이용해서 해결합니다!
Promise 체이닝
callback hell이 발생하는 이유는 비동기에 함수의 작업 처리 결과를 가지고
다른 비동기 함수를 호출할 때 이러한 함수 호출이 중첩되기 때문입니다.
then과 catch를 사용하면 중첩되는 코드를 복잡성은 낮추고 가독성은 높이는 방식으로 처리할 수 있습니다.
간단한 비동기 처리인 setTimeout으로 둘의 코드를 비교해볼까요?
먼저 callback hell 코드를 보겠습니다.
function fetchData(url, callback) {
setTimeout(() => {
const data = '예시 비동기 데이터';
if (data) callback(data);
else callback(new Error('예외 발생'));
}, 1000);
}
fetchData('https://example.com', function(data) {
console.log('첫 번째 데이터', data);
fetchData('https://example.com/other', function(otherData) {
console.log('두 번째 데이터', otherData);
fetchData('https://example.com/more', function(moreData) {
console.log('세 번째 데이터', moreData);
});
});
});
개선 해보겠습니다.
function fetchData(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const data = '예시 데이터';
if (data) resolve(data);
else reject(new Error('예외 발생'));
}, 1000);
});
}
fetchData('https://example.com/api/data')
.then(data => {
console.log('첫 번째 데이터:', data);
return fetchData('https://example.com/api/otherdata');
})
.then(otherData => {
console.log('두 번째 데이터:', otherData);
return fetchData('https://example.com/api/moredata');
})
.then(moreData => {
console.log('세 번째 데이터:', moreData);
})
.catch(error => {
console.error('에러 발생:', error);
});
훨씬 보기 좋지 않나요? 이렇듯 Promise는 callback hell을 해결하는 방법 중 하나입니다.
async & await
위에서 설명한 Promise는 확실히 callback hell을 개선하는 방법이지만 Promise 생성자를 생성하고
then도 중첩된다면 가독성에서 좋지 않은 코드가 될 수 밖에 없습니다.
async await는 이런 Promise의 단점을 보완해 Promis를 보다 쉽게 사용할 수 있도록 도와줍니다.
async 키워드를 붙인 함수는 항상 Promise를 반환합니다.
Promise의 resolve나 reject로 감싸지 않아도 내부적으로 감싸서 반환하기 때문입니다.
async function ex() {
return 1;
}
ex()
.then((res) => console.log(res)); // 1
await는 기다린다는 뜻이죠? async 함수 내에서만 동작할 수 있고 Promise가 처리될 때까지
기다리고 기다림이 끝난 후 결과를 반환할 수 있게 해주는 역할을 합니다.
function fechData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const data = '임시 비동기 데이터';
if (data) resolve('처리 성공');
else reject(new Error('예외 발생'));
}, 1000);
});
}
async function ex() {
const data = await fetchData();
console.log(data);
}
ex();
이렇게 await는 Promise 작업이 끝날 때까지 기다리면서 비동기 처리를 가능하도록 합니다.
위에선 직접 Promise를 사용했지만 네트워크 요청, 이벤트 처리 등등 다양한 작업에 async await를 사용할 수 있습니다.
then을 사용했다면 좀 더 길어졌을 코드가 많이 줄고 가독성도 더 좋아졌습니다 :)
async await가 then-catch를 개선한 문법이지만 예외 처리를 정확하고 효율적으로 관리할 수는 없습니다.
try-catch와 같이 사용하는 방법이 가장 많이 사용됩니다.
try/catch
try/catch ES3 부터 있었고 초기부터 JS 코드에 포함되어 있던 문법입니다.
async/await와 같이 사용하여 비동기 코드를 처리한다고 잘못 알 수 있지만 사실 동기 코드를 처리할 때 사용합니다.
동기 코드에서 발생하는 에러, 예외를 처리할 때 유용합니다.
try/catch를 사용하면 스크립트가 죽는 것을 방지하고 에러를 잡아 더 합당한 무언가를 할 수 있게 됩니다.
try/catch의 동작을 알아볼까요?
1. try 블록 안의 코드가 실행됩니다.
2. 에러가 발생하지 않는다면 try의 마지막 줄까지 실행되며 catch 블록을 건너뜁니다.
3. 에러가 발생한다면 try에서 코드 실행이 중단되고 catch 블록으로 제어 흐름이 넘어가게 됩니다.
catch는 try와 다르게 인자를 받는데 이 인자는 에러 객체를 포함합니다. ( 이름은 아무렇게나해도 상관 X )
then/catch와 비슷합니다. 위 과정을 통해서 스크립트는 죽지 않고 에러를 잡을 수 있게 됩니다.
try/catch가 잡아내지 못 하는 오류
1. 유효하지 않은 코드
try/catch는 "유효한 코드"에서 발생하는 오류인 런타임 에러만을 잡아냅니다.
그래서 유효하지 않은 -> 문법적으로 맞지 않은 코드는 catch 블록으로 넘어가지 않습니다.
try {
{{{{{{{{
} catch(e) {
alert('예외 발생');
}
위처럼 유효하지 않은 코드는 런타임에러이기 때문에 catch 블록에서 처리하지 못 합니다.
2. 비동기적으로 실행되는 코드
그리고 try/catch는 동기적으로 동작합니다. 그래서 setTimeout처럼 스케쥴 된 코드에서는 예외를 잡지 못 합니다.
try {
setTimeout(() => {
noDefineValue;
}, 1000);
} catch (e) {
alert('예외 발생');
}
즉, 위에서 정의되지 않은 noDefineValue는 분명한 오류이지만 catch 블록에서
처리하지 못 하고 스크립트가 멈추게 됩니다.
setTimeout에서 예외를 잡고 싶다면 setTimeout 내부에 try/catch를 구현해주면 됩니다!
setTimeout(() => {
try {
noDefineValue;
} catch(e) {
alert('예외 발생');
}
}, 1000);
이제 위 코드는 예외를 잡을 수 있게 됐습니다.
에러 객체
catch 블록이 인자로 받는 에러 객체는 어떻게 이루어져 있을까요?
보통 e, err, error로 사용하지만 아무 이름이나 상관없습니다. potato라는 네이밍도 가능합니다.
이러한 에러 객체는 name과 message, 비표준 프로퍼티 stack 등으로 이루어져 있습니다.
name
에러의 이름입니다. 위 noDefineValue 같은 정의되지 않은 변수 때문에 일어난 오류는
ReferenceError라는 이름을 가지게 됩니다.
message
에러에 대한 상세 내용을 담고 있습니다.
stack
현재 호출 스택을 나타냅니다. stack이라는 이름답게 에러를 유발한 중첩 호출들의 순서 정보를 가진 문자열입니다.
디버깅을 위한 목적으로 사용됩니다.
try {
noDefineValue;
} catch(e) {
alert(err.name); // ReferenceError
alert(err.message); // noDefineValue is not defined
alert(err.stack); // ReferenceError: lalala is not defined at ...
// 에러 전체를 보여줄 때 에러 객체는 "name: message" 형태의 문자열로 반환
alert(err); // ReferenceError: noDefineValue is not defined
}
만약 에러에 대한 정보가 필요 없다면 생략도 할 수 있습니다!
구식 브라우저에서는 폴리필(브라우저가 지원하지 않는 자바스크립트 코드를 지원 가능하도록 변환한 코드) 작업이 필요하다고 합니다.
try {
// ...
} catch {
// ...
}
try/catch 사용
이제 try/catch에 대한 개념은 거의 이해한 것 같습니다.
아까 설명하다 멈춘 async/await 및 사용 방법에 대해서 알아보겠습니다.
서버에서 받아온 데이터가 잘 못되어 스크립트가 죽을 때 에러 핸들링을 하지 않으면 사용자가
개발자 도구를 열지 않는 이상 어떤 오류가 일어났는지 알지 못합니다. 이러면 안 되겠죠?
const json = "{ bad json }";
try {
const user = JSON.parse(json);
alert(user.name);
} catch(e) {
alert("알 수 없는 오류가 발생했습니다.")
}
예외를 처리하고 있긴 하지만 요청 다시 보내기 어떤 오류가 나타났는지 정확하게 알려주는 등과 같은
구체적인 일을 할 수도 있습니다.
throw
throw는 숫자, 문자열 같은 원시형 자료를 포함해 어떤 것이든 에러 객체로 사용할 수 있습니다.
하지만 내장 에러와의 호환을 위해서 에러 객체에 name과 message를 포함하는 것을 권장합니다.
JS에서 에러 객체는 Error, SyntaxError, ReferenceError, TypeError 등의 표준 에러 객체 생성자를 지원합니다.
const first_error = new Error(message);
const second_error = new SyntaxError(message);
객체 생성자이기에 new 연산자로 생성할 수 있습니다.
name
내장 에러 객체의 name 프로퍼티는 생성자 이름과 동일한 값을 갖습니다. Error는 Error
SyntaxError일때는 SyntaxError라는 이름을 갖습니다.
message
message 프로퍼티는 인자로 전달한 것에서 가져옵니다.
json은 잘 되어있지만 name이 속성이 없을 때의 예제를 보겠습니다.
let json = '{ "age": 19 }';
try {
let user = JSON.parse(json);
if (!user.name) {
throw new SyntaxError("불완전한 데이터: 이름 없음");
}
alert( user.name );
} catch(e) {
alert( "JSON Error: " + e.message ); // JSON Error: 불완전한 데이터: 이름 없음
}
이렇게 throw로 에러 처리를 해보았습니다. 그런데 위 코드는 JSON에 관련된 오류만을 사용자에게 보여줍니다.
아래 코드에서 그러면 안 되겠죠?
let json = '{ "age": 19 }'; // 불완전한 데이터
try {
user = JSON.parse(json);
// ...
} catch(err) {
alert("JSON Error: " + err); // JSON Error: ReferenceError: user is not defined
}
분명 catch가 오류를 잡아냈지만 JSON에 관련된 오류가 아닙니다.
저희는 발생할 수 있는 여러 오류들을 잡아야 합니다.
그래서 할 수 있는 방법이 다시 던지기라는 기술을 사용할 수 있습니다. 다시 던지기는 간단합니다!
1. catch가 모든 에러를 잡아냄
2. catch 블록 안에서 에러 객체를 분석
3. 에러 처리 방법을 알지 못하면 throw error
에러 객체를 분석할 때는 instanceof 명령어가 유용합니다.
try {
user = {};
} catch(e) {
if (e instanceof ReferenceError) {
alert('참조 오류 발생');
}
// ...
}
이번에는 문법적 오류인 JSON 오류만 잡아내고 나머지는 다시 던져보겠습니다.
let json = '{ "age": 19 }';
try {
let user = JSON.parse(json);
if (!user.name) {
throw new SyntaxError("불완전한 데이터: 이름 없음");
}
lalala(); // 예상치 못 한 오류
alert( user.name );
} catch(e) {
if (e instanceof SyntaxError) {
alert( "JSON Error: " + e.message );
} else {
throw e; // 다시 던지기
}
}
이렇게 되면 instanceof 명령어로 문법적 오류만 잡아내고 그 외 알 수 없는 오류들은 스킵할 수 있게 됩니다.
catch 블록안에서 다시 던지기가 일어난다면 try/catch를 벗어나 바깥으로 이동합니다.
만약 바깥에 또 다른 try/catch가 있다면 잡아내고 없다면 스크립트가 죽게 됩니다.
바깥에 또 다른 try/catch가 있는 것도 코드로 구현해보겠습니다.
function readJson() {
let json = '{ "age": 19 }';
try {
// ...
lalala();
} catch (e) {
// ...
if (!(e instanceof SyntaxError)) {
throw e;
}
}
}
try {
readJson();
} catch (e) {
alert( "Enexpected error: " + e );
}
readJson 함수의 try/catch 문에서 다시 던지기가 일어났습니다.
이렇게 되면 다시 던진 오류는 현재 try/catch 문의 바깥으로 던져지고 readJson을 감싸는 또 다른
try/catch문에서 이 오류를 잡아낼 수 있게 됩니다.
스크립트에서 발생할 수 있는 오류는 정말 무수히 많기 때문에 다시 던지기는 매우 중요한 작업입니다.
finally
이제 다 왔습니다. then/catch에서도 사용할 수 있는 finally입니다.
finally는 then/catch, try/catch 둘 다에서 볼 수 있는데 핵심적인 기능은 동일합니다.
작업이 성공하든 에러가 발생하든 실행된다는 것.
return문을 사용해도 try/catch를 벗어나면 무조건 실행됩니다.
finally는 피보나치 수열같은 수학적 방식, try, finally만 사용해 그저 작업이 확실히 마무리되었는지 확인하는 등
사용방법이 다양합니다.
📚 정리
비동기란 작업을 병렬적으로 처리하는 방식.
js에서는 callback으로 비동기 작업을 처리할 수 있지만 callback hell이라는 문제점이 생길 수 있음.
이를 개선하기 위해 Promise라는 개념이 등장했고 then/catch를 통해서 callback hell보다 복잡도가 낮은 코드 작성.
하지만 많은 Promise 체이닝도 좋은 코드라고 하기는 애매했고 async/await를 통해 개선할 수 있게 됨.
async 함수는 무조건 Promise를 반환하고 await를 통해 비동기 처리작업이 완료될 때까지 기다릴 수 있음.
async/await에서 예외처리는 try/catch를 통해 편리하게 관리가 가능함.
크게 정리하면 위와 같을 것 같습니다.
다양한 비동기 처리에 대한 문제를 개선하려는 JS의 의도가 돋보였고 어떤 방식으로 기존의 문제점을
해결했는지 눈에 보여서 오래 걸렸지만 즐거운 포스팅이었다고 생각합니다.
'Javascript' 카테고리의 다른 글
즉시 평가, 지연 평가 - JavaScript (0) | 2024.09.25 |
---|---|
RORO 패턴이란 - JavaScript (1) | 2024.09.17 |
this? 이것? (0) | 2024.04.15 |
ES6? 다른 숫자도 있는건가? (0) | 2024.03.31 |
🦴 JS 타입 종류 (원시 타입, 참조 타입?) (0) | 2024.02.05 |