
내 회원가입 코드는 50% 확률로 실패했다 (Async/Await의 함정)
회원가입 후 프로필 이미지를 올리는 간단한 로직이었습니다. 그런데 왜 가끔 'User Not Found' 에러가 뜰까요? 자바스크립트의 비동기 처리와 Promise Waterfall 문제를 해결한 과정을 공유합니다.

회원가입 후 프로필 이미지를 올리는 간단한 로직이었습니다. 그런데 왜 가끔 'User Not Found' 에러가 뜰까요? 자바스크립트의 비동기 처리와 Promise Waterfall 문제를 해결한 과정을 공유합니다.
분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

서비스 런칭 전날, 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번에 한 번꼴로 터진 것이었죠.
자바스크립트는 기본적으로 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 정보를 돌려줄 때까지 다음 줄은 실행되지 않습니다.
버그는 사라졌습니다. 하지만 새로운 문제가 생겼습니다.
대시보드 페이지를 만드는데, 이번엔 로딩이 너무 오래 걸렸습니다. 코드를 보니 이렇게 되어 있었습니다.
/* 나쁜 예: 비동기 폭포 (Waterfall) */
const loadDashboard = async () => {
const user = await fetchUser(); // 1초
const posts = await fetchPosts(); // 2초
const friends = await fetchFriends(); // 1초
// 총 4초 소요
};
이 세 가지 데이터는 서로 의존성이 없습니다. (게시글을 가져오기 위해 친구 목록이 필요하지 않음). 그런데 굳이 한 줄서기를 시켜서 4초나 걸리고 있었습니다.
호텔 뷔페에서 접시를 들고 줄을 서는데, 앞사람이 스테이크를 다 썰 때까지 뒷사람이 샐러드도 못 푸게 막는 꼴입니다.
서로 상관없는 요청이라면 동시에 출발시키는 게 맞습니다.
/* 좋은 예: 병렬 처리 */
const loadDashboard = async () => {
const [user, posts, friends] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchFriends()
]);
// 가장 오래 걸리는 요청 시간(2초)이면 끝!
};
Promise.all은 배열 안의 모든 요청을 동시에 쏩니다.
4초 걸리던 로딩이 2초로 줄어들었습니다. 성능 최적화는 멀리 있는 게 아닙니다.
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를 만들 수 있습니다.
왜 이런 일이 일어날까요? 자바스크립트 런타임의 내부를 들여다봐야 합니다.
setTimeout, fetch 등의 콜백이 대기하는 곳입니다.api.createUser();
api.uploadAvatar();
위 코드를 실행하면 두 함수는 즉시 Call Stack에 들어갑니다. createUser는 네트워크 요청만 날리고 바로 리턴해버립니다. 그리고 런타임은 쉬지 않고 바로 uploadAvatar를 실행합니다.
await를 쓰지 않는 한, 자바스크립트는 절대 멈추지 않습니다.
순차 실행보다 더 무서운 게 경쟁 상태입니다. 검색창을 상상해 보세요.
이때 필요한 게 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%는 예방할 수 있습니다.
비동기 코드를 짤 때 저는 항상 두 가지 질문을 던집니다.
await로 순차 처리. (회원가입 -> 이미지 업로드)Promise.all로 병렬 처리. (대시보드 로딩)이 간단한 원칙만 지켜도 버그는 줄어들고 속도는 빨라집니다.
여러분의 await는 필수인가요, 아니면 그저 습관인가요?
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.
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.
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.
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.
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.
When writing async code, I always ask two questions:
await (Sequential). (Signup -> Upload)Promise.all (Parallel). (Dashboard Loading)Sticking to this simple principle reduces bugs and speeds up your app.
Is your await necessary, or is it just a habit?
Why does this happen? We must look under the hood of the Javascript Runtime.
setTimeout, fetch callbacks wait.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).
The most dangerous async bug isn't sequential execution; it's Race Conditions. Imagine a Search Input.
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.
Sticking to this simple principle reduces bugs and speeds up your app.
Is your await necessary, or is it just a habit?
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.