최근 그라운드 플립이라는 땅따먹기 서비스를 개발하였다. 이 프로젝트는 사용자가 지나간 영역을 사용자의 영역으로 만들어주는 프로젝트이다. 걷기 장려를 목적으로 만든 서비스이기 때문에 걸어서 땅을 점령하는 것이 중요하다. Flutter 를 사용해 걷는 상태와 자동차를 탄 상태를 구분하는 로직을 개발한 과정을 소개하려고 한다.
그라운드 플립은 위 영상과 같이 사용자가 지나간 영역에 해당하는 사각형 만큼 지도에 색이 칠해진다.
그라운드 플립은 스토어에서 다운 받을 수 있습니다!!🔥🔥
써보시고 피드백 남겨주시면 너무 감사할 것 같습니다!!🙏🙏
많은 관심 부탁드립니다!
현재 서비스를 개발해 배포한 상황이다. 사용자 유입을 위해 이벤트를 열려고 하고 있다. 현재 우리 앱에는 사용자가 획득한 땅의 개수를 기반으로 하는 랭킹 시스템이 존재한다.
이 랭킹 시스템을 이용해서 1등 부터 3등 까지 상품을 주는 이벤트를 기획중이다. 상품이 걸린 만큼 땅을 차지하는 로직이 누구에게나 공정해야한다고 판단했다.
우리 서비스는 땅따먹기라는 게이미피케이션 요소를 도입하여 걷기나 달리기를 장려하는 앱이다. 하지만 상품을 위해 자동차나 킥보드를 사용해서 앱을 사용하면 단 시간에 엄청많은 땅을 획득할 수 있다. 이렇게 되면 걸어서 땅을 획득하는 사용자들이 공정한 경쟁을 할 수 없기때문에 자동차나 킥보드를 타고 사용하는 행위를 막아야했다.
그래서 클라이언트 측에서 걷기나 달리기를 구분하는 로직을 개발하기로 했다!
개발 환경은 Flutter 를 사용한다. 위치 정보, 즉 GPS
데이터를 얻어오기 위해 Flutter 의 location
패키지를 사용했다.
이 글은 걷기나 달리기 행동을 구분하는 로직에 대한 설명글이다. 때문에 땅따먹기 로직이나 다른 로직보다는 걷는 상태와 자동차를 탄상태를 구분하는 로직에 집중하여 설명한다.
처음에 어떻게 사람이 실제 움직이는 것과 탈 것을 사용하는 행위를 구분할지 생각해보았다. 그러다가 포켓몬 고의 예시를 살펴보니 10.5km/h 이상으로 움직이면 인식을 안한다고 들어서 이 방법을 적용해보기로 했다.
Flutter 의 location
패키지는 위치 정보를 반환 할 때 속도 값을 같이 반환한다. 반환값인 LocationData
를 살펴보면
/// In meters/second
///
/// Will be null if not available.
final double? speed;
speed
값이 있다. 이 speed
값은 m/s 로 나오기 때문에 10.5 km/h 와 비교하려면 3.6 을 speed 에 곱해야한다.(3.6 을 곱하면 km/h 로 환산 가능.) 그 결과 아래와 같이 코드를 작성했다.
void _trackUserLocation() {
_locationService.location.onLocationChanged.listen((newLocation) async {
if(newLocation.speed * 3.6 <= 10.5) {
await occupyPixel();
}
});
}
그 결과 테스트 하기 위해 버스와 지하철을 타고 앱을 실행해보았더니 적용하지 않았을 때 보다는 확실히 사람이 움직이는 행위를 구분해내었다.
하지만 문제점이 꽤나 많았다. 문제점 들은 다음과 같았다.
생각보다 땅이 획득되는 구간이 많아서 제대로 부정행위를 방지 할 수 없다고 판단해서 이 문제들을 개선하기로 했다!
우선 왜 위와 같은 문제점들이 발생했는지 원인을 분석 해보았다.
터널 진입시와 지하철 탑승시의 속도를 확인해보았다.
-1 이 나온다?
황당했다,,,
찾아보니 GPS 를 잘 잡지 못하는 경우에는 속도를 -1로 표시하는 것을 알았다. 속도가 -1 로 측정되니 항상 기준 속도인 10.5km 보다 작아서 땅이 획득 된 것이었다. 지하철의 경우도 GPS 가 이곳 저곳으로 튀는데 속도 값은 -1로 나오니 땅이 획득 되었다.
location
패키지에 의존하지 않고 직접 계산 해야할 필요가 생겼다.이 부분은 자동차의 신호 대기나 코너를 돌면서 속도가 줄어들며 발생한다. 속도가 10.5 km 아래로 줄어들면 그 순간에 땅이 점령되었다.
우선 GPS 가 약한 곳에서도 처리 할 수 있게 위치를 기반으로 직접 속도를 계산 해보았다.
위 그림처럼 좌표값과 좌표에 방문한 시간만 있으면 속도를 쉽게 구할 수 있다. 다만 여기서 위경도를 기준으로 거리를 구하는 것이 좀 복잡하다. 지구가 둥글기 때문에 일반 적인 평면을 기준으로 계산하면 안된다. 자세한 내용은 설명하면 너무 길어서 구글에 찾아보길 바란다.
그해서 두 좌표사이의 거리를 소요시간으로 나누면 속도를 구할 수 있다.
calculateSpeed(LocationData previousLocation, LocationData currentLocation) {
final timeInSeconds =
(currentLocation.time! - previousLocation.time!) / 1000;
final distanceInMeters = calculateDistance(
previousLocation.latitude!,
previousLocation.longitude!,
currentLocation.latitude!,
currentLocation.longitude!,
);
final speedInMps = distanceInMeters / timeInSeconds;
final speedInKmH = speedInMps * 3.6;
return speedInKmH;
}
double calculateDistance(
double startLat,
double startLng,
double endLat,
double endLng,
) {
const double earthRadius = 6371000;
final double dLat = degreesToRadians(endLat - startLat);
final double dLng = degreesToRadians(endLng - startLng);
final double a = (sin(dLat / 2) * sin(dLat / 2)) +
(cos(degreesToRadians(startLat)) *
cos(degreesToRadians(endLat)) *
sin(dLng / 2) *
sin(dLng / 2));
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
double degreesToRadians(double degrees) {
return degrees * (3.141592653589793 / 180);
}
위와 같은 코드로 직접 좌표간의 속도를 구했다.
우선 자동차가 느려지는 구간을 가려내기 전에 직접 자동차를 타서 속도 데이터를 수집해봤다. 약 30분 간의 주행 데이터이다. 가로 축은 시간, 새로 축은 속도(km/h) 를 의미한다.
그 결과 위와 같은 그래프가 그려진다. 위 빨간선이 10.5 km/h 에 해당하는데 이 선 밑으로 속도가 떨어지는 구간 마다 땅이 먹어진다.
이를 막기 위해 속도를 판단할 때 순간의 속도를 판단하는 것이 아니라 특정 구간의 속도를 판단해보기로 했다.
속도가 감소하는 부분을 살펴보면 보통 50초 사이에 감소되는 것을 발견했다. 이 구간을 같이 판단하면 일시적으로 속도가 줄어드는 구간을 판단할 수 있지 않을까 하는 생각이들었다.
GPS 는 1초 간격으로 수집되기 때문에 약 1초간격으로 위치와 속도가 기록된다. 그래서 내가 세운 로직은 현재 속도가 낮아도 최근 50초 사이의 속도 중에 사람이 내지 못할 속도가 기록되었다면 속도가 감소된 걸로 파악되게 로직을 생각해봤다.
사람이 내지 못할 속도는 25 키로로 정했다. 우사인 볼트 같은 사람의 순간 최고 속력은 빠르다고 40km/h 에 가깝다고 하지만 일반 사람들이나 마라톤 선수들의 평균속도가 20 키로 정도라고 하기 때문에 그것보다 살짝 높게 25키로를 기준으로 잡았다.
그 결과 위 그림처럼 속도가 기준 속도보다 낮은 경우에도 이전 50초 사이의 기록중에 25를 넘는 기록이 있기 때문에 자동차를 탄것으로 인식된다.
위 방법에서 가장 주의할 점은 얼마만큼의 구간을 같이 확인 할 것인지이다. 위에서는 50초로 예를 들었지만 구간의 길이만큼 성능이 차이날 것이라고 예상했다.
때문에 적당한 구간을 설정해주어야되는 어려움이 있었다. 결론 적으로 나는 50초를 기준으로 테스트를 해보았다.
또한 단 하나의 기준치를 넘는 기록을 가지고 판단하면 안된다. 왜냐하면 GPS가 가끔씩 튀는 구간이 있어서 걷는 중이라도 속도가 급작스럽게 높은 수치가 찍힐 수 있다. 때문에 적당한 threshold 를 잡아 적용해야한다. 나는 5개를 기준으로 잡았다.
앞서 단순히 현재 속도를 기준으로 판단하는 로직보다 걷는 것을 잘 구분한다.
하지만 여기에도 미쳐 발견하지 못한 문제가 많았다…
실제 테스트를 위해 버스를 탑승하였는데 일반적으로는 잘 구분이 되었다. 하지만 차가 막히니 차가 지나가는 땅을 모두 점령해버렸다…
이유는 간단하다. 차가 막히면 지속적으로 기준점보다 낮은 속도를 계속 유지한다. 때문에 걷기랑 똑같은 데이터로 측정된다.
결국 속도기반으로 구분하는 것은 한계가 있다라는 결론에 도달했다. 다른 데이터를 기준으로 구분 할 필요가 있었다.
어떤 데이터를 기준으로 나누어야 효과적으로 구분할 수 있을지 곰곰히 고민해봤다. 만보기가 떠올랐다. 만보기는 사람의 걸음수를 측정한다. 어떻게 측정할까? 사람이 움직일때마다 가속도 센서등을 사용하여 움직임을 측정한다. 이는 오직 사람이 걷거나 뛰면서 몸이 흔들리며 핸드폰이 흔들려야 측정된다.
자동차에 탄사람은 걷거나 뛰는 것 만큼 흔들리지 않는다!
Flutter 의 pedometer
패키지를 보면 PedestrianStatus
이라는 데이터를 제공한다. 이것이 걷는 중인지 정지상태인지 pedometer
에서 계산해주는 데이터이다. 이를 활용하면 쉽지만 꽤 정확하게 걷는 상태와 걷지 않는 상태를 구분해낼 수 있었다.
void _trackUserLocation() {
_locationService.location.onLocationChanged.listen((newLocation) async {
_locationService.updateCurrentLocation(newLocation);
if (walkingService.isWalking()) {
await occupyPixel();
}
});
}
class WalkingService {
static final WalkingService _instance = WalkingService._internal();
static const _walking = 'walking';
static const _stopped = 'stopped';
static const _unknown = 'unknown';
String pedestrianStatus = _stopped;
late Stream<PedestrianStatus> _pedestrianStatusStream;
WalkingService._internal();
factory WalkingService() {
return _instance;
}
init() {
// 걷기 상태가 변경될때마다 상태를 업데이트 한다.
_pedestrianStatusStream = Pedometer.pedestrianStatusStream;
_pedestrianStatusStream.listen((PedestrianStatus event) {
String status = event.status;
pedestrianStatus = status;
});
}
isWalking() {
if (pedestrianStatus == _unknown) {
return false;
} else {
return pedestrianStatus == _walking;
}
}
}
결과는 매우 잘 작동했다!
위에서 문제가 되었던
문제들 모두 해결되었다. pedometer
가 자동차 정도의 흔들림은 걷는 것으로 인식하지 않았다.
위 영상은 자동차를 타고 실제로 로직을 개선한 버전과 개선하지 않은 버전을 동시에 녹화하여 빨리 감기한 영상이다. 개선전 영상은 속도가 느려질때 마다 땅이 점령되는데 반해 개선 후 영상은 느려져도 점령되지 않는 것을 확인할 수 있다!
완벽할 것 같은 이 방법에도 문제점이 있었다.
차 안에서 강제로 핸드폰을 흔들면 걷기로 인식되어 땅이 점령된다…
일반적으로 차안에서 강하게 핸드폰을 흔들 사람은 별로 없을 것이라고 예상되지만 완벽한 공정성을 위해서는 눈감고 넘어갈 수 없는 문제였다.
사실 이 부분은 간단하게 해결할 수 있는 것이 위에서 속도 기반으로 구분하는 로직을 만들지 않았는가? 이 로직을 적절히 합치면 해결할 수 있다.
위 두 방법 모두 걷기로 인식하는 경우만 걷기로 인식한다.
몇가지 예를 통해 결과를 예측해보자!
이제 걷는 상태와 자동차등을 탄 상태를 매우 잘 구분하는 로직이 완성되었다. 이를 통해 보다 공정한 경쟁 시스템을 만들 수 있다!!!
걷는 상태와 자동차를 탄 상태를 구분하는 것은 처음에는 간단해보였지만 전혀 간단하지않은 작업이었다.
무엇보다 구분하는 기준을 생각해내는 것이 쉽지 많은 않았다. 기준을 떠올리려고 자동차를 타고 시간에 따른 속도도 측정해보고, 걸으면서 걷기의 특징도 생각하는 등 여러 작업을 해보았다. 심지어 꿈에서 까지 생각했었다. ㅋㅋㅋ
테스트 하는 것도 쉽지 않았다. 실제 걷는 것과 자동차를 타는 데이터가 필요하다보니 에뮬레이터로는 테스트 해 볼 수 없었다. 때문에 실제기기를 사용해서 실제로 걷고, 뛰고, 버스를 타고, 지하철을 타보고 테스트 할 수 밖에 없어서 시간이 오래 걸렸다. 또 많이 걸어서 몸도 피곤했다…
그래도 마지막에 만족할만한 성능으로 개선해서 만족스러운 작업이었던 것 같다. 만보기 기반으로 해결 될 때 고민 했던 것에 비해 너무 쉽게 해결되어서 좀 좌절했지만 결국 고민 했던 부분들이 헛수고 아니라 같이 합쳐지게 되어 되게 뿌듯했던 것 같다.
늘 백엔드에서 서버들끼리 최적화 방법만 생각하다가 실제 사람의 행동을 구분하는 로직을 짜니 재미있고 새로웠다!
iOS 기준이다. 위 표는 터널에 들어갔을때 속도를 나타낸 그래프이다. 터널에 들어가면 GPS 를 못 잡아서 터널에 들어갈때 속도를 나올때 까지 표시 해주는 것 같다. 위 그래프도 터널에 들어간 순간 일자로 쭉 이어진다.
신기한 개발 경험 공유 감사드립니다 :)