
요약

들어가며
안녕하세요. 당근 로컬비즈니스실 동네걷기팀에서 FE 인턴으로 일하고 있는 카멜(진나영)이에요. 당근 동네걷기는 걸음 수를 기록하고 보상을 제공하는 서비스예요. 그런데 모달, 바텀시트, OS 권한 팝업이 각기 다른 조건에서 열리다 보니, 한 화면에 여러 UI가 겹쳐 나타나는 문제가 있었어요.
이 글은 제가 이 복잡도를 어떻게 풀었는지에 대한 기록이에요. 다만 “이렇게 만들었습니다” 보다는, 어떻게 문제를 정의하고 고민하며 해결해나갔는지 서술해보려고 해요.
이런 분들이 읽으면 재미있을 것 같아요
- 한 화면에 오버레이를 띄우는 조건이 계속 늘어나서 onClose 체이닝이 감당이 안 되기 시작한 분
- 웹뷰-네이티브 환경에서 웹뷰가 제어할 수 없는 OS 팝업까지 같은 흐름으로 다루고 싶은 분
- 비동기 스케쥴링에 대한 이해가 실무에 어떻게 활용될 수 있을지 궁금한 분
나올 키워드들
문제 재정의 · 추상화 단위(데이터 vs 함수) · Promise deferred 패턴 · ES2024 Promise.withResolvers · 실행 컨텍스트와 렉시컬 환경 · Job(ECMAScript의 비동기 작업 단위) · queueMicrotask
1. 동네걷기 홈은 오버레이가 참 많아요

동네걷기 홈은 도메인 요구사항 대부분이 모달, 바텀시트 같은 오버레이로 나타나는 화면이에요. 페이지에 들어가자마자 뜰 수 있는 것만 적어볼게요.
- OS 신체활동 권한 팝업 (네이티브가 띄워요. 웹뷰가 제어할 수 없어요)
- 신체 활동 권한 안내 (웹뷰 UI)
- 앱 알림 설정 유도 (웹뷰 UI)
- 온보딩 가이드 (코치마크 → 0걸음 보상 → 획득 애니메이션)
- 걸음 수 알림 모달
- 연속 방문 모달
- 그때그때 추가/제거되는 프로모션 오버레이들
문제는 이 오버레이 컴포넌트들이 서로의 존재를 전혀 모른 채 독립적으로 마운트되고, 각자의 조건을 보고 발화한다(스스로 뜨려고 함)는 거였어요. 이 오버레이들은 유저가 눌러서 뜨지 않아요. 웹뷰 바깥 맥락(앱버전, 네이티브 권한 상태, 서버 응답, 정책 조건 등)이 충족되면 스스로 발화해요.
그런데 사용자에게는 반드시 한 번에 하나씩, 앞의 오버레이가 닫힌 다음에 그 다음 오버레이가 보여야 하잖아요. 모달과 바텀시트, 토스트가 한꺼번에 뜨면 사용자는 무엇부터 확인해야 할지 혼란스러우니까요.
이전까지의 해법은 컴포넌트끼리 직접 연결하는 거였어요.

이 구조의 비용은 꽤 분명했어요.
- 각 컴포넌트가 “자기 다음에 뭐가 와야 하는지”를 알아야 해요. 오버레이 하나가 다른 오버레이의 존재를 알고 있어야 순서가 성립하니까요.
- onClose, onAnimateEnd 핸들러가 prop으로 계속 뚫려요.
- 새 이벤트성 오버레이가 중간에 끼어들면 체인을 끊고 다시 이어야 해요. 기후캠페인처럼 한시적 프로모션이 붙을 때마다 전체 분기와 prop 핸들러를 손대야 했어요.
- 도메인 로직 사이에 맥락을 알기 어려운 분기 조건이 섞여요. 그리고 이게 QA 사각지대가 돼요. 발화 조건 조합의 경우의 수만큼 순서가 깨질 수 있으니까요.
- 가장 큰 문제는 플래그였어요. 서로의 비동기 데이터가 준비됐는지, 조건을 평가했는지 등을 알리기 위한 플래그가 하나 둘 늘었고, 흐름은 점점 읽기 어려워졌어요.
2. 근데 진짜 골치는 웹뷰 밖에 있었어요
여기에 문제는 하나 더 있었어요. 동네걷기는 웹뷰라서, 유저의 걸음 수를 가져오려면 네이티브 브릿지를 통해야 해요. 그런데 이 호출은 OS 시스템 권한 팝업을 띄워요.

await bridge.getStepCount({})
// 권한을 처음 요청할 때면 OS가 시스템 팝업을 띄우고,
// 사용자가 응답할 때까지 이 Promise는 pending이 팝업은 웹뷰가 띄우는 게 아니에요. 그래서 웹뷰가 닫을 수도, 지금 떠 있는지 조회할 수도 없어요. 웹뷰가 알 수 있는 유일한 신호는 브릿지가 돌려준 Promise가 팝업이 떠있는 동안은 pending이라는 사실 하나뿐이었어요.
즉 “지금 화면 위에 뭔가 떠 있다”를 표현할 수 있는 채널이 Promise의 상태밖에 없었던 거예요.
이 쯤에서 “오버레이 복잡도는 overlay-kit 같은 기존 라이브러리를 쓰면 되는 거 아닌가?”라고 궁금하실 수 있을 것 같아요. 하지만 이런 라이브러리가 푸는 건 “어떻게 띄울지”예요. 저희 문제는 ‘오버레이를 지금 띄워도 되는지’였어요.
- open() 호출 순서가 그대로 노출 순서가 돼요. 그런데 우리는 그 호출 순서가 곧 서버 응답 도착 순서라 매번 달랐어요. 라이브러리를 깔아도 “지금 떠도 되나”를 판단하는 플래그는 그대로 남아요.
- 웹뷰 밖 오버레이를 웹뷰는 알 수 없어요. 걸음수 세기 브릿지가 띄우는 OS 권한 팝업은 React 트리 밖이라 어떤 오버레이 라이브러리도 그 pending을 알 수 없었어요.
- 순서는 도메인 정책이라 라이브러리 밖에 남아요. “권한 결손은 진행 중인 걸 밀어낸다” 같은 건 어떤 라이브러리도 대신 정해주지 않아요.
3. 처음엔 “오버레이를 한 곳에 등록하자” 였어요
눈에 보이는 문제가 오버레이니까, 오버레이를 한 곳에 등록해서 제어하자.
처음엔 ebay/nice-modal-react처럼 컴포넌트를 전역에 register해두고 호출처는 id로 부르되, 거기 없는 우선순위 큐잉을 얹는 형태를 생각했어요. 그렇게 버디한테 공유했던 멘탈 모델은 이랬어요.
오버레이는 우체국 창구처럼 줄을 서요. 한 번에 한 명만 창구에 있고, 다음 사람은 앞 사람이 끝나야 들어와요. VIP는 새치기할 수 있고, VIP가 오면 지금 창구에 있는 사람은 즉시 자리를 비워요.
처음 그린 설계는 지극히 자연스러운 생각의 흐름이었어요.
그리고 이 비유는 결국 최종 설계까지 그대로 남았지만, 줄을 서는 대상은 완전히 바뀌게 돼요.
4. 버디가 기존 설계의 트레이드오프를 짚어줬어요
설계 리뷰에서 돌아온 피드백은 이거였어요.
도메인과 강하게 결합된 오버레이가 전역 레이어로 끌어올려지면 응집도가 깨진다.
이 피드백을 계속 곱씹어보니, register 방식의 매력이었던 “한눈에 보는 제어”에는 구체적으로 이런 비용이 붙어있었어요.
- 로직과 UI가 서로 다른 곳에 정의돼요. 호출처는 register만 하고 실제 렌더는 전역 호스트에서 일어나요. 오버레이 하나를 이해하려면 호출처 / 렌더 맵 / 정책 카탈로그 세 곳을 따라가야 흐름이 보여요.
- 도메인 로직이 전역 state로 흘러가요. 호출처가 navigation이나 보상 처리 함수를 payload에 담아 등록하면, 그 클로저가 전역 state에 보관된 채 호스트에서 호출돼요. 호출처가 언마운트되거나 리렌더돼도 클로저는 등록 시점 스냅샷 그대로 살아남아요.
- 변경 영향 범위가 커져요. 새 오버레이 하나 추가하려면 호출처, 렌더 맵, 정책 카탈로그를 다 건드려야 해요.
그러니까 “한눈에 보는 제어”는 여러 도메인 맥락이 전역 레이어로 올라와 섞이는 비용을 치르고 사는 거였어요. 그러고 나니 제가 문제를 보는 시각 자체에 찜찜함이 남았어요.
애초에 내가 문제를 너무 좁게 정의한 건 아닐까?
5. 문제 재정의
문제 정의 자체에 대한 의심은 들었지만 그렇다고 쉽게 추상화가 되지는 않았어요. 그래서 의식적으로 큰 맥락을 보려고, 하루를 통째로 마인드맵 그리는 데 썼어요. 모든 생각에 의문을 달고, 그 의문에 다시 생각을 다는 식으로요.

그렇게 한참을 째려보다가 도달한 재정의는 이거였어요.
‘복잡도의 본질은 오버레이가 아니라, 여기저기서 제멋대로 시작되는 비동기 작업이었구나!’
오버레이는 그 작업이 지금 화면에 드러나는 모양일 뿐이었어요.
같은 발화가 다른 도메인에서는 토스트일 수도, 네이티브 팝업일 수도, 화면 전환일 수도 있어요. 제가 줄을 세워야 하는 건 UI가 아니라 오버레이 발화 그 자체였던 거예요.
6. 재정의하고 나니 해법이 따라왔어요
문제를 “비동기 발화의 동시성 제어”라고 재정의 하는 순간, 해법이 거의 동시에 생각났어요. 자바스크립트에는 이미 비동기 동시성을 우아하게 다루라고 만들어둔 상태머신이 있잖아요.
Promise와 async/await요.
그리고 오버레이의 생명주기가 Promise의 상태에 거짓말처럼 맞아떨어졌어요.

여기서부터 쓸 용어를 먼저 정리할게요. 큐에 줄 세우는 단위를 엔트리(entry), 엔트리가 들고 있는 비동기 함수를 trigger(코드에서는 fn 필드), 지금 실행 중인 엔트리를 active라고 부를게요. 또, 호출처가 큐에 엔트리를 넣는 건 예약(reserve) 이라고 할게요.
오버레이가 떠 있다 = 그 엔트리의 Promise가 pending 상태다.
그렇다면 큐 동기화를 명령형으로 짤 이유가 없어요. 이 한 줄이면 되니까요.
await entry.fn() // 오버레이가 닫힐 때까지 이 줄에서 멈춰 있어요
// 이 await이 풀리는 순간(= 사용자가 닫아서 resolve가 호출된 순간) 큐가 다음 엔트리로 넘어가요
Promise 는 비동기의 진행 상태를 알려주는 JS의 상태 머신이에요. 실행 순서는 마이크로태스크 큐가 정하며, 코드 실행을 기다렸다가 다시 이어하는 복잡한 제어는 await 과 브라우저 런타임이 책임져요.
호출처가 달라지는 모습은 이래요. 각 컴포넌트는 자기 우선순위만 얹어서 예약하고, 줄 세우기는 큐가 해요.

추상화 단위를 데이터에서 함수로
큐에 줄 세우는 대상을 오버레이 정보(데이터)에서 실행할 일(함수)로 바꿨어요. 큐는 그 안에서 뭘 하는지 모르고, 끝날 때까지 기다리기만 해요.

여기서 재밌었던 게, 아이디어가 정해지고 나서 코드베이스를 살펴보니 팀에 이미 같은 문제를 다룬 선행 설계(OverlayManager)가 있었다는 거예요. 출발한 문제 정의도, 우선순위 큐라는 아이디어까지도 비슷했는데 추상화 단위가 달랐어요. 그쪽은 오버레이를 정책을 가진 이벤트 객체로 표현하는 데이터 중심 모델이었거든요.
그래서 이걸 “다른 방식”이 아니라 “그 기반 위에서 추상화 단위를 한 칸 올린 형태” 로 포지셔닝했어요. 자료구조와 정렬 알고리즘, dedupe 개념은 거의 그대로 차용하고, 상태 관리 모델만 바꿨어요.
이 한 칸 덕분에 별도 메커니즘 없이 풀리는 영역이 세 개 생겼어요.
(1) 웹뷰 밖 비동기, 그러니까 네이티브 팝업의 통합
이벤트 객체 모델에서는 OS 팝업이 떠 있는 동안 큐가 그 사실을 알 채널이 없어요. 브릿지 호출과 응답이 큐 바깥에서 처리되고, 결과를 받은 뒤에 다시 이벤트로 등록하는 식으로 우회해야 해요. 그동안 큐는 다른 엔트리를 active로 승급시킬 수 있고, 그러면 OS 팝업과 웹뷰 모달이 겹쳐 보여요.
함수 단위에서는 그냥 trigger 안에서 await 하면 돼요.
reserve('high', async () => {
await bridge.getStepCount({}) // OS 팝업 떠 있는 동안 pending
// 응답 후 후속 처리
})브릿지 Promise의 pending 기간이 그대로 큐의 active 기간이 돼요. 웹뷰가 제어할 수는 없지만, 떠 있는 동안 기다릴 수는 있게 된 거예요. 2장에서 “웹뷰가 알 수 있는 신호가 Promise뿐”이라고 했던 게 이 구조에서는 자산이 됐어요.
(2) 복합 비동기 플로우의 원자성
권한 요청 → 결과 → 분기 모달 같은 흐름은 본질적으로 여러 비동기 단계가 묶인 하나의 시퀀스예요. 데이터 모델에서는 이걸 큐 바깥의 콜백으로 엮어야 하고, 콜백 안에서 다시 등록하는 중첩 구조가 생기고, 중간에 다른 오버레이가 끼어들면 흐름이 깨져요.
함수 단위에서는 await 체이닝 한 번이에요.
reserve('high', async () => {
const result = await bridge.requestPermission()
if (result === 'granted') await grantedModal.open()
else await deniedModal.open()
})이 trigger의 Promise가 settled되기 전에는 큐의 다른 엔트리가 시작되지 않아요. 흐름 전체가 큐의 한 단위가 되면서 다른 trigger와 섞이지 않아요.
(3) 호출처 응집도 보존
trigger 함수가 호출처 컴포넌트 안에 머무니까, UI 렌더와 도메인 로직도 거기 머물러요.
function PedometerPageExit() {
const modal = useReserveOverlay(OVERLAY_PRIORITY.UNCLAIMED_REWARD_ALERT)
const { pop } = useNavigator()const handleExit = async () => {
if (rewardableMilestones) {
await modal.open() // 사용자가 닫을 때까지 기다려요
pop() // navigation이 호출처 클로저에 머물러 있어요
} else {
pop()
}
}
return modal.isOpen
? <UnclaimedRewardAlert />
: null
}pop이 전역 state로 흘러가지 않으니 노출 범위가 줄어요. 큐는 priority랑 trigger 함수만 받으니까 도메인 로직과 UI는 큐의 책임 영역 밖에 남고요.
7. 그런데 Promise의 resolve를 밖에서 불러도 되나요?
여기까지 오면 구현상 걸리는 지점이 하나 생겨요. Promise는 settle 제어권(resolve/reject 호출)이 생성자 콜백 안에 갇혀 있어요. fetch 응답처럼 결과가 자체적으로 도출되는 경우엔 문제가 없는데, 사용자의 닫기 클릭으로 settle을 일으켜야 하는 우리 케이스에는 부족하잖아요.
찾아보니 이건 자바스크립트 생태계가 오래 겪어온 고민이었고, 그만큼 오래된 해법이 있었어요. deferred 패턴이라고 불렸는데, 생성자 콜백 안에서 resolve/reject를 외부 변수에 캡처해두고 나중에 밖에서 호출하는 방식이에요. jQuery의 $.Deferred, Bluebird의 Promise.defer, Q 라이브러리까지 여러 구현체가 같은 형태를 제공해왔더라고요.
이러한 Promise 내부의 제어권을 외부에 노출하는 패턴은 캡슐화를 해친다는 우려가 있었어요.
하지만 복잡한 비동기 상태 전이를 다룰 때 이 구조가 얼마나 자주 필요한지 커뮤니티가 증명해 냈고, 결국 ES2024에서 Promise.withResolvers라는 정식 스펙으로 편입되었어요.
표준화가 이 패턴의 완벽함을 보증하는 것은 아니에요. 다만, 자바스크립트 환경에서 비동기 상태 머신을 더 유연하게 통제하려는 실무적 요구사항이 그만큼 지배적이었음을 보여주는 증거였어요.
Promise.withResolvers 라는 API 는 명세상 다음 코드와 동등해요.
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
이 말인즉, 구현 비용이 거의 없다는 뜻이기도 해요. 브라우저 지원 범위 때문에 폴리필을 붙일지 잠깐 고민했지만, 9줄짜리 헬퍼로 끝나는 일이라 의존성을 늘릴 이유가 없다고 판단했어요.
// withResolvers.ts
export function withResolvers<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
이 시스템의 큐 동기화는 전부 이 패턴 위에 서 있어요.
8. 하지만 생각보다 스케줄링은 직접 해야 했어요
설계를 코드로 옮기기 시작하면서 처음 만난 벽은 이거였어요. Promise랑 이벤트 루프를 쓰면 스케줄링이 공짜로 될 줄 알았는데, 그게 아니었던 거에요.
문제를 정확히 적어보면 이래요.
- 페이지가 마운트되면 자식 컴포넌트들이 같은 커밋 페이즈에서 마운트되고, 각자의 useEffect가 같은 동기 태스크 안에서 연이어 실행돼요.
- 그러면 reserve 호출도 같은 태스크 안에 줄줄이 쌓여요. 그런데 호출 순서와 우선순위 순서는 아무 상관이 없어요.
- 만약 reserve 직후에 곧바로 큐 처리(process)를 동기 실행하면, 가장 먼저 호출된 엔트리가 그대로 active가 돼버려요. 우선순위 정렬이 무의미해지죠.
- 그러니까 동기 코드는 Promise를 만들어두기만 하고, 실제 실행 결정은 “이번 동기 태스크에 들어온 요청이 전부 모인 뒤”에 한 번만 내려야 해요.
그 “전부 모인 뒤”가 정확히 어디냐. 여기서 제 이해가 모호하다는 걸 알게 됐어요.
모호했던 두 가지
원래 알고 있던 흐름은 대충 이랬어요.
동기 코드가 먼저 실행되고, await을 만나면 async 함수가 콜스택에서 빠졌다가, Promise가 settled되면 다시 돌아와서 나머지가 실행된다.
그런데 막상 구현하려니 두 지점이 막혔어요.
- “콜스택이 비워진다”는 기준이 정확히 뭐지? 전역 실행 컨텍스트까지 제거되는 순간이라면, 그 안에서 돌던 함수의 지역 변수도 같이 사라지는 건데, 큐는 어떻게 자기가 어떤 엔트리를 기다리고 있었는지 기억한 채 이어서 실행되지?
- await 이후 라인은 정확히 언제, 어느 사이클에 실행되지? 마이크로태스크 큐랑 매크로태스크 큐가 각각 한 번씩 비워지고 난 다음 사이클인가?
명세를 따라가서 정리한 답
“콜스택이 비었다”는 전역 실행 컨텍스트까지 빠진 상태예요. 스크립트가 끝까지 실행되면 스크립트 자신의 컨텍스트도 스택에서 내려가요. 마이크로태스크는 그다음에 돌아요.
그럼 변수는 왜 안 사라질까요? await이 실행 컨텍스트를 통째로 챙겨두기 때문이에요. await을 만나면 async 함수의 실행 컨텍스트는 스택에서 내려가지만 버려지지 않아요. Promise에 걸린 핸들러가 그걸 붙잡아 둬요. 변수가 담긴 렉시컬 환경도, “어디까지 실행했는지”도 전부 그 안에 들어 있어요. Promise가 settle되면 이 컨텍스트가 스택에 다시 올라와 멈춘 줄부터 이어가요. 명세는 이때 큐에 올라가는 작업 단위를 Promise Reaction Job이라고 불러요. 큐가 몇 초를 멈춰 있어도 깨어나서 그 entry를 resolve할 수 있는 건 이것 때문이에요. 저는 실행 컨텍스트와 렉시컬 환경을 같은 걸로 알고 있었는데, 여기서 처음 둘을 구분하게 됐어요.
또, await 다음 줄은 await을 만난 사이클이 아니라, settle된 사이클의 마이크로태스크에서 실행돼요. 사용자가 모달을 닫기까지 5초가 걸렸다면, 그 5초 동안 이벤트 루프는 수없이 돌고 닫는 순간에야 다음 줄이 실행돼요. 큐 입장에서 보면 await entry.fn()에서 몇 초씩 멈춰 있는 동안에도 바깥은 계속 돌아간다는 뜻이에요. 이게 뒤에서 재진입 플래그가 필요해지는 이유예요.
명확한 이해가 구현에 준 두 가지 확신
첫째, 같은 태스크 안의 race 걱정은 사라졌어요. async 함수가 제어권을 되찾는 시점이 “모든 동기 코드가 끝난 뒤”니까, 동기 코드에서 여러 컴포넌트가 Promise를 만들며 reserve를 호출하는 것과 큐가 그걸 정렬해 처리하는 것 사이에 경합이 생길 수가 없어요. 명세 기준으로 보장되는 순서였어요.
둘째, queueMicrotask를 쓰는 이유가 확정됐어요.
reserve(priority, fn, options) {
// ... 정렬 삽입
set((state) => ({ queue: sortQueue([...state.queue, entry]) }));
queueMicrotask(() => { void process(); }); // ★
}
같은 태스크에 들어온 예약은 이렇게 한 번에 정렬돼요.
매크로태스크(setTimeout(0))로 미루면 그 사이에 다른 태스크가 끼어들 수 있어요. 마이크로태스크로 미루면 같은 동기 태스크에 들어온 모든 reserve 호출이 전부 쌓인 뒤 한 번에 정렬되어 처리되는 게 보장돼요. queueMicrotask는 WHATWG 표준이고 당근의 브라우저 최소 지원 스펙도 커버하는 데다, 의미적으로도 "이 태스크 직후에 한 번"이라는 의도를 그대로 드러내서 이걸로 정했어요.
설계랑 엔진 동작이 그대로 맞닿는 지점이라, 개인적으로 이 프로젝트에서 제일 재밌었던 대목이에요.
9. 그래서 이렇게 만들었어요
쓰는 쪽에서 보이는 모습부터 시작해서, 정책, 내부 구현 순서로 내려가 볼게요.
호출처가 보는 API
호출처는 이 훅 하나만 알면 돼요.
useReserveOverlay<T = void>(options: { priority: Priority; id?: string; subOrder?: number }): {
isOpen: boolean // 렌더 조건
open: (options?: { onOpen?: () => void }) =>
Promise<ReserveResult<T | undefined>> // 큐에 예약
close: (value?: T) => void // 닫기 + 큐 진행
}호출처는 priority, id, subOrder를 직접 적지 않고, 카탈로그에 정의해둔 값을 꺼내 그대로 넘겨요.
onOpen은 예약한 시점이 아니라 실제로 화면에 뜬 시점에 불려요. 우선순위에 밀려 늦게 뜨거나 아예 못 뜰 수도 있어서, 노출 로그는 여기서 찍어요.
우선순위는 코드가 아니라 카탈로그에
호출처가 'high' 같은 문자열을 직접 쓰지 않게 했어요. 순서 요구사항이 바뀌었을 때 한 파일만 열면 되도록 하는 게 이 설계의 실질적인 목표 중 하나였거든요.
// overlayPriorities.ts
export const OVERLAY_PRIORITY = {
PERMISSION_ALERT_DIALOG: { priority: "critical", id: "permission-alert-dialog" },
APP_VERSION_GUIDE_BOTTOM_SHEET: { priority: "high", id: "app-version-guide-bottom-sheet", subOrder: 20 },
ZERO_STEP_ONBOARDING: { priority: "normal", id: "zero-step-onboarding", subOrder: 0 },
STREAK_SECTION_DIALOG: { priority: "normal", id: "streak-section-dialog", subOrder: 40 },
} as const satisfies Record<string, OverlayConfig>;
우선순위와 결과 시맨틱
우선순위는 4단계로 고정했어요.
type Priority = 'critical' | 'high' | 'normal' | 'low'
// 정렬 가중치 1000 / 100 / 10 / 1 — 간격을 넉넉히 둔 건 중간 단계 확장 여지 때문이에요
- critical: active 엔트리를 강제 종료하고 즉시 끼어들어요. 시스템 전제 조건이 깨진 긴급 상황(권한 거부 같은)에만 써요. 단, 강제 종료는 웹뷰가 닫을 수 있는 오버레이에만 동작해요. 가령, OS 팝업은 강제로 종료할 수 없어요.
- high : active 엔트리가 끝난 직후 실행되도록 앞쪽에 예약돼요.
- normal:일반 안내예요.
- low:온보딩 같은 보조 안내. 항상 뒤예요.
같은 priority 안에서는 호출 순서(FIFO)를 따르고, 명시적 순서가 필요할 때만 subOrder로 조정해요. 큐가 비어 있으면 모든 priority가 즉시 승급하고요.
그리고 결과는 취소와 에러를 다르게 다뤄요.
export type ReserveResult<T> =
| { cancelled: false; value: T }
| { cancelled: true; reason: "preempted" | "flushed" | "duplicate" };
취소 사유는 세 가지예요. preempted는 critical 엔트리에 밀려난 경우, flushed는 화면을 떠나면서 큐가 비워진 경우, duplicate는 같은 id가 이미 예약되어 있던 경우예요.
- 취소는 정상 플로우라 resolve로 돌려줘요. { cancelled: true, reason } 형태예요. 취소를 reject로 던지면 호출처마다 try/catch를 강제하게 되고, 안 잡으면 unhandled rejection이 떠요. 선점당하거나 화면이 정리된 건 예외가 아니라 결과예요.
- trigger가 throw한 진짜 에러만 reject로 전달해요. 큐가 삼키면 호출처가 실패를 모르게 되니까요. 다만 process의 try/catch가 그걸 받아서 엔트리 하나의 에러가 큐 전체를 멈추지는 않게 해요. 루프는 계속 돌고, 실패는 그 엔트리의 호출처만 받아요.
const result = await modal.open();
if (result.cancelled) return;
if (result.value === "exit") proceed();
이렇게 모달을 그냥 닫았을 때, 취소했을 때, 진행했을 때 각 다른 분기로 뻗어나가는 흐름도 이벤트 체이닝을 끊어내고 큐의 결과를 이용해 언어의 멘탈모델 아래서 사용할 수 있게 됐어요.
내부 구현
큐 스토어
큐는 Zustand 스토어로 관리했어요.
export interface Entry<T = unknown> {
priority: Priority;
/** trigger. 차례가 오면 실행되고, 반환한 Promise가 pending인 동안 active */
fn: () => Promise<T>;
/** critical 선점 / flush 시 호출. UI도 함께 닫히도록. */
onForceClose?: () => void;
/** 호출처에 반환된 Promise의 resolve. */
resolve: (result: ReserveResult<T>) => void;
/** trigger 자체가 throw했을 때의 reject. */
reject: (reason?: unknown) => void;
// … id, subOrder, createdAt (중복 판별·정렬용)
}interface State {
queue: Entry[];
active: Entry | null;
running: boolean;
}한 엔트리에는 Promise가 두 개 관여하고, 둘 다 withResolvers로 만들어요. 방향이 반대라는 게 포인트예요.
- fn()이 반환하는 Promise - “오버레이가 닫혔나”를 알려요. useReserveOverlay가 fn 안에서 만들고 resolve를 ref에 보관해뒀다가, 사용자가 닫아 close()가 불릴 때 resolve해요. process가 await하는 대상이 이거고요. UI에서 큐로 가는 신호예요.
- reserve()가 반환하는 Promise - “내 예약이 어떻게 끝났나”를 알려요. 큐가 resolve(정상 close 또는 취소)나 reject(trigger가 throw)로 매듭지어요. await modal.open()이 받는 게 이거예요.
const { promise, resolve, reject } = withResolvers<ReserveResult<T>>();process
process는 이 시스템의 심장인데, 실질적으로 await entry.fn() 한 줄이에요. 나머지는 전부 그 한 줄을 안전하게 감싸는 장치예요.
const process = async () => {
if (get().running) return;
if (get().active) return;
set({ running: true });try {
for (;;) {
const { queue } = get();
if (queue.length === 0) break;
const [entry, ...rest] = queue;
set({ queue: rest, active: entry });
try {
const value = await entry.fn();
entry.resolve({ cancelled: false, value });
} catch (error) {
entry.reject(error);
} finally {
set({ active: null });
}
}
} finally {
set({ running: false });
}
};useReserveOverlay 구현
내부 구현의 본질은 fn 안에서 만든 Promise의 resolve를 close에서 호출할 수 있게 다리를 놓는 것이고, 그 다리가 useRef예요.
export function useReserveOverlay<T = void>({ priority, id, subOrder }: Options) {
const reserve = useReservePromiseQueue();
const [isOpen, setIsOpen] = useState(false);
const resolverRef = useRef<((value: T | undefined) => void) | null>(null);const close = useCallback((value?: T) => {
setIsOpen(false);
const resolve = resolverRef.current;
resolverRef.current = null;
if (resolve) resolve(value);
}, []);
const open = useCallback(({ onOpen }: OpenOptions = {}) => {
return reserve<T | undefined>(
priority,
() => {
const { promise, resolve } = withResolvers<T | undefined>();
resolverRef.current = resolve;
setIsOpen(true);
onOpen?.();
return promise;
},
{ id, subOrder, onForceClose: () => close() },
);
}, [reserve, priority, id, subOrder, close]);
// … 언마운트 처리 (기다리던 resolve 정리, 언마운트 뒤 실행 방지)
return { isOpen, open, close };
}여기서 헷갈리기 쉬운 네 개념을 분리해두는 게 중요했어요.

onClose는 "resolve를 부르는 쪽"일 뿐 fn의 본체가 아니에요. 이 연결을 호출처가 직접 다루게 하면 러닝커브가 생겨서, 저수준 API(useReservePromiseQueue)는 래퍼 훅 안에 숨겼어요.
await이 막는 게 어디까지인지
큐가 한 엔트리를 await하고 있을 때 막는 건 큐의 다음 엔트리 진행뿐이에요. await은 "해당 async 함수 스코프의 일시 중지"지 "메인 스레드 블로킹"이 아니거든요.
- 렌더, setState, effect - 정상
- 사용자 인터랙션(클릭, 입력) - 정상
- 백그라운드 fetch, 폴링, setInterval - 정상
- 다른 컴포넌트의 reserve 호출 - 정상 (큐에 쌓이고 차례를 기다려요)
막히는 건 오직 “한 번에 하나의 trigger만 실행한다”는 원칙을 지키기 위한 큐 진행이에요.
entry.fn()은 사용자가 닫을 때까지 pending이라, 그 사이 이벤트 루프가 온전한 턴을 여러 번 돌아요. 그때 다른 코드가 process를 다시 부를 수 있고, state.running은 그걸 막기 위한 플래그예요. 8장에서 본 “settle이 일어난 사이클 기준”이 여기서 값을 치르는 셈이에요.
한 사이클로 돌려보면 이런 그림이에요
같은 태스크에 들어온 예약들이 마이크로태스크 경계에서 한 번에 정렬되고, 한 번에 하나씩 active가 되고, 중간에 들어온 critical이 active 엔트리를 밀어내는 것까지 담았어요.

10. 이게 임시방편이면 어떡하지, 싶었어요
구현하는 내내 찜찜한 게 하나 있었어요. 이 스케줄링이 제가 급조한 임시방편은 아닐까?
그래서 동시성 프로그래밍 책을 샀어요. Rust의 비동기 런타임 구현 부분이 목적이었는데, Rust는 async 런타임을 라이브러리 레벨에서 직접 구현하기 때문에 JS에서는 엔진에 가려 보이지 않는 구조가 코드로 드러나거든요.
그리고 대응 관계가 비슷하게 맞아떨어졌어요.

자바스크립트는 Promise/await이 Future/Waker를 자동으로 추상화해주기 때문에 코드가 훨씬 짧아져요.
구조가 똑같진 않지만, 완료 신호를 밖에서 주입하고 런타임이 재개시키는 패턴은 비슷했어요.
솔직히 책 사고 나서 “학습한 개념이랑 JS 구현이 시원하게 연결이 안 되는데, 괜히 복잡도만 키우는 건 아닐까” 싶은 순간도 있었어요. 하지만 결론적으로 이건 단순한 지적 만족이 아니라 실용적인 학습이자 소득이었어요. 설계의 흐름을 명세로 환원해 비교하면서 설계 구멍과 얻어걸렸던 안정성을 발견하고 이해할 수 있었거든요.
11. 배포하고 나서야 알게 된 이 설계의 경계
여기까지가 설계와 구현 이야기예요. 그런데 이 글에서 사실 제일 하고 싶은 이야기는 그다음이에요.
시스템이 잘 돌아가니까, 저는 화면 위의 모든 순서 문제를 이 큐로 풀려고 했어요. 대표적인 게 보상 흐름이었어요.
보상 받기 클릭 → 쿠폰 다이얼로그 → 배지 획득 페이지 → 첫 보상 안내 nudge
이 체이닝도 큐에 넣으면 복잡도가 사라질 거라고 기대했어요. 결과는 정반대였어요.
- 순서가 호출처를 떠나서 전역 카탈로그(subOrder)로 흩어졌어요.
- 발화가 handleRewardClick + handleAnimationEnd + effect로 쪼개졌어요.
복잡도가 완벽하게 준 게 아니라 이동하고 은닉된 거였어요. 제가 처음에 register 방식을 기각했던 바로 그 이유가, 다른 얼굴로 돌아온 거죠.
큐가 원래 푸는 문제로 되돌아가기
여기서 원점으로 돌아갔어요. 큐는 무엇을 위한 도구였지?
진입/세션 중에 여러 독립 조건이 각각 다른 hook/effect에서, 서로의 존재를 모른 채 blocking 오버레이를 띄우려 하는 상황.
큐 = 서로를 모르는 채 오버레이를 띄우려는 코드들의 조율기
이게 큐의 존재 이유이자 유일한 핵심 역할이에요.
반면 보상 흐름은 애초에 서로를 모르는 독립적인 오버레이가 아니었어요. 쿠폰을 여는 그 핸들러는 배지도, 넛지도 같은 자리에서 이미 알고 있거든요. 스스로 순서를 잡을 수 있는 하나의 흐름이었어요.
큐는 “흐름 내부 순서(체이닝)”를 위한 도구가 아니에요. 체이닝이 문제였던 게 아니라, 체이닝이 암묵적이고 분산되어 있던 게 문제였어요. 그 해법은 큐가 아니라 명시적인 로컬 시퀀서예요.
// 한 흐름이 세트를 다 아는 경우 — 큐가 아니라 그냥 async/await
await showCoupon() // close까지
await pushBadgeAndReturn() // 홈 복귀까지
await showNudge()
판별 기준을 한 문장으로
그래서 “무엇을 큐에 넣을 것인가”의 기준을 코드로 판별 가능한 형태로 정리했어요.
부록처럼 붙여둔 한 문장은 이거예요.
“이 blocking 오버레이가 뜨려는 그 순간, 그걸 여는 코드가 ‘지금 나 말고 뭐가 뜨려는지’를 알고 있나?”
안다(같은 흐름) → 로컬
모른다(독립 발화) → 큐
우리가 실제로 답해야 하는 건 이 오버레이를 누가 소유하나가 아니라, 여는 코드가 무엇을 보고 있나가 기준이에요. 이렇게 잡으면 판별 기준이 코드 구조에 붙기 때문에, 나중에 발화 코드를 리팩터해서 시야가 달라지면 재분류도 쉬워지고요.
따라서 서로의 존재를 알고 결합되어있을 수 밖에 없는 보상 흐름 내부의 순서는 로컬 시퀀서에서 관리했어요. 그리고 다른 독립적인 오버레이와 겹치지 않도록, 보상 흐름 전체를 관리하는 로컬 시퀀서 전체를 하나의 엔트리로 큐에 예약했어요.
12. 정리하며
큐 이전에는 동네걷기 홈 화면이 오버레이의 순서를 직접 들고 있었어요.
condition:
isZeroStepOnboardingResolved &&
isPermissionGranted &&
!wasShownStreakDialogAlready,
이렇게 서로 다른 도메인의 플래그 셋을 한 자리에서 엮어야 “지금 이 오버레이를 띄워도 되나”가 나왔어요. 새 오버레이가 하나 끼면 이 조합을 다시 짜야 했고요.
지금 이 파일에는 오버레이 순서 코드가 한 줄도 없어요. 오버레이 순서를 조율하고 겹쳐뜨는 것을 방지하기 위해 order, condition, candidates가 나오던 일곱 곳이 전부 사라졌어요. 남은 건 예약 훅 호출 한 줄이에요.
플래그 자체가 없어진 건 아니에요. “권한이 허용됐나”, “이번 주에 봤나”는 도메인이 답해야 하는 질문이라 그대로 있어요. 사라진 건 그 플래그들을 한곳에 모아 조합하던 자리예요. 지금은 각 예약 훅이 자기 조건만 보고, 서로의 조건은 모른 채 응집되었어요.
그럼 순서는 어디로 갔냐면, 위에서 소개한 우선순위 카탈로그 한 파일로 갔어요.
순서를 실제로 바꿀 때 어떤 모양인지 하나만 볼게요. 기후캠페인 스탬프 흐름을 연속 출석 모달 뒤로 옮긴 변경에서 바뀐 코드는 이게 전부였어요.
subOrder: 10 → 40
큐를 만들고 4개월 반 동안 오버레이의 순서 요구사항은 15번 바뀌었어요.
예전이었다면 순서 변경마다 복잡한 조건 조합을 다시 짜고, 그게 다른 오버레이의 조건과 부딪히지 않는지 확인해야 했을 거에요.
또, 이제 오버레이 하나가 삭제되더라도 남은 오버레이의 순서를 다시 엮는 작업은 없었어요.
마지막으로 검증하는 방식이 바뀌었어요. 예전에는 순서를 하나 건드리면 그게 맞는지 확인할 방법이 화면을 직접 띄워보는 것밖에 없었어요. 조건 조합의 경우의 수만큼 다시 봐야 했고요. 지금은 그 확인이 테스트 파일 4개, 케이스 29개로 내려가 있어요.
- 호출 순서가 아닌 priority 순으로 entry가 처리된다
- active만 강제 종료하고 대기 큐는 유지한 채 critical을 먼저 처리한 뒤 본래 순서로 이어간다
- active 컴포넌트가 close 없이 언마운트되어도 다음 entry가 진행된다
이 테스트가 보장되는 한 오버레이는 순서대로 하나씩 발화될테니 화면을 직접 띄워 확인할 필요가 없어졌어요. 순서에 대한 확인이 수동 QA에서 CI로 옮겨간 거에요.
돌아보면, 이 프로젝트는 크게 세 번의 전환으로 이루어졌어요.
첫째, 문제 정의의 전환. 눈에 보이는 문제(오버레이 충돌)에 머물지 않고 “이 정의가 너무 좁은 건 아닐까?”를 의식적으로 의심한 게 결정적이었어요. 버디의 피드백이 그 의심의 계기가 됐고, 마인드맵으로 큰 그림을 보는 시간이 추상화 도약의 토대가 됐어요. 재정의가 되고 나니, 해법에 대한 아이디어는 거의 바로 나왔고요.
좋은 해법이 좋은 문제 정의를 만드는 게 아니라, 좋은 문제 정의가 좋은 해법을 만들었어요.
둘째, 추상화 단위의 전환. 데이터에서 함수로 한 칸 올린 것만으로 네이티브 비동기 통합, 복합 플로우의 원자성, 호출처 응집도 보존이 대부분 따라왔어요. 추상화는 뭔가를 더 얹는 일이라기보다, 이미 언어가 가지고 있는 능력에 문제를 갖다 붙이는 일에 더 가까웠어요.
셋째, 이해의 깊이. Promise를 상태머신으로 활용하는 흐름을 제대로 이해하려고 deferred 패턴의 계보랑 ES2024 표준, ECMAScript의 Job 실행 모델, 다른 언어의 검증된 런타임 구조까지 찾아 들어갔어요.
그리고 11장이 말해주듯, 잘 만든 시스템은 그다음에 “이걸 어디까지 쓸 것인가” 라는 새로운 문제를 만들어요. 큐를 만든 것보다 큐를 어디에 쓰지 않을지를 정한 것이 이 프로젝트에서 더 어려운 판단이었어요.
혹시 지금 화면 위에서 오버레이들이 서로 밀치고 있다면, 먼저 이 질문부터 던져보시길 권해요.
이 오버레이를 여는 코드는, 지금 나 말고 뭐가 뜨려는지 알고 있나요?
답이 “모른다”면, 독립적으로 실행되는 흐름을 조율하기 위해 큐를 고려해볼 수 있어요.
하지만 “안다”면, 필요한 건 큐가 아니라 의미를 담은 체이닝일 수 있어요.
참고한 자료
- MDN — Promise.withResolvers()
- MDN — queueMicrotask()
- ECMAScript 명세 — ScriptEvaluation, async function 재개 (Job / Realm / LexicalEnvironment)
- sindresorhus/p-queue — process 재진입 방지, clear 시 대기 Promise 처리의 반면교사
- eBay/nice-modal-react — register 기반 오버레이 관리 패턴
- 다카노 유키, 『동시성 프로그래밍』(한빛미디어) — Executor / Waker / Task 모델
당근 동네걷기 위에서 동시에 쏟아지는 오버레이들을 우아하게 제어하기 was originally published in 당근 기술 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.