싱글 스레드에서도 경쟁 상태는 발생한다

이상한 진행률 바 동작
최근 프로젝트에서 여러 이미지를 동시에 업로드하고 진행률을 Progress Bar로 표시하는 기능을 구현했다. 그런데 진행률이 순차적으로 증가하지 않고 증가와 감소를 반복하는 문제를 경험했다.

디버깅 결과, 요청은 ( 1 → 2 → 3 → … ) 순서로 시작되지만 응답은 ( 5 → 1 → 2 → … ) 순으로 무작위로 도착하는 것을 확인했다. 바로 이것이 비정상적인 진행률 증가의 원인이었다.

Race Condition이란?
Race Condition(경쟁 상태)은 여러 개의 프로세스나 스레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 상황이다.
주로 멀티스레드 환경에서 흔히 발생하는 문제로, Java나 C++ 개발자들은 락(Lock), 뮤텍스(Mutex), 세마포어(Semaphore) 같은 동기화 기법으로 이를 해결해왔다.
// Java의 전형적인 Race Condition
private int counter = 0;
public void increment() {
counter++; // 여러 스레드가 동시 접근하면 결과 예측 불가
}
하지만 JavaScript 환경에서도 동일한 문제가 발생할 수 있다.
JavaScript에서 경쟁 상태가 발생하는 이유
JavaScript는 싱글스레드인데 왜?
많은 프론트엔드 개발자들이 “JavaScript는 싱글스레드니까 경쟁 상태가 없겠지?” 라고 생각할 수 있다. 하지만 이는 정확하지 않다.
브라우저는 멀티스레드 환경
JavaScript 엔진은 싱글스레드지만, 브라우저는 멀티스레드 환경이다.
브라우저 환경
├── JavaScript 엔진 (싱글스레드)
│ └── Call Stack
├── Web API (멀티스레드)
│ ├── Network 요청 (fetch, XMLHttpRequest)
│ ├── Timer (setTimeout, setInterval)
│ └── 기타 비동기 API
└── Event Loop + Task Queue (Microtask Queue, Macrotask Queue)
비동기 작업에서 순서는 보장되지 않는다

이벤트 루프는 다음과 같은 흐름으로 동작한다.
- 메인 스레드(Call Stack)에서 JavaScript 실행
- 비동기 작업(fetch, setTimeout 등)은 브라우저의 Web API 스레드로 위임
- 완료된 콜백은 Callback Queue에 등록
- Call Stack이 비는 순간, Event Loop는 Callback Queue에서 콜백을 꺼내 실행
여러 비동기 작업을 병렬로 처리할 때, 요청 순서와 응답 순서가 다를 수 있다.
// 요청 순서: 1 → 2 → 3
fetch('/api/1'); // 3초 후 응답
fetch('/api/2'); // 1초 후 응답
fetch('/api/3'); // 2초 후 응답
// 콜백큐 도착 순서: 2 → 3 → 1
여기서 핵심은 Web API에서 처리되는 비동기 작업들은 각자 다른 처리 시간을 가지므로, 요청한 순서와 완료되는 순서가 다를 수 있다는 것이다. 이것이 바로 JavaScript에서 경쟁 상태가 발생하는 근본적인 이유다.
이런 이유로, 프론트엔드에서도 다양한 형태의 경쟁 상태가 발생할 수 있다. 예를 들어,
- 빠르게 여러 API 호출을 했는데, 나중에 보낸 요청의 응답이 먼저 도착해 이전 값을 덮어쓰는 경우
- 비동기 로직에서 공유 상태(state) 에 동시에 접근해, 순서가 꼬이는 경우
내가 경험한 문제는 두 번째 유형이다. 클라이언트에서 여러 비동기 작업이 동일한 자원을 공유함으로 경쟁 상태가 발생해 순서 제어에 실패한 경우다.
문제 상황 분석
그럼 이제 문제가 있던 코드를 살펴보자.
const uploadImages = async (images) => {
let process = 0; // 공유 자원
await Promise.all(
presignedUrls.map(async (urlInfo, index) => {
// (문제) 요청 시점에 미리 진행률 계산
process++;
const progressPercent = getProgressPercent(process, images.length);
const response = await mediaAPI.uploadImagesToS3(urlInfo.url, images[index].image);
// 응답 순서와 상관없이 미리 계산된 값으로 UI 업데이트
setProgress((prev) => ({ ...prev, upload: progressPercent }));
return response;
}),
);
};
process 변수는 모든 비동기 작업이 공유하는 자원이다. 그리고 process++ 작업은 비동기 처리 이전인 요청 시점에 실행된다. 하지만 setProgress는 비동기 처리가 끝난 응답 시점에 실행된다. 응답 순서가 보장되지 않으므로 경쟁 상태 문제가 발생했던 것이다.
실행 흐름 분석
실제 실행 순서
1. 요청1 시작: process = 1, progressPercent = 8%
2. 요청2 시작: process = 2, progressPercent = 15%
3. 요청3 시작: process = 3, progressPercent = 23%
...
하지만 응답은 다른 순서로!
1. 요청3 완료 → UI에 23% 표시
2. 요청1 완료 → UI에 8% 표시 (진행률 감소)
3. 요청2 완료 → UI에 15% 표시
...
진행률이 ( 23% → 8% → 15% → … )로 감소하는 비정상적인 동작이 발생한다.
해결 방법: 응답 순서 기준으로 상태 관리
문제의 핵심은 UI가 요청 시점에 의존하고 있었다는 것이다.
해결책은 간단하다. 응답이 완료된 시점에 상태를 업데이트하면 된다.
const uploadImagesToS3 = async (images) => {
let process = 0;
await Promise.all(
presignedUrls.map(async (urlInfo, index) => {
// 비동기 처리 먼저 실행
const response = await mediaAPI.uploadImagesToS3(urlInfo.url, images[index].image);
// 응답 후에 진행률 계산 및 상태 업데이트
process++;
const progressPercent = getProgressPercent(process, images.length);
setProgress((prev) => ({ ...prev, upload: progressPercent }));
return response;
}),
);
};
물론 async/await 외에도 Promise 메서드를 사용해서 동일하게 해결할 수 있다. 중요한 것은 어떤 문법을 사용하든 비동기 처리 완료 후에 상태를 업데이트하는 것이다.
개선된 실행 흐름
1. 요청2 완료 → process=1, 8% 표시
2. 요청3 완료 → process=2, 15% 표시
3. 요청1 완료 → process=3, 23% 표시
...

이제 process는 실제 완료된 요청을 기반으로 정확히 증가하고, 진행률도 의도대로 계산되어 UI가 부드럽게 갱신된다.

요청 순서와 독립적으로 응답 완료 순서대로 진행률을 증가시키는 것도 확인할 수 있다.
그 외 프론트엔드 Race Condition 사례
프론트엔드에서는 다양한 상황에서 경쟁 상태가 발생할 수 있다.
여러 API 호출을 했을 때, 나중에 보낸 요청의 응답이 먼저 도착해 이전 값을 덮어쓰는 경우에 대한 몇 가지 예시다.
검색 자동완성
검색 관련된 비동기 처리를 할 때, 아래 코드의 경우 경쟁 상태가 발생할 수 있다.
const search = async (query) => {
const results = await searchAPI(query);
setSearchResults(results);
};
자동완성 또는 사용자가 연속으로 검색을 요청했을 때, 이전 검색 결과가 나중에 도착해 최신 결과를 덮어쓸 수 있다. 이 경우 사용자는 원하는 검색 결과를 보지 못하여 사용자 경험이 저하될 수 있다.
사용자 인터랙션에 따라 서비스의 상태 일관성이 유지되는 것은 매우 중요하다.
요청한 비동기 작업을 중단할 수 있게 해주는 Web API AbortController를 활용하면 간단하게 해결할 수 있다. 사용자가 새로운 요청을 하면 기존 처리 중인 요청을 취소하고 최신 요청을 처리하는 것이다.
let controller = null;
const search = async (query) => {
if (controller) {
controller.abort(); // 이전 요청 취소
}
controller = new AbortController(); // 최신 요청에 대한 새 컨트롤러 생성
const results = await searchAPI(query, { signal: controller.signal });
setSearchResults(results);
};
페이지네이션
페이지네이션의 경우에도 경쟁 상태가 발생할 수 있다. 만약 사용자가 1페이지를 요청하고 응답 전 2페이지를 요청했다고 가정해보자. 이때 2페이지 응답보다 1페이지 응답이 나중에 온다면 어떻게 될까? 사용자는 2페이지에서 1페이지에 대한 내용을 보게 될 수 있다.
const loadPage = async (page) => {
const data = await getPage(page);
setCurrentPageContents(data);
};
이번에는 AbortController가 아닌 요청된 페이지와 현재 페이지가 같은지 조건문을 통해서 해결해보자.
const loadPage = async (page) => {
const data = await getPage(page);
if (page === currentPage) {
setCurrentPageContents(data);
}
};
page는 요청 시점에 사용자가 원하는 페이지다. 그리고 currentPage는 현재 사용자가 보고 있는 페이지다. 두 페이지가 일치하는 경우에만 상태를 업데이트해주면 사용자는 의도한 내용을 볼 수 있다.
마무리
프론트엔드 개발자들은 종종 “JavaScript는 싱글스레드니까 동시성 문제는 없다”고 생각한다. 하지만 비동기 작업이 복잡해질수록 경쟁 상태는 빈번하게 발생한다.
- JavaScript는 싱글 스레드지만, Web API는 멀티 스레드로 동작한다
- 비동기 응답 순서는 요청 순서와 다를 수 있다
- 공유 상태는 반드시 응답 완료 시점에 업데이트한다
- 필요 시, 이전 요청 취소 또는 최신 요청 검증을 통해 경쟁 상태를 방지한다
작은 변수 하나라도 비동기 흐름과 결합되면 예상치 못한 문제를 만들 수 있다. 이제는 프론트엔드 개발자에게도 경쟁 상태를 읽고 제어할 수 있는 역량이 필수라고 생각한다.