떠오를 때마다 적어둬요

바텀시트가 SMALL 로 줄어들지 않아요.

Object.keys 의 삽입 순서를 믿으면 안 되는 이유예요.

지도 화면의 바텀시트가 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. 재현하기

각 조합을 표로 그려보면 한눈에 보여요.

sheetSizedirectioncurrentIdx기대실제
MEDIUMDOWN0SMALL 로 이동(이동 안 함)
MEDIUMUP0LARGE 로 이동LARGE
LARGEDOWN1MEDIUM 로 이동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 는 그저 객체 키 순서였어요. 둘이 우연히 일치할 때까지 버그가 잠재하고 있다가, 어떤 시점에 호출부가 객체를 다른 순서로 만들면서 갑자기 표면에 떠올라요. 이런 종류의 버그는 발견된 시점 보다 심어진 시점 이 훨씬 멀리 있다는 점에서 골치 아파요.

작은 정렬 한 줄이 동작 전체를 결정짓는 케이스였어요. 코드를 단순하게 유지하려고 정렬을 빼는 게 매력적으로 보일 때도 있지만, 도메인 순서가 외부 입력 순서로 결정되는 건 위험해요. 한 번 정렬해두고 나머지 로직을 단순하게 짜는 쪽이 결국 더 안전해요.