1. 간헐적 실패의 미스터리
서비스 런칭 전날, QA 팀에서 이상한 버그 리포트가 올라왔습니다. "회원가입을 하고 프로필 사진을 등록하면, 가끔 실패합니다."
코드는 대충 이랬습니다.
const handleSignup = (data) => {
api.createUser(data); // 1. 유저 생성
api.uploadAvatar(data.image); // 2. 이미지 업로드
alert("가입 완료!");
};
제 눈에는 완벽했습니다. 1번 줄에서 유저를 만들고, 2번 줄에서 이미지를 올린다. 순서대로 썼으니까 순서대로 실행되겠지?
틀렸습니다.
createUser가 서버에서 응답을 받기도 전에(0.1초), 자바스크립트는 참지 않고 바로 uploadAvatar를 실행해버린 겁니다.
서버 DB에 유저가 아직 안 생겼는데 이미지를 저장하려고 하니, "User Not Found" 에러가 2번에 한 번꼴로 터진 것이었죠.
2. 기다림의 미학 (async/await)
자바스크립트는 기본적으로 Non-blocking(기다리지 않음) 언어입니다. 개발자가 명시적으로 "야, 이거 끝날 때까지 기다려!"라고 말해주지 않으면, 그냥 다음 줄로 도망갑니다.
해결책은 await였습니다.
const handleSignup = async (data) => {
try {
const user = await api.createUser(data); // 기다려!
await api.uploadAvatar(user.id, data.image); // 이제 실행해!
alert("가입 완료!");
} catch (e) {
alert("실패...");
}
};
이제 createUser가 성공해서 user 정보를 돌려줄 때까지 다음 줄은 실행되지 않습니다.
버그는 사라졌습니다. 하지만 새로운 문제가 생겼습니다.
3. 이번엔 너무 느려졌다 (Waterfall 문제)
대시보드 페이지를 만드는데, 이번엔 로딩이 너무 오래 걸렸습니다. 코드를 보니 이렇게 되어 있었습니다.
/* 나쁜 예: 비동기 폭포 (Waterfall) */
const loadDashboard = async () => {
const user = await fetchUser(); // 1초
const posts = await fetchPosts(); // 2초
const friends = await fetchFriends(); // 1초
// 총 4초 소요
};
이 세 가지 데이터는 서로 의존성이 없습니다. (게시글을 가져오기 위해 친구 목록이 필요하지 않음). 그런데 굳이 한 줄서기를 시켜서 4초나 걸리고 있었습니다.
호텔 뷔페에서 접시를 들고 줄을 서는데, 앞사람이 스테이크를 다 썰 때까지 뒷사람이 샐러드도 못 푸게 막는 꼴입니다.
4. 병렬 처리 - 동시에 달리기 (Promise.all)
서로 상관없는 요청이라면 동시에 출발시키는 게 맞습니다.
/* 좋은 예: 병렬 처리 */
const loadDashboard = async () => {
const [user, posts, friends] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchFriends()
]);
// 가장 오래 걸리는 요청 시간(2초)이면 끝!
};
Promise.all은 배열 안의 모든 요청을 동시에 쏩니다.
4초 걸리던 로딩이 2초로 줄어들었습니다. 성능 최적화는 멀리 있는 게 아닙니다.
5. 하나만 실패해도 다 죽는다? 자세히 살펴보기
Promise.all의 치명적인 단점은 "팀플레이의 비극"입니다.
3개 중 하나만 에러가 나도, 전체가 에러로 처리되어 catch로 넘어가 버립니다.
게시글 로딩에 실패했다고 해서 유저 정보까지 안 보여주는 건 너무 가혹하죠.
이럴 땐 Promise.allSettled를 씁니다.
const results = await Promise.allSettled([fetchUser(), fetchPosts()]);
// results[0] -> { status: 'fulfilled', value: { ... } }
// results[1] -> { status: 'rejected', reason: Error }
성공한 건 보여주고, 실패한 건 "로딩 실패"라고 띄워주는 유연한 UI를 만들 수 있습니다.
6. 이벤트 루프 시각화 (Event Loop) 제대로 이해하기
왜 이런 일이 일어날까요? 자바스크립트 런타임의 내부를 들여다봐야 합니다.
- Call Stack (호출 스택): 동기 코드가 실행되는 곳입니다. 싱글 스레드라 한 번에 하나만 처리합니다.
- Task Queue (태스크 큐):
setTimeout,fetch등의 콜백이 대기하는 곳입니다. - Event Loop (이벤트 루프): 교통경찰입니다. "스택이 비었나? 비었으면 큐에서 하나 가져와서 실행해!"라고 지시합니다.
api.createUser();
api.uploadAvatar();
위 코드를 실행하면 두 함수는 즉시 Call Stack에 들어갑니다. createUser는 네트워크 요청만 날리고 바로 리턴해버립니다. 그리고 런타임은 쉬지 않고 바로 uploadAvatar를 실행합니다.
await를 쓰지 않는 한, 자바스크립트는 절대 멈추지 않습니다.
7. 치명적 버그 - 경쟁 상태 (Race Condition) 해결하기
순차 실행보다 더 무서운 게 경쟁 상태입니다. 검색창을 상상해 보세요.
- 사용자가 "사"를 입력. 요청 A 출발 (3초 걸림)
- 사용자가 "과"를 입력. 요청 B 출발 (0.1초 걸림)
- 요청 B 도착. 화면에 "사과" 결과 표시.
- 요청 A 도착. 화면을 "사" 결과로 덮어씌움.
사용자는 "사과"를 검색했는데 "사"의 결과를 보게 됩니다.
이때 필요한 게 AbortController입니다.
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const res = await fetch(url, { signal: controller.signal });
// ...
} catch (e) {
if (e.name === 'AbortError') console.log('이전 요청 취소됨');
}
};
fetchData();
return () => controller.abort(); // 재실행 시 이전 요청 취소
}, [query]);
이 코드는 가장 최신의 요청만 살리고, 이전 요청은 가차 없이 죽여버립니다. 이 패턴 하나만 알아도 비동기 버그의 80%는 예방할 수 있습니다.
6. 마무리 - 순서가 필요한가? 아닌가?
비동기 코드를 짤 때 저는 항상 두 가지 질문을 던집니다.
- "뒷 작업이 앞 작업의 결과물(데이터)을 필요로 하는가?"
- YES ->
await로 순차 처리. (회원가입 -> 이미지 업로드)
- YES ->
- "아니라면, 굳이 기다릴 필요가 있나?"
- NO ->
Promise.all로 병렬 처리. (대시보드 로딩)
- NO ->
이 간단한 원칙만 지켜도 버그는 줄어들고 속도는 빨라집니다.
여러분의 await는 필수인가요, 아니면 그저 습관인가요?
My Signup Code Failed 50% of the Time (Async/Await Trap)
1. The Mystery of Intermittent Failures
The day before launch, QA reported a weird bug. "Signup works, but profile image upload fails randomly."
The code looked like this:
const handleSignup = (data) => {
api.createUser(data); // 1. Create User
api.uploadAvatar(data.image); // 2. Upload Image
alert("Done!");
};
It looked perfect to me. Line 1 creates the user, Line 2 uploads the image. Sequential order, right?
Wrong.
JavaScript is impatient. Before createUser got a response from the server (0.1s), JS immediately executed uploadAvatar.
Trying to save an image for a user that didn't exist in the DB yet caused a "User Not Found" error 50% of the time.
2. The Art of Waiting (async/await)
JavaScript is Non-blocking by default. Unless you explicitly say "Hey, wait for this to finish!", it runs away to the next line.
The solution was await.
const handleSignup = async (data) => {
try {
const user = await api.createUser(data); // Wait!
await api.uploadAvatar(user.id, data.image); // Now execute!
alert("Done!");
} catch (e) {
alert("Failed...");
}
};
Now, the next line won't run until createUser succeeds and returns the user.
The bug vanished. But a new problem emerged.
3. Now It's Too Slow (Waterfall Problem)
I was building a dashboard, and loading was painfully slow. I checked the code:
/* Bad Example: Async Waterfall */
const loadDashboard = async () => {
const user = await fetchUser(); // 1s
const posts = await fetchPosts(); // 2s
const friends = await fetchFriends(); // 1s
// Total: 4s
};
These three data points are independent. (I don't need the friend list to fetch posts). But I forced them into a single-file line, taking 4 seconds total.
It's like blocking the person behind you at a buffet salad bar until you finish cutting your steak at the meat station.
4. Parallel Processing: Running Together (Promise.all)
If requests are unrelated, launch them simultaneously.
/* Good Example: Parallel */
const loadDashboard = async () => {
const [user, posts, friends] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchFriends()
]);
// Takes as long as the slowest request (2s)!
};
Promise.all fires all requests in the array at once.
Loading time dropped from 4s to 2s. Performance optimization isn't rocket science.
5. Advanced: One Fail, All Die?
The fatal flaw of Promise.all is "Tragedy of the Team."
If even one request fails, the whole thing rejects and jumps to catch.
Failing to load posts shouldn't prevent showing user info. That's too harsh.
For this, use Promise.allSettled.
const results = await Promise.allSettled([fetchUser(), fetchPosts()]);
// results[0] -> { status: 'fulfilled', value: { ... } }
// results[1] -> { status: 'rejected', reason: Error }
You can build a flexible UI that shows what succeeded and displays "Load Failed" for what broke.
6. Conclusion: To Await or Not to Await?
When writing async code, I always ask two questions:
- "Does the next task depend on the data from the previous task?"
- YES ->
await(Sequential). (Signup -> Upload)
- YES ->
- "If not, do I really need to wait?"
- NO ->
Promise.all(Parallel). (Dashboard Loading)
- NO ->
Sticking to this simple principle reduces bugs and speeds up your app.
Is your await necessary, or is it just a habit?
7. The Event Loop Visualization
Why does this happen? We must look under the hood of the Javascript Runtime.
- Call Stack: Where your synchronous code runs. It's single-threaded. One thing at a time.
- Task Queue (Callback Queue): Where
setTimeout,fetchcallbacks wait. - Event Loop: The traffic cop. It checks: "Is the Stack empty? If yes, move one item from Queue to Stack."
When you write:
api.createUser();
api.uploadAvatar();
Both functions enter the Call Stack instantly. createUser fires off a network request (to the Web API) and returns immediately. Then uploadAvatar runs. The runtime does not pause unless you use await (which is syntactic sugar for dividing code into chunks and putting them in the Microtask Queue).
8. Critical: Handling Race Conditions (AbortController)
The most dangerous async bug isn't sequential execution; it's Race Conditions. Imagine a Search Input.
- User types "A". Request A fires (takes 3s).
- User types "Ap". Request B fires (takes 0.1s).
- Request B returns. UI shows "Apple".
- Request A returns. UI overwrites "Apple" with results for "A".
The user searched for "Apple" but sees results for "A".
This is where AbortController saves lives.
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const res = await fetch(url, { signal: controller.signal });
// ...
} catch (e) {
if (e.name === 'AbortError') console.log('Old request cancelled');
}
};
fetchData();
// Cleanup function: Cancel previous request when effect re-runs
return () => controller.abort();
}, [query]);
This ensures that only the latest request matters. The outdated ones are killed.
9. Conclusion: Control Time, Don't Let It Control You
Sticking to this simple principle reduces bugs and speeds up your app.
Is your await necessary, or is it just a habit?
10. Real-World Pattern: Queueing Requests
Sometimes, you need sequential processing but can't use await (e.g., inside a non-async event handler).
In this case, use a Promise Queue.
class RequestQueue {
constructor() {
this.queue = Promise.resolve();
}
add(operation) {
this.queue = this.queue.then(operation).catch(err => {
console.error("Queue Error:", err);
});
}
}
const queue = new RequestQueue();
// Button click handler
const handleClick = () => {
queue.add(() => api.analyticsLog('click')); // Guaranteed to run in order
};
This ensures that even if the user clicks 10 times rapidly, the analyticsLog calls will happen one after another, preserving the order of events without blocking the main thread.
This is extremely useful for Analytics, Logging, or Auto-saving features where order matters more than speed.