싱글 스레드에서도 경쟁 상태는 발생한다
이상한 진행률 바 동작
최근 프로젝트에서 여러 이미지를 동시에 업로드하고 진행률을 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는 멀티 스레드로 동작한다.
- 비동기 응답 순서는 요청 순서와 다를 수 있다
- 공유 상태는 반드시 응답 완료 시점에 업데이트한다
- 필요 시, 이전 요청 취소 또는 최신 요청 검증을 통해 경쟁 상태를 방지한다
작은 변수 하나라도 비동기 흐름과 결합되면 예상치 못한 문제를 만들 수 있습니다. 이제는 프론트엔드 개발자에게도 경쟁 상태를 읽고 제어할 수 있는 역량이 필수라고 생각합니다.