OneStep 파헤치기 (2) - GPS는 왜 튀는가 (임계값 가드로 이상치 걸러내기)

troubleshooting·35 min read·2026-08-21(Updated 2026-08-22)

이 글은 OneStep 프로젝트에서 작성되었습니다.

목차

한 줄 요약

GPS 원시 좌표가 튀는 원인을 정리하고, 칼만 필터 대신 정확도·거리 임계값 가드로 이상치를 걸러낸 이유와 구현을 정리한다.

왜 (배경/문제 상황)

1편에서 받은 위치는 원본(raw) 데이터라 오차와 노이즈가 그대로 섞여 있다고 언급했다. 실제로 산책 화면에서 좌표를 로그로 찍어보면, 사용자가 가만히 서 있어도 좌표가 순간적으로 수십 미터씩 튀는 경우가 있었다. 도착 판정 로직이 이 튄 좌표를 그대로 믿으면, 실제로는 도착하지 않았는데 도착으로 처리되거나(오탐), 반대로 실제 도착했는데도 계속 미도착으로 남는(누락) 오작동이 생긴다. 이 문제를 어떻게 다뤘는지 정리한다.

본문

1. GPS 좌표가 튀는 이유

  • Multipath: 건물 벽이나 지형에 반사된 신호가 섞여 들어와, 실제보다 먼 거리를 이동한 것처럼 계산되는 현상.
  • 도심 협곡(urban canyon): 고층 건물 사이에서는 일부 위성 신호가 가려져 확보되는 위성 수가 줄고, 그만큼 정확도가 급격히 떨어진다.
  • Cold start: GPS 모듈이 막 켜진 직후에는 아직 충분한 위성 신호를 확보하지 못해 초기 좌표의 정확도가 낮다.
  • location.accuracy는 “오차가 이 값 이내로 보장된다”는 값이 아니라, 실제 위치가 해당 반경(미터) 안에 있을 확률이 68% 수준이라는 신뢰구간 값이다. 즉 accuracy가 낮게(정확하게) 나온 순간에도, 드물게 크게 튀는 좌표가 섞여 들어올 수 있다.

2. 칼만 필터 vs 임계값 가드

칼만 필터는 이전 상태와 새 관측값을 가중 평균해 부드러운 궤적을 만들어내는 표준적인 노이즈 대응 기법이다. 하지만 OneStep의 산책 챌린지에서는 칼만 필터 대신 단순한 임계값 가드를 택했다.

  • 목표가 “부드러운 궤적을 그리는 것”이 아니라 “도착 여부를 정확히 판정하는 것”이었다. 궤적이 살짝 삐뚤어도 상관없지만, 순간이동 수준의 명백한 이상치가 도착 판정에 섞이면 안 된다 — 이 문제에 맞는 건 스무딩이 아니라 이상치 제거였다.
  • 타겟 사용자(고립·은둔 청년)의 실제 이동 반경이 좁아, 칼만 필터의 튜닝 비용(프로세스·관측 노이즈 공분산 추정) 대비 얻는 이득이 크지 않았다. 반면 임계값 가드는 조정할 파라미터가 정확도·거리(속도) 두 가지뿐이라 구현과 검증이 훨씬 빨랐다.

3. 임계값 가드 구현

가드는 두 단계로 구성했다.

정확도 가드: location.accuracy 값이 기준치보다 크면, 애초에 신뢰도가 낮은 값이므로 버린다.

private const val MAX_ACCURACY_METERS = 30f

fun isAccurate(location: Location): Boolean {
    return location.accuracy <= MAX_ACCURACY_METERS
}

속도(거리) 가드: 정확도 기준을 통과해도, 직전 좌표와의 거리를 경과 시간으로 나눈 속도가 도보로는 불가능한 값이면 이상치로 간주해 버린다.

private const val MAX_WALKING_SPEED_MPS = 20_000f / 3600f // 시속 20km를 m/s로 환산

fun isPlausibleMove(previous: Location, current: Location): Boolean {
    val distanceMeters = previous.distanceTo(current)
    // Location.time은 시스템 시각이라 시계 보정으로 거꾸로 흐를 수 있다.
    // elapsedRealtimeNanos는 부팅 후 단조 증가하는 값이라 경과 시간 계산엔 이쪽을 써야 한다.
    val elapsedSeconds = (current.elapsedRealtimeNanos - previous.elapsedRealtimeNanos) / 1_000_000_000f
    if (elapsedSeconds <= 0f) return false

    val speedMps = distanceMeters / elapsedSeconds
    return speedMps <= MAX_WALKING_SPEED_MPS
}

두 가드를 모두 통과한 좌표만 “신뢰할 수 있는 현재 위치”로 갱신한다.

class LocationFilter {
    private var lastAccepted: Location? = null

    fun accept(location: Location): Location? {
        if (!isAccurate(location)) return null

        val previous = lastAccepted
        if (previous != null && !isPlausibleMove(previous, location)) {
            return null
        }

        lastAccepted = location
        return location
    }
}

LocationCallback 안에서 원시 좌표를 바로 상태에 반영하지 않고, 이 필터를 거친 값만 반영한다.

val locationFilter = LocationFilter()

val locationCallback = object : LocationCallback() {
    override fun onLocationResult(result: LocationResult) {
        val raw = result.lastLocation ?: return
        val accepted = locationFilter.accept(raw) ?: return
        // accepted.latitude, accepted.longitude로 도착 판정 로직 수행
    }
}

4. 도착 판정에 가드가 필요했던 이유

도착 판정은 “현재 좌표와 목적지 좌표 사이 거리가 반경 R 이내인가”로 계산한다. 여기에 필터링되지 않은 원시 좌표를 그대로 쓰면, 실제로는 목적지 밖에 있는데 튄 좌표 하나가 반경 안으로 순간 진입해 도착 처리되는 오탐이 생긴다. 반대로 실제 도착 좌표가 튀어서 반경 밖으로 나가면 도착이 누락된다. 필터를 거친 좌표만 판정에 쓰면 이 두 오작동을 크게 줄일 수 있다.

5. 직접 눌러보기 — 필터링 전후 비교

아래 데모는 위 isAccurate + isPlausibleMove 가드를 그대로 재현한 것이다. 왼쪽은 필터를 거치지 않은 원시 좌표, 오른쪽은 두 가드를 통과한 좌표만 남긴 결과다. 같은 노이즈 데이터를 두 방식으로 그려 비교한다.

📡 원시 좌표UNFILTERED
수신: 0총거리: 0.0m
✅ isAccurate + isPlausibleMove 통과FILTERED
채택: 0총거리: 0.0m정확도 탈락: 0속도 탈락: 0
MAX_ACCURACY_METERS = 30f   MAX_WALKING_SPEED_MPS = 20_000f / 3600f (≈5.56 m/s)

예제

전체 흐름을 하나로 합치면 다음과 같다.

class WalkLocationManager(
    private val destination: Location,
    private val arrivalRadiusMeters: Float,
) {
    private val filter = LocationFilter()
    private var hasArrived = false

    fun onNewLocation(raw: Location): Boolean {
        val accepted = filter.accept(raw) ?: return false
        val isWithinRadius = accepted.distanceTo(destination) <= arrivalRadiusMeters
        if (!isWithinRadius || hasArrived) return false

        hasArrived = true
        return true
    }
}

onNewLocation()이 true를 반환하는 시점에 도착 처리 로직(챌린지 완료 API 호출 등)을 실행한다. hasArrived 없이 반경 안 여부만 반환하면, 도착 반경 안에서 잡히는 좌표마다 매번 true가 나와 완료 API가 중복 호출된다.

주의사항

  • 정확도·속도 임계값은 실측 로그로 튜닝해야 한다. 이 글의 30m·시속 20km는 예시 값이니, 서비스의 사용자 이동 패턴(도보/자전거/차량)에 맞게 조정해야 한다.
  • 가드를 통과하지 못한 좌표를 계속 버리기만 하면, 사용자가 실외로 나가 정확도가 급격히 개선되는 상황에서도 lastAccepted가 오래된 값에 머물러 있을 수 있다. 일정 시간(예: 10초) 이상 새 좌표를 받지 못하면 가드를 완화하는 등의 예외 처리가 실무에서는 추가로 필요하다.
  • hasArrived 플래그는 클라이언트가 재시작되면 초기화된다. 완료 API 쪽에도 idempotency key나 “이미 완료된 챌린지는 재요청을 무시” 같은 방어 로직을 둬야, 클라이언트만 믿고 중복 보상을 지급하는 일을 막을 수 있다.
  • 이 가드는 “명백한 이상치 제거”만 담당할 뿐 궤적을 부드럽게 만들어주지는 않는다. 지도에 이동 경로를 시각적으로 예쁘게 그려야 하는 서비스라면 칼만 필터나 이동평균 같은 스무딩 기법이 별도로 필요하다.

참고자료