지도 화면의 바텀시트가 MEDIUM 에서 SMALL 로는 줄어들지 않고, LARGE 로만 가는 문제가 있었어요. 사용자가 시트를 아래로 드래그해도 SMALL 단계로 떨어지지 않았고, 한 단계만 작동하는 시트가 됐어요. 결국 한 줄짜리 fix 였지만, 그 한 줄은 “객체 키의 삽입 순서를 신뢰하면 안 된다” 는 흔한 함정에 대한 이야기예요.
1. 진단하기
useStandardBottomSheet 훅 내부에 시트 크기 전환 분기 로직이 있었어요. 핵심은 다음과 비슷한 모양이에요.
const sizes = Object.keys(parsedSnapPoints) as Size[]
const currentIdx = sizes.indexOf(sheetSize)
if (touchMove.movingDirection === 'DOWN' && currentIdx > 0) {
return sizes[currentIdx - 1] // 더 작은 시트
}
if (touchMove.movingDirection === 'UP' && currentIdx < sizes.length - 1) {
return sizes[currentIdx + 1] // 더 큰 시트
}
언뜻 합리적으로 보여요. “현재 idx 보다 한 단계 작은 / 큰 시트로 이동” 이라는 의미니까요.
문제는 parsedSnapPoints 가 어떤 순서로 만들어져 들어오는지였어요. 코드를 따라가보니 호출부에서 { MEDIUM, LARGE, SMALL } 순서로 객체를 구성하고 있었어요. ES2015 이후로 string key 의 삽입 순서는 spec 이 보장하긴 하는데, 그렇다고 비즈니스 로직 순서 와 일치한다는 보장은 어디에도 없어요.
그러니까 Object.keys(parsedSnapPoints) 의 결과는 ['MEDIUM', 'LARGE', 'SMALL']. 즉 MEDIUM=0, LARGE=1, SMALL=2. 시트 크기로는 SMALL < MEDIUM < LARGE 인데, idx 로는 MEDIUM < LARGE < SMALL 이 된 거예요. idx 의 의미와 도메인의 의미가 어긋나 있었어요.
2. 재현하기
각 조합을 표로 그려보면 한눈에 보여요.
| sheetSize | direction | currentIdx | 기대 | 실제 |
|---|---|---|---|---|
| MEDIUM | DOWN | 0 | SMALL 로 이동 | (이동 안 함) |
| MEDIUM | UP | 0 | LARGE 로 이동 | LARGE |
| LARGE | DOWN | 1 | MEDIUM 로 이동 | MEDIUM |
MEDIUM 에서 DOWN 일 때 currentIdx > 0 가드가 0 > 0 → false 로 막혀버리는 게 보여요. UP 방향은 0 < 2 → true 라 idx=1 인 LARGE 로 정상 이동하고요. SMALL 에서 출발하는 케이스는 사용자가 거의 닿지 않는 상태라 오랫동안 발견되지 않았어요.
코드를 그대로 두고 객체 만드는 쪽만 { SMALL, MEDIUM, LARGE } 순서로 바꿔도 동작했을 거예요. 다만 그건 “객체를 만들 때마다 순서를 신경써야 한다” 는 fragile 한 규칙을 도입하는 거라, 호출부가 늘어날수록 다시 깨질 가능성이 높아져요.
3. 수정하기
Object.keys 결과를 값 기준 으로 한 번 정렬해서, idx 와 시트 크기의 의미를 코드 안에서 일치시켰어요.
const sizes = (Object.keys(parsedSnapPoints) as Size[])
.sort((a, b) => parsedSnapPoints[b] - parsedSnapPoints[a])
snap point 의 값은 화면 상단에서의 y 좌표예요. y 가 클수록 시트가 더 아래에 있고, 즉 시트가 더 작다는 뜻이에요. 그래서 y 내림차순으로 정렬하면:
['SMALL', 'MEDIUM', 'LARGE']
idx 0 idx 1 idx 2
이제 MEDIUM 의 idx 가 1 이 되니까, DOWN 방향은 1 > 0 → true 로 SMALL (idx=0) 로 이동, UP 방향은 1 < 2 → true 로 LARGE (idx=2) 로 이동해요. MEDIUM 이 양쪽 끝이 아니라 가운데에 위치하면서 양방향 전환이 자연스러워졌어요.
이 PR 에서는 안드로이드/iOS 분기를 앱/웹 분기로 단순화한 다른 수정도 함께 들어갔는데, 그건 다른 글에서 다룰 예정이에요. snap point 정렬 한 줄이 이번 사건의 본질이었어요.
4. 재발 방지하기
이 사건을 거치면서 “객체 키 순서를 신뢰하지 않는다” 는 원칙이 좀 더 단단해졌어요.
첫째, Object.keys() / Object.values() / Object.entries() 의 순서를 비즈니스 로직 순서로 가정하지 않아요. spec 상의 보장은 “삽입 순서를 유지한다” 는 뜻이지, “삽입 순서가 도메인 의미 순서와 같다” 는 뜻이 아니에요. JSON 직렬화/역직렬화를 거치거나, 객체를 합치거나, 혹은 단순히 다른 사람이 객체를 다른 순서로 만들면 그 순간 깨져요.
둘째, idx 기반 분기를 작성할 땐 “정렬된 배열” 이라는 사실을 코드 자체에서 만들어요. 위 수정처럼 sort 를 한 번 거치면, 이후 로직은 idx 의 의미를 신뢰할 수 있어요. 외부에서 들어온 객체 순서에 의존하지 않으니까, 호출부가 늘어나도 안전해요.
셋째, “도메인 의미와 idx 의미가 일치하는가” 를 의심해요. 이번 케이스는 시트 크기가 도메인 의미였고, idx 는 그저 객체 키 순서였어요. 둘이 우연히 일치할 때까지 버그가 잠재하고 있다가, 어떤 시점에 호출부가 객체를 다른 순서로 만들면서 갑자기 표면에 떠올라요. 이런 종류의 버그는 발견된 시점 보다 심어진 시점 이 훨씬 멀리 있다는 점에서 골치 아파요.
작은 정렬 한 줄이 동작 전체를 결정짓는 케이스였어요. 코드를 단순하게 유지하려고 정렬을 빼는 게 매력적으로 보일 때도 있지만, 도메인 순서가 외부 입력 순서로 결정되는 건 위험해요. 한 번 정렬해두고 나머지 로직을 단순하게 짜는 쪽이 결국 더 안전해요.