2025. 6. 30. 09:00ㆍJavascript/Javascript
자바스크립트의 동기와 비동기
자바스크립트는 싱글 스레드 언어이기 땜누에 한 번에 하나의 작업만 수행할 수 있다. 즉 이전 작업이 완료되어야 다음 작업을 수행할 수 있게 된다는 얘기다. 우리가 프로그래밍을 하면서 일반적으로 각 함수와 코드들이 위에서 아래로 차례대로 동작하는 방식이라고 할 수 있다. 이러한 코드의 순차 실행을 동기(Synchronous)라고 부른다.
동기 방식은 간단하고 직관적이지만 작업이 오래 걸리거나 응답이 늦어지는 경우 전체적인 성능과 사용자 경험에 영향을 줄 수 있다. 예를 들어 서버에 데이터를 요청하고 응답을 받아야 하는 작업이 있다면 응답이 올 때까지 다른 작업을 하지 못하고 대기해야 하기 때문이다. 이렇게 되면 프로그램의 흐름이 멈추거나 지연될 수 있다.
따라서 자바스크립트는 여러 작업을 동시에 처리하기 위해 비동기(Asynchronous)라는 개념을 도입하여, 특정 작업의 완료를 기다리지 않고 다른 작업을 동시에 수행할 수 있도록 하였다. 자바스크립트를 공부하다 보면 setTimeout()이나 fetch() 함수를 접해봤을 것이고, 이들이 비동기로 동작한다는 말을 한 번쯤은 들어봤을 것이다.
비동기는 메인 스레드가 작업을 다른 곳에 인가하여 처리하게 되고, 그 작업이 완료되면 콜백 함수를 받아 실행하는 방식으로 쉽게 말해 백그라운드에 요청하여 처리되도록 하여 멀티로 동시에 작업을 처리하는 것으로 볼 수 있다.
서버에 데이터를 요청하고 응답을 받아야 하는 작업이 있다면 응답이 오는 것과 관계 없이 다른 작업을 계속 이어나가 병렬로 동시에 처리하는 것이 가능해져 프로그램의 흐름이 멈추거나 지연되지 않게 된다. 따라서 task들이 병렬적으로 동시에 처리되고 총 코드의 실행 시간은 획기적으로 줄어들게 된다.
자바스크립트 비동기의 특징
비동기 처리의 유용성
예를 들어 웹 애플리케이션에서 데이터베이스 쿼리를 수행하는 작업이 있다고 가정해 보자. 이 작업을 만일 동기적으로 수행하면 데이터베이스에서 응답이 올 때까지 기다려야 한다. 그러면 이때 애플리케이션은 다른 요청을 처리하지 못하므로 대규모 트래픽이 발생할 경우 성능이 저하될 수 있다.
하지만 비동기 방식으로 데이터베이스 쿼리를 수행하면 DB에서 응답이 올 때까지 기다리는 동안에도 다른 요청을 처리할 수 있게 된다. 결과가 주어지는 데 시간이 걸리더라도 그 시간 동안 다른 작업을 할 수 있으므로 자원을 효율적으로 사용할 수 있는 것이다. 이렇게 비동기 방식을 사용하면 대규모 트래픽에서도 안정적으로 동작할 수 있는 애플리케이션을 만들 수 있다.
대표적으로 웹에서 비동기 처리를 가능하게 하는 것으로 Ajax가 있다. 다른 서버에 데이터를 요청할 때 XMLHttpRequest 객체 또는 fetch 메서드로 요청을 하는데, 서버로부터 응답을 기다리는 동안에도 사용자와의 상호작용을 유지할 수 있으므로 사용자 경험을 향상시킬 수 있게 된다.
// fetch 함수에 URL 전달
fetch("https://test.com/todos/1")
.then(function(response) {
return response.json(); // 응답을 JSON 형식으로 변환
})
.then(function(data) {
console.log(data); // JSON 데이터를 출력
})
.catch(function(error) {
console.error(error); // 에러를 출력
});
비동기 병렬 처리의 원리
자바스크립트 개발자라면 필수로 익히는 지식 중 하나가 호출 스택과 이벤트 루프일 것이다. 그리고 어쩌면 아래와 같은 그림을 본 적이 있을 수도 있다. 비동기 함수의 콜백 함수가 이벤트 루프에 의해서 Callback Queue에 담기고 다시 싱글 스레드인 Call Stack에 담겨서 콜백 함수가 실행되는 동작 원리 말이다.
그런데 자바스크립트는 싱글 스레드 언어라고 했는데 어떻게 작업(task)들을 동시에 처리할 수 있는 걸까?
자바스크립트를 실행하는 콜 스택(Call Stack)은 싱글 스레드지만 서버에 리소스를 요청하거나 파일 입출력 혹은 타이머 대기 작업을 실행하는 Web APIs들은 멀티 스레드이기 때문에 동시 작업 처리가 가능하기 때문이다(멀티 스레드를 잘 모른다면 백그라운드에서 동시에 처리된다고 이해하면 된다).
Web API는 타이머, 네트워크 요청, 파일 입출력, 이벤트 처리 등 브라우저에서 제공하는 다양한 API를 포괄하는 API의 총칭이다. 브라우저마다 다를 수 있지만 크롬 브라우저일 경우 Web API는 멀티 스레드로 구현되어 있다.
즉 브라우저라는 소프트웨어가 멀티 스레드이기 때문에 메인 자바스크립트의 스레드를 차단하지 않고 다른 스레드를 사용하여 Web API의 작업을 처리하여 동시 처리가 가능한 것이다.
만약 아래와 같이 3초를 대기하는 setTimeout 비동기 함수와 그 외 작업들이 있다고 한다면 이 setTimeout 코드가 Web APIs들 중 타이머 처리를 담당하는 Timer API에 넘어가서 3000ms 밀리초를 병렬로 처리되면서 동시에 메인 콜 스택의 Task1, Task2 ...를 처리하는 것이다.
setTimeout(() => {
console.log('3초 대기')
}, 3000);
Task1();
Task2();
Task3();
정리하자면 브라우저는 멀티 스레드로 이루어져 있고 이러한 동시적 처리 작업 원리 덕분에 우리는 비동기 함수를 통해 성능 향상을 누릴 수 있었던 것이다.
완벽하지 못한 멀티 스레딩
setTimeout을 이용해 비동기의 멀티 작업 처리를 설명했지만 사실 자바스크립트의 비동기는 완벽한 멀티 스레딩이 아니다. 위 예시에서 타이머 3000ms만 병렬적으로 처리되고그 안의 콜백 함수 실행 코드는 추후에 이벤트 루프에 의해 콜 스택(Call Stack)에 들어가 싱글 스레드로 처리되기 때문이다.
setTimeout(() => { // 그러나 콜백 함수 자체는 나중에 Call Stack에서 싱글 스레드로 처리
console.log('3초 대기 완료')
}, 3000); // 타이머 3초는 멀티 스레드로 처리
Task1();
Task2();
Task3();
setTimeout뿐만 아니라 fetch 함수도 마찬가지다. 서버에 요청하여 리소스를 다운로드하는 것은 멀티 스레드로 병렬적으로 처리되지만 요청이 완료되고 나서의 then 핸들러의 콜백 함수는 콜 스택에 별도로 처리된다.
fetch("https://test.com/todos/1") // 서버에서 리소스 다운로드는 멀티 스레드로 처리
.then(function(response) {
return response.json(); // 콜백 함수 부분은 나중에 콜 스택 싱글 스레드로 처리
})
.then(function(data) {
console.log(data);
})
.catch(function(error) {
console.error(error);
});
그런데 병렬로 동시 처리를 할 거라면 전체를 완전히 처리할 것이지 왜 이런 식으로 번거롭게 나눈 것일까?
아마 자바의 멀티 스레드 프로그래밍을 해 본 개발자라면 이에 대한 이유를 알고 있을지 모른다. 완전한 병렬 처리는 성능만큼은 이득을 얻을 수 있을지도 모르겠지만 항상 동시성 문제가 따라와 synchronized 처리가 수반된다는 것을 말이다. 자바스크립트의 비동기만 고려한다면 잘 와닿지 않을 수 있지만, 이러한 synchronized 처리를 잘못하면 오히려 성능 감소가 일어나기 때문에 고난이도의 지식과 실력을 요구한다. 따라서 자바스크립트에서는 동시성 문제에 대해 심플하게 처리하기 위해 비동기 콜백 함수 방식을 채택하였다고 볼 수 있다. 이밖에도 자바스크립트라는 언어를 설계하던 당시에는 멀티 프로세스 컴퓨터가 보편적이지 않았을 뿐더러 자바스크립트가 처리할 코드의 양 또한 적었기 때문이라는 이유도 있다.
자바스크립트에서도 멀티 스레딩을 사용하고 싶다면?
그렇다면 자바스크립트에서는 영원히 멀티 스레딩을 구현하지 못하는 것일까? 그래서 나온 것이 바로 웹 워커(web workers)이다. 웹 워커를 사용하면 자바스크립트도 자바의 스레드와 같이 멀티 스레드 프로그래밍을 할 수가 있다.
// 웹 워커 스크립트 파일(worker.js)
self.addEventListener('message', function(e) {
// 메인 스크립트로부터 메시지를 받으면 실행할 함수
var result = "Hello " + e.data;
self.postMessage(result); // 결과를 메인 스크립트로 전송
}, false);
// 메인 스크립트에서 웹 워커 사용 예시
var worker = new Worker('./worker.js'); // 웹 워커 객체 생성
worker.addEventListener('message', function(e) {
// 웹 워커로부터 메시지를 받으면 실행할 함수
console.log(e.data); // 결과 출력
}, false);
worker.postMessage('World'); // 웹 워커에게 메시지 전송
브라우저의 비동기 처리
이러한 비동기 원리는 꼭 자바스크립트뿐만 아니라 브라우저의 HTML 마크업 언어에서도 동일하게 적용된다. HTML의 파싱 동작 방식을 예로 들 수 있는데 아래와 같이 <script> 태그를 HTML 파일의 <head> 안에 넣으면 자바스크립트 파일이 다운로드되고 실행될 때까지 HTML의 파싱이 중단된다. 이는 사용자가 웹 페이지의 내용을 보는 데 오래 기다려야 하게 한다.
<!DOCTYPE html>
<html>
<head>
<script src="script1.js"></script>
<script src="script2.js"></script>
<script src="script3.js"></script>
</head>
<body>
<h1>웹 페이지 제목</h1>
<p>웹 페이지 내용</p>
</body>
</html>
자바스크립트에 비동기 함수가 있다면 HTML에도 비동기를 이용할 수 있는 기술이 있다. 바로 <script> 태그에 붙이는 async와 defer 키워드이다. async와 defer 속성은 자바스크립트 파일을 비동기적으로 다운로드하고 실행할 수 있게 해 준다. 이렇게 하면 HTML 파싱과 자바스크립트의 다운로드가 동시에 진행되어 페이지 로딩 속도를 향상시킬 수 있다.
<!DOCTYPE html>
<html>
<head>
<script async src="script1.js"></script>
<script async src="script2.js"></script>
<script async src="script3.js"></script>
</head>
<body>
<h1>웹 페이지 제목</h1>
<p>웹 페이지 내용</p>
</body>
</html>
두 그림을 비교하면 똑같은 작업을 처리하는 데 있어 Asynchronous에 총 걸린 시간이 더 적은 것을 볼 수 있다. 이 역시 위에서 배운 자바스크립트와 Web API 원리와 같이 파일 다운로드 동작 자체를 비동기로 브라우저 내부의 멀티 스레드에 양도하고 계속 HTML 파싱을 이어나가면서 동시에 파일을 다운 받는 원리이다.
HTML의 비동기는 자바스크립트의 콜 스택과 이벤트 루프 동작과는 별개로 브라우저 내부 구현에 의존하는 것이다.
웹 애플리케이션을 이용하는 빠른 서비스를 원하지 불러오는 속도가 느리고 반응이 없는 것을 원하지 않기 때문에 비동기적으로 페이지를 구성하는 것은 필수 스킬이라 할 수 있다.
비동기 처리의 문제점
성능 향상을 위해 비동기 처리를 이용할 때 주의해야 할 점이 있다. 앞서 Asynchronous는 요청한 작업의 완료 여부를 기다리지 않고 자신의 그 다음 작업을 계속 수행해 나간다고 했다. 그런데 만약 그 다음에 실행할 작업이 이전에 요청한 작업의 결과를 반드시 필요로 하는 경우 문제가 생긴다.
쉽게 예를 들어 보면 아래 HTML의 스크립트 코드는 에러를 일으키게 된다. 왜냐하면 $('body').append() 코드는 제이쿼리 전용 코드로서 제이쿼리 라이브러리 파일이 다운을 모두 받은 상태여야만 사용할 수 있기 때문이다. 하지만 제이쿼리 파일의 호출을 비동기적으로 진행해 다운로드되기도 전에 제이쿼리 코드를 사용했으니 에러가 일어나는 게 당연한 것이다(만약 파일의 다운로드가 엄청 빨리 진행된다면 오류가 발생하지 않을 수도 있다).
<script async src='https://code.jquery.com/jquery-3.6.0.min.js'></script>
<script>
// 제이쿼리 파일을 비동기적으로 호출하고 바로 제이쿼리 전용 코드를 실행 시켰기 때문에 에러가 발생하다
$('body').append('<h1>Hello World</h1>'); // ! ERROR
</script>
이밖에도 서버로부터 데이터를 받을 때 비동기 함수의 결과가 동기적으로 실행되는 코드에 영향을 줄 때도 문제가 발생한다.
예를 들어 다음 코드는 서버의 데이터베이스를 조회하여 데이터를 가져오는 로직을 간단하게 표현한 예제이다. getDB() 함수를 통해 데이터베이스를 조회하는데 이때 조회 시간이 3초 걸린다고 가정해 보자. 그리고 DB로부터 응답을 받게 되면 data 변수에 저장하고 값을 * 2 연산 후 출력하려고 한다.
function getDB() {
let data;
// 데이터베이스에서 값을 가져오는 3초 걸린다고 가정 (비동기 처리)
setTimeout(() => {
data = 100;
}, 3000);
return data;
}
function main() {
let value = getDB();
value *= 2;
console.log('value의 값 : ', value); // value의 값 : NaN
}
main(); // 메인 스레드 실행
하지만 결과를 확인해 보니 data 변수에 NaN이라는 값이 들어가 있다. 왜 이런 결과가 나왔을까? 그 이유는 비동기 함수인 setTimeout 함수가 3초를 대기하는 동안 완료될 때까지 기다리지 않고 그 다음 코드인 console.log(data)를 실행하였기 때문이다. 이때 data 변수에는 아직 데이터가 저장되지 않았으므로 여기에 연산을 하니 이상한 값이 출력되는 것이다.
그렇다면 위와 같이 작업 순서를 맞추는 것이 필수 불가결일 경우 어쩔 수 없이 비동기를 포기하고 동기로 처리해야 하나 싶겠지만 이를 해결하는 몇 가지 방법이 있다. 콜백함수, Promise, async / await이 바로 그것이다.
'Javascript > Javascript' 카테고리의 다른 글
[Javascript] 비동기 처리 - (2) Promise (3) | 2025.07.02 |
---|---|
[Javascript] 비동기 처리 - (1) 콜백 함수 (3) | 2025.07.01 |
[Javascript] 이벤트 루프 (3) | 2025.06.27 |
[Javascript] Sync와 Asyc (2) | 2025.06.26 |
[Javascript] Symbol (0) | 2025.06.25 |