Skip to main content
← 블로그

한 단계의 모달이 만든 다섯 가지 문제

VauDium ·

가이드 모드의 만족도 단계가 모달이라는 이유로, 그 위로 다섯 개의 문제가 줄줄이 따라왔습니다. 우여곡절 끝에 결국 RN Modal을 바꿨습니다.

한 단계의 모달이 만든 다섯 가지 문제

시작은 단순했다

태스크 회고(retrospect) 가이드 모드의 흐름은 결과 → 만족도 → 회고 세 단계로 구성됩니다. 결과와 회고는 텍스트 입력 (인라인), 만족도는 별 5개 모달이라 모달이 흐름 한가운데 끼어 있습니다.

처음 보고된 버그: “만족도 선택하고 나면 회고 입력으로 포커스가 안 가는데?”

간단해 보였습니다. 모달 닫고, 다음 입력에 focus만 주면 되니까. 그런데 그 한 줄이 다섯 개의 문제로 펼쳐졌습니다.

문제 1: focus race condition

만족도 모달이 닫히면서 회고 input에 focus를 주려고 했는데 안 됐습니다. iOS의 first responder 흐름이 modal close animation과 겹치면서 race가 났던 것.

처음 시도: setTimeout(focus, 100ms). 가끔 잘 됐지만 들쭉날쭉.

그러다 edit-daily-review 화면에서는 같은 패턴이 깔끔하게 동작하는 걸 발견. 거기는 setTimeout(focus, 300ms)로 modal close animation 끝난 뒤에 충분히 기다리고 있었습니다.

근본적으로는, BottomSheetModal이 실제로 unmount된 시점을 알려주는 콜백이 필요했습니다. onClosed prop을 추가해서 close animation + unmount 완료 후에 firing하게 했고, 거기서 handleGuideNext()를 호출. 이제 race 없이 깔끔하게 focus 진행.

문제 2: 만족도 변경이 너무 무거웠다

focus는 잡혔는데, 이번엔 사용자가 “굉장히 느리다”고 합니다.

원인은 taskRecordMutations.upsertbumpVersion → 글로벌 atom 업데이트 → BrowseTaskRecordPage(2400줄짜리 컴포넌트) 전체 리렌더. 만족도 한 번 바꿀 때마다 무거운 리렌더가 일어나서 modal close + focus 흐름을 막고 있었습니다.

다른 필드들(result, retrospect)은 어떻게 처리하고 있는지 봤더니 — local React state를 쓰고 있더군요. atom은 안 거치고 가벼운 setState만. 만족도도 같은 패턴으로 바꿨습니다.

// Before: heavy
taskRecordMutations.upsert(json);  // bumpVersion → 글로벌 리렌더

// After: light
setSatisfaction(value);  // local state, 가벼움
setTaskRecordSatisfaction(...).then(...);  // API는 백그라운드

비슷한 UX 패턴이라도 어떻게 구현했느냐에 따라 체감 속도가 완전히 다릅니다. 가벼운 path가 있는데 무거운 걸 쓰고 있었던 것.

문제 3: 끝나고 맨 위로 스크롤이 끝까지 안 갔다

가이드 끝나면 맨 위로 스크롤하도록 했는데, 중간에 멈추거나 아예 시작도 안 했습니다.

원인을 추적해보니 footerHeight = guideMode ? window.height : 300 이라는 코드가 있었습니다. 가이드 모드 종료 시 footer가 갑자기 작아지면서 contentSize가 줄어, scroll target이 clamp됐던 것.

해결: keepGuideFooter라는 별도 state로 분리. 가이드 종료 시퀀스를 다음과 같이 정렬했습니다:

  1. t=0: 키보드 dismiss + 종료 메시지 표시
  2. t=300ms: scroll-to-top 시작 (footer 큰 상태에서)
  3. t=1500ms: 가이드 바 fadeout (LayoutAnimation)
  4. t=2200ms: footer 축소 (LayoutAnimation)

scroll이 안전하게 0까지 도달한 다음에 footer를 줄이는 방식. 한 박자씩 떨어뜨려야 하는 건 좀 번거롭지만, 동시에 일어나는 layout 변화들이 서로를 망가뜨리는 걸 막을 수 있습니다.

문제 4: 가이드 바가 모달 dim 아래로 들어갔다

만족도 모달이 뜨면 가이드 바(화면 상단)가 모달 backdrop의 어두운 dim에 가려졌습니다.

처음엔 backdropTopInset prop을 추가해서 backdrop 시작 위치를 가이드 바 밑으로 옮겼습니다. 시각적으로는 가이드 바가 보였지만 — 터치가 안 됐습니다.

iOS RN <Modal> 컴포넌트의 본질적 한계: Modal은 별도 UIWindow에 렌더되고, 그 window가 자기 영역의 모든 터치를 가로챕니다. backdrop이 비어있어도 터치는 modal window 안에서 죽어버립니다.

이게 한참 막혔습니다. “이건 못 고치겠지?” 라고 사용자가 묻길래 처음엔 그렇다고 했습니다. 모달은 RN의 native window여서 그 위로 다른 걸 띄울 방법이 없다고.

근데 잘못 생각했었습니다. 위로 띄우려는 게 아니라 모달을 RN Modal 대신 다른 방식으로 그리면 되는 거였습니다.

문제 5의 해결: RN Modal을 버리고 Portal

@gorhom/portal이 이미 프로젝트에 있었습니다. Portal은 메인 RN window 안에서 root level에 렌더하는 도구입니다 — 별도 native window를 안 만듭니다.

BottomSheetModal에 usePortal opt-in prop을 추가:

if (usePortal) {
    return (
        <Portal>
            <View style={ABSOLUTE_FILL} pointerEvents={mounted ? "box-none" : "none"}>
                {content}
            </View>
        </Portal>
    );
}
return (
    <Modal transparent visible={mounted} onRequestClose={onRequestClose}>
        {content}
    </Modal>
);

pointerEvents="box-none"이 핵심. 빈 영역(가이드 바 위치)의 터치는 underlying view로 자연스럽게 통과됩니다. backdrop과 sheet 위 터치는 정상 동작.

가이드 모드일 때만 usePortal={true}. 다른 모달들은 영향 없음 (opt-in).

문제 5: Next 누르면 모달이 안 닫혔다

Portal로 가이드 바와 모달이 같이 보이게 된 다음, 사용자가 Next 버튼을 모달이 떠 있는 동안에도 누를 수 있게 됐습니다. 그런데 누르면 가이드 단계는 다음으로 가는데 모달이 안 닫히는 문제.

원인: autoOpen prop이 false로 바뀌어도 SatisfactionPicker의 useEffect가 close 분기를 안 가졌었습니다. autoOpen=true일 때만 open만 했지 false일 때 close 안 했음.

useEffect(() => {
    if (autoOpen) {
        setShowModal(true);
        onOpenChange?.(true);
    } else if (showModal) {
        // 추가: autoOpen이 false로 전환되면 modal 닫기
        setShowModal(false);
        onOpenChange?.(false);
    }
}, [autoOpen]);

handleGuideNext에서 satisfaction 단계 떠날 때 setGuideSatisfactionAutoOpen(false)로 — useEffect가 close까지 처리하게.

정리

한 단계의 모달이 가이드 흐름 한가운데 있다는 이유로:

  1. focus race condition — onClosed 콜백 추가
  2. 무거운 글로벌 리렌더 — local state 패턴으로 전환
  3. scroll-to-top clamp — footer 축소 타이밍 분리
  4. GuideBar dim/touch 차단 — RN Modal → Portal로 교체
  5. Next 누를 때 modal 안 닫힘 — autoOpen false 전환 시 close 분기

다섯 개의 문제가 모두 “모달이 흐름 한가운데에 있다”는 한 가지 사실의 부수 효과였습니다. 인라인 picker로 바꾸면 다섯 개 모두 한 번에 사라지는데 — 사용자가 “인라인 picker는 우리에게 존재하지 않는다”고 단호하게 거부해서 (이유는 나름 있겠죠), 하나씩 우회해서 풀어냈습니다.

배운 것

구조적 결정 vs 회피 결정. 어떤 문제는 구조를 바꿔서 한 번에 사라뜨릴 수 있고, 어떤 문제는 구조를 유지하면서 다섯 번 우회해야 합니다. 둘 다 정답이 있고, 어느 쪽을 고르느냐는 비기능적 가치(브랜드 정체성, 사용자 멘탈 모델 등)에 달려 있습니다.

저는 인라인 picker가 더 깔끔하다고 생각했지만, 사용자는 만족도가 모달이라는 게 어떤 의도성/집중도를 만들어준다고 본 것 같습니다. 개발자 관점에서 “이게 더 나아 보인다”가 항상 옳지는 않습니다.

RN Modal의 한계를 처음으로 의식하게 됐다. “RN Modal은 별도 window라서 위에 못 띄운다”라는 사실은 알고 있었지만, “그러면 RN Modal 자체를 안 쓰면 되지”라는 발상은 한참 후에 떠올랐습니다. 익숙한 도구의 본질적 한계를 만났을 때, 그 도구를 쓰는 게 전제가 아니라 하나의 선택지라는 걸 잊지 말기.

그리고 한 가지 더 — 사용자가 “들쭉날쭉”이라고 했을 때 진지하게 받아들여야 합니다. 100ms 타임아웃으로 가끔 동작했던 게 race condition의 신호였는데, 처음엔 그냥 더 큰 timeout으로 우회하려고 했습니다. 결국 onClosed 콜백 같은 이벤트 기반 동기화가 답이었고, 처음부터 그 방향으로 갔으면 시간을 아꼈을 텐데요.