← Blog

GPS는 종종 거짓말을 한다 — 맛도리 동네 인증 정확도 트러블슈팅

JunGyu Kim · · 11분 읽기React NativeExpo위치기반트러블슈팅
목차

1. 문제 상황: "여기 우리 동네 맞는데요"

맛도리의 핵심 신뢰 장치는 GPS 동네 인증입니다. 답변자가 추천하려는 동네에 실제로 있는 상태에서 현장 인증을 해야 현지인 자격이 생기고, 서버는 좌표를 행정동 코드로 변환한 즉시 원본 좌표를 폐기합니다. 이 설계 자체는 이전 개발기에서 다뤘으니, 이번 글은 그 다음 이야기를 하려고 합니다. 이 인증을 실제 기기에서 돌려보면 어떤 방식으로 어긋나는지, 그리고 그 어긋남을 어떻게 다뤘는지에 대한 기록입니다.

개발 중과 출시 전후로 테스트하며 마주친 증상은 대체로 이런 것들이었습니다. 집에서 인증했는데 옆 동네로 판정되는 경우가 있었습니다. 특히 실내 깊숙한 곳이나 고층 건물에서 시도하면 판정이 흔들렸습니다. 안드로이드 에뮬레이터에서는 위치를 서울로 설정해 두었는데도 미국 좌표가 잡혀서 "한국 안에서만 인증할 수 있어요"라는 안내가 떴습니다. 어떤 날은 위치 확인 버튼을 누르고 스피너가 한참을 돌았습니다. 실기기 측정에서 getCurrentPositionAsync가 30초를 넘겨서야 좌표를 돌려준 적도 있습니다. 그리고 해외에 다녀온 직후 앱을 켜면, 지도가 떠나온 도시를 잠깐 보여주는 문제도 있었습니다.

이 증상들은 각각 원인이 다르지만, 뿌리는 하나였습니다. 저는 처음에 GPS 좌표를 "정답"으로 취급하고 파이프라인을 설계했습니다. 좌표를 받으면 행정동이 나오고, 행정동이 나오면 인증이 끝난다는 직선적인 흐름이었습니다. 그런데 GPS는 정답을 주는 장치가 아니었습니다. 상황에 따라 수십에서 수백 미터씩 어긋나는 확률적인 값을 주는 장치였고, 늦게 주거나 아예 안 주거나 심지어 며칠 전의 값을 주기도 했습니다. 이 글은 그 사실을 받아들이고 나서 인증 플로우를 어떻게 다시 짰는지에 대한 이야기입니다.

2. 원인 추적: 점 하나로 폴리곤을 고르는 일의 위태로움

먼저 옆 동네 판정 문제부터 추적했습니다. 맛도리의 동네 판정은 카카오 로컬 API의 coord2regioncode를 사용합니다. 좌표 하나를 넣으면 그 점이 속한 행정동 정보를 돌려주는 API입니다. 여기서 중요한 건, 이 변환에는 오차라는 개념이 아예 없다는 점입니다. 점은 반드시 어느 한 폴리곤 안에 떨어지고, API는 그 폴리곤을 확신에 찬 얼굴로 돌려줍니다. GPS 좌표에 100미터 오차가 있다는 사정은 어디에도 반영되지 않습니다.

한편 서울 도심의 행정동은 생각보다 작습니다. 폭이 몇백 미터밖에 안 되는 동도 흔합니다. 그러니 사용자가 행정동 경계에서 백여 미터 안쪽에 서 있기만 해도, GPS 오차만으로 점이 경계 너머에 찍힐 수 있습니다. 도심 고층 빌딩 사이에서는 위성 신호가 건물에 반사되어 도착하는 멀티패스 때문에 오차가 더 커지고, 실내에서는 GPS 대신 Wi-Fi 기반 측위로 대체되면서 좌표가 건물 단위로 튀기도 합니다. "집에서 인증했는데 옆 동네가 나온다"는 증상은 버그가 아니라, 점 하나로 폴리곤을 고르는 구조가 경계 근처에서 필연적으로 만드는 결과였습니다.

에뮬레이터 미국 좌표 문제는 다른 갈래였습니다. expo-location의 정확도 옵션 중 Balanced는 네트워크 기반 위치를 허용하는데, 이 경로는 캐시된 값을 돌려줄 수 있습니다. 에뮬레이터에서 위치를 바꿔도 새 값이 반영되지 않고 기본 좌표(미국)가 계속 나오던 원인이 이것이었습니다. 이 경험 덕분에 동네 인증에는 Accuracy.High를 쓰기로 확정했습니다. GPS를 우선하는 모드라 응답은 조금 느려질 수 있지만, 행정동이라는 좁은 단위를 판정하는 일에는 애초에 높은 정확도가 필요했습니다.

마지막으로 "떠나온 도시가 잠깐 보이는" 문제는 캐시의 기본값을 의심하게 만들었습니다. expo-location의 getLastKnownPositionAsync는 옵션을 주지 않으면 최대 나이를 사실상 무한대로 해석합니다. 안드로이드와 iOS 네이티브 구현을 직접 열어 확인해 보니, 옵션이 없을 때 며칠 묵은 좌표도 그대로 돌려주는 동작이 맞았습니다. 출장에서 돌아온 사용자의 기기에는 떠나온 도시의 좌표가 마지막 위치로 남아 있고, 앱은 그걸 "지금 여기"로 믿어버린 것입니다.

3. 해결 1 — 판정을 사용자에게 넘기다: 주변 동 후보 방식

경계 오판 문제의 해결 방향을 정하는 데 시간이 걸렸습니다. 처음에는 정확도를 더 끌어올리는 쪽을 고민했습니다. 여러 번 측위해서 평균을 내거나, 정확도 값이 좋아질 때까지 기다리는 방식입니다. 그런데 이 방향은 끝이 없습니다. 오차를 줄일 수는 있어도 없앨 수는 없고, 경계에서 10미터 안쪽에 서 있는 사용자는 어떤 평균으로도 구제되지 않습니다. 그래서 방향을 바꿨습니다. 기계가 하나를 확정하는 대신, 기계는 후보를 만들고 최종 판정은 그 동네를 가장 잘 아는 사람, 즉 사용자 본인에게 넘기기로 했습니다.

구현은 이렇습니다. 클라이언트가 좌표를 보내면 서버는 그 좌표 하나만 변환하지 않고, 중심점과 8방향으로 약 500미터씩 이동한 지점까지 총 아홉 개 지점을 행정동으로 변환합니다. 그리고 중복을 제거한 동 목록을 후보로 돌려줍니다. 사용자 눈에는 "지금 위치 주변 동네예요 — GPS가 살짝 어긋날 수 있으니 실제 내 동네를 골라 주세요"라는 문구와 함께 동 이름 칩들이 보이고, 자기 동네를 골라 인증을 확정합니다.

후보 샘플링 (서버, 좌표는 조회 즉시 폐기)
  중심(0,0) + 8방향 오프셋 → 9개 지점을 각각 행정동으로 변환
  위도 오프셋 0.0045도, 경도 오프셋 0.0057도 ≈ 서울 위도 기준 500m
  중복 동 제거 → 후보 목록 (중심 판정 동이 항상 첫 번째)

사소해 보이지만 위도와 경도의 오프셋 값이 다른 데에도 이유가 있습니다. 경도 1도의 실제 거리는 위도에 따라 달라지기 때문에, 같은 500미터를 만들려면 서울 위도 기준으로 경도 쪽 각도를 더 크게 잡아야 합니다. 이 값을 같게 두면 후보 탐색 범위가 남북으로는 넓고 동서로는 좁은 타원이 됩니다.

다만 선택지를 열어주는 순간 새로운 구멍이 생깁니다. 후보를 클라이언트가 고르게 했으니, 조작된 요청이 엉뚱한 동을 보내올 수 있습니다. 그래서 확정 요청에서 사용자가 고른 동이 중심 판정 동과 다르면, 서버가 현재 좌표 주변 후보를 다시 계산해서 고른 동이 그 안에 있는지 재검증합니다. 실제로 그 근처에 있어야만 고를 수 있는 구조라, 멀리 있는 동네를 사칭하는 경로는 막혀 있습니다. 또 하나, 후보 조회 시점에 잡은 좌표를 확정 단계에서 그대로 재사용합니다. 두 단계 사이에 사용자가 이동하면 재검증 기준이 달라져 멀쩡한 선택이 탈락할 수 있는데, 같은 좌표를 쓰면 후보를 만든 기준과 검증하는 기준이 일치합니다. 덧붙여 가짜 위치 앱 사용 여부를 나타내는 신호도 요청에 함께 실어 서버로 보내, 운영 판단에 활용할 수 있게 했습니다.

4. 해결 2 — 오래된 캐시와 역외 좌표: 기본값을 의심하기

캐시 문제는 두 겹으로 손봤습니다. 첫째, getLastKnownPositionAsync에 최대 나이 5분과 최대 오차 1킬로미터라는 명시적인 조건을 걸었습니다. 5분은 "방금 거기 있었다"가 참이라고 볼 수 있는 범위로 잡은 값이고, 이 조건을 넘는 캐시는 버리고 실측으로 넘어갑니다. 오차 조건도 필요했습니다. 셀 타워 기반 위치는 수 킬로미터씩 어긋날 수 있는데, 그런 좌표를 골목 단위 줌으로 그려주면 "여기가 당신 위치"라는 거짓말이 되기 때문입니다.

둘째, 한반도 좌표 대역 검사를 후보마다 따로 적용하도록 고쳤습니다. 이건 실제로 겪은 버그였습니다. 처음 구현은 캐시 좌표가 있으면 캐시를, 없으면 실측을 고른 뒤 마지막에 한 번만 대역 검사를 했습니다. 그러자 역외 캐시 하나가 실측 시도 자체를 봉인해 버렸습니다. 캐시가 존재하니 실측 경로는 아예 타지 않고, 그 캐시가 대역 밖이니 최종 결과는 "위치 없음"이 되는 것입니다. 귀국 당일의 기기나 미국 기본 좌표가 남은 에뮬레이터에서 GPS가 멀쩡한데도 위치를 못 잡는 증상이 정확히 이 경로였습니다.

[버그] candidate = lastKnown ?? getCurrent()
       → 마지막에 한 번만 한반도 대역 검사
       → 역외 캐시가 있으면 실측 기회 자체가 사라짐
 
[수정] lastKnown을 검사해서 탈락하면 → getCurrent()를 다시 검사
       후보마다 따로 판정해야 다음 후보에게 기회가 간다

같은 대역 검사는 서버에도 있습니다. 요청 스키마 차원에서 위도 3339도, 경도 124132도 밖의 좌표는 거부합니다. 클라이언트와 서버가 같은 대역 상수를 쓰기 때문에, 클라이언트 검사를 우회해도 서버에서 같은 기준으로 걸러집니다. 에뮬레이터의 기본 미국 좌표가 인증 요청으로 이어지는 일은 이 이중 검사가 막아줍니다.

5. 해결 3 — 권한 거부와 타임아웃: 실패의 어휘를 나누기

측위가 느리거나 실패할 때의 UX는 생각보다 설계할 것이 많았습니다. 가장 먼저 정리한 건 타임아웃의 위치입니다. 권한 요청 다이얼로그가 떠 있는 시간은 측위 시간에 포함하지 않습니다. 사용자가 다이얼로그를 읽고 있는 동안 타임아웃이 터지면 "위치를 못 찾았다"는 안내가 나가는데, 이건 명백한 거짓말이기 때문입니다. 타임아웃은 권한이 허용된 다음, 순수하게 측위를 기다리는 구간에만 겁니다.

다음으로 부딪힌 건 더 미묘한 문제였습니다. 타임아웃을 걸면 실패와 대기가 구분되지 않습니다. 측위 함수가 null을 돌려줄 때, 그것이 "권한이 거부되어 영영 못 쓴다"는 뜻인지 "아직 안 왔을 뿐"인지 호출자가 알 수 없게 됩니다. 안드로이드 실기기에서 30초 만에 좌표가 도착한 사례가 있었는데, 그 좌표는 버릴 이유가 전혀 없는 멀쩡한 값이었습니다. 일괄 타임아웃을 걸었다면 그 좌표는 버려졌을 것입니다.

그래서 타임아웃을 기본값으로 두지 않고, 기다림을 끊어야만 하는 쪽이 명시적으로 요구하는 옵션으로 설계했습니다. 사용자가 버튼을 눌러 놓고 스피너를 보고 있는 화면에서는 상한을 겁니다. 무한 스피너는 재시도조차 막기 때문입니다. 반면 홈 화면의 초기 지도 카메라처럼 늦게 온 좌표도 여전히 쓸모 있는 소비처에서는 상한을 걸지 않습니다. 대신 4초가 지나면 GPS를 취소하지 않은 채로 폴백 체인을 함께 시작합니다. 인증된 내 동네가 있으면 그리로, 없으면 관심지역으로 카메라를 옮기되, 폴백이 빈손인 신규 사용자에게는 늦게 도착한 GPS가 여전히 유일한 답이 됩니다. 기다림을 끊는 것과 좌표를 버리는 것은 다른 결정이라는 걸 이 과정에서 배웠습니다.

권한 거부와 각종 실패는 사유별 문구로 나눴습니다. 권한이 거부되면 설정에서 허용하는 길을 안내하고, 대역 밖 좌표면 한국 안에서만 인증할 수 있다고 알리고, 두 단계 사이의 이동으로 재검증이 탈락하면 다시 시도해 달라고 안내합니다. 여기에 자잘한 발견이 하나 있었는데, 인증 시트가 모달로 떠 있는 동안에는 토스트가 시트 뒤에 가려진다는 점입니다. 실패 사유를 토스트로만 보여주면 사용자는 아무 일도 일어나지 않은 것처럼 느낍니다. 그래서 같은 문구를 시트 안 인라인 텍스트로도 병행 표시하도록 고쳤습니다.

6. 해결 4 — 좌표를 저장하지 않으면서 재시도를 설계하기

맛도리는 인증 좌표를 저장하지 않습니다. 행정동 코드로 변환한 즉시 폐기하는 것이 프라이버시 설계의 축이고, 이 원칙은 지킬 가치가 있었습니다. 그런데 트러블슈팅 관점에서는 대가가 있습니다. "옆 동네로 판정됐어요"라는 제보를 받아도 서버에는 그때의 좌표가 없으니, 어떤 입력이 어떤 판정을 만들었는지 재현할 방법이 없습니다. 로그를 뒤져서 원인을 찾는 통상의 경로가 원천적으로 막혀 있는 셈입니다.

그래서 디버깅 정보를 서버가 아니라 화면에 두기로 했습니다. 개발 빌드에 한해, 인증 실패 문구 아래에 GPS가 실제로 돌려준 좌표를 소수점 넷째 자리까지 병기합니다. 에뮬레이터의 위치 설정이 안 먹고 있는 상황인지, 좌표는 맞는데 판정이 어긋난 상황인지가 화면만 보고 즉시 구분됩니다. 프로덕션 빌드에서는 이 표시가 완전히 빠지므로 폐기 원칙과 충돌하지 않습니다.

재시도 전략도 이 제약 위에서 정리했습니다. 서버가 기억하는 게 없으니 재시도는 언제나 처음부터입니다. 실패 후 다시 시도하면 잡아둔 좌표와 후보 목록을 전부 리셋하고 새로 측위합니다. 낡은 좌표 위에서 재시도해 봐야 같은 실패를 반복할 뿐이고, GPS는 시간이 지나면 더 나은 값을 주는 경우가 많기 때문에 새 측위가 곧 가장 좋은 재시도이기도 합니다. 과도한 반복 호출은 서버 쪽 호출 한도가 막아주지만, 그 전에 사유별 안내 문구가 "잠시 후 다시" 시도할 이유를 설명해 주는 쪽이 먼저라고 생각했습니다.

7. 배운 점

이번 트러블슈팅을 관통하는 교훈은 하나로 요약됩니다. GPS를 정답 생성기가 아니라 후보 생성기로 취급하라는 것입니다. 좌표 하나를 받아 행정동 하나를 확정하는 구조는 경계 근처에서 반드시 틀립니다. 반면 기계가 후보를 만들고 사람이 확정하는 구조는, 오차를 없애는 대신 오차와 함께 사는 법을 택합니다. 정확도를 끌어올리는 어떤 기법보다 이 구조 전환 하나가 체감 품질을 크게 바꿨습니다.

두 번째 교훈은 실패의 어휘를 나누라는 것입니다. "위치를 쓸 수 없다"와 "아직 모른다"는 다른 상태인데, 타임아웃과 null은 이 둘을 쉽게 뭉갭니다. 소비처마다 어느 쪽이 필요한지를 따져 명시적 대기 화면에만 상한을 걸고, 암시적 소비처는 폴백과 병행하며 늦은 좌표를 살리는 식으로 나눈 뒤에야 UX가 정돈됐습니다. 세 번째는 캐시의 기본값을 의심하라는 것입니다. 라이브러리의 기본 동작이 며칠 묵은 좌표를 돌려줄 수 있다는 사실은 네이티브 소스를 직접 열어보고서야 확신할 수 있었습니다.

마지막으로, 프라이버시 설계에는 디버깅 비용이 따라온다는 점을 기록해 두고 싶습니다. 좌표를 저장하지 않는 결정은 옳았지만, 그 결정은 서버 로그 기반 디버깅을 포기한다는 뜻이기도 했습니다. 개발 빌드 한정 표시처럼 원칙을 훼손하지 않는 보완 장치를 처음부터 함께 설계했다면 초기 삽질이 훨씬 줄었을 것입니다. 위치 기반 기능을 만들 계획이 있다면, GPS가 종종 거짓말을 한다는 전제에서 시작하시기를 권합니다. 그 전제 위에서 설계한 플로우가 결국 사용자에게 정직한 플로우가 됐습니다.

광고

관련 글