[철학과 개발] #23 동네 사람인지 알아야 도움 줄 수 있다! - 지역인증

in hive-196917 •  4 years ago  (edited)

P & D

Research and Development가 아니라 Philosophy and Development의 P & D 입니다. 개발글에 있어서 새로운 시도를 하고 있습니다. 그저 개발기를 적어 내려가는 것이 아니라, 어떤 철학을 가지고 개발을 해나가고 있는지, 어떤 시도를 했는지, 개발은 어떻게 하는지를 적어 내려갑니다. 이번 시리즈는 도움을 주고 받는 방법 - 이타인클럽의 철학과 개발 과정을 다룰 것입니다.

이전글 - [철학과 개발] #22 적절한 툴을 사용하자! Firebase Storage


철학: 동네 사람인지 알아야 도움 줄 수 있다

사람들은 도움 주고 받을 때 가장 우려하는 부분이 있습니다.

  • 내가 엉뚱한 사람들 도와주는 거 아닌가?
  • 저 사람이 나를 부려먹으라고 하는거 아닌가?

이러한 현상은 네이버 지식인의 문제점으로 부각되었습니다. 특히 학생들이 숙제를 네이버에 올려서 대신 해결하는 사례도 있습니다.

도와주는 선의의 마음을 이용해 먹으려는 사람에게 왠지 거부감이 생기고, 도와 주고 싶은 마음이 싹 사라지게 됩니다. 이 문제는 도움뿐만 아니라 여러 분야에서 선의의 행동을 악용하려는 모든 경우에 해당하는 얘기입니다.

이러한 남용과 불신을 깨드린 것이 당근마켓이 적용한 지역인증입니다. 자신의 지역을 인증하고, 동네 사람들하고만 거래할 수 있도록 제한을 둔 것이 오히려 사업을 더 크게 확장시켰습니다.

단순히 생각하면, 지역기반으로만 거래를 하면 사업 확장이 어려워 보이는데, 각 지역마다 신뢰하는 커뮤니티가 만들어진다면, 그 사업은 동네 곳곳으로 퍼져 나갈 수 있는 큰 사업이 되는 것입니다.

당근마켓도 이를 고려하여, 단순히 중고물품 거래에 그치는 것이 아니라, 본심은 각 지역 커뮤니티에서 발생하는 모든 서비스를 거래할 수 있는 플랫폼 사업을 계획하고 있습니다. 당근마켓이 우리 생활 깊숙히 자리잡을 수도 있는 야심찬 계획입니다.

따라서, 자신이 살고 있는 지역을 인증하는 것은 결코 사소한 일이 아닙니다. 지역 기반서비스의 핵심입니다.

당근마켓이 취하는 방식은 사용자의 지역을 선택하고, 스마트폰의 위치 정보와 일치하는지 인증하는 방식입니다. 이러한 지역 인증만으로는 굳건한 신뢰를 형성하기 어렵습니다. 맘만 먹으면 자신이 사는 위치가 아닌 곳을 의도적으로 인증하여 속일 수도 있습니다. 하지만 그렇게 할 동기가 별로 없는 것이죠.

어쨌든, 지역 커뮤티니에서 신뢰도를 향상 시키기 위해서 기본은 지역인증입니다. 블록체인 기술로 인증 내역을 기록, 관리, 비용 부과를 하게 되면 더욱 높은 신뢰도를 형성할 수가 있습니다.

개발: 지역 인증 구현

당근마켓과 같은 지역인증 기능을 구현해 보겠습니다. 앞서 잠시 소개했듯이 기능적으로는 별거 없습니다. 사용자 기기의 위치 정보를 바탕으로 지역을 인증하게 하면 됩니다. 당근마켓 방식과는 조금 다릅니다.

UI 구성

최대 2개의 지역을 입력할 수 있게 했습니다.
image.png

위 화면에서 "위치" 아이콘을 클릭하면 아래와 같이 지도를 표시하고 인증하게 합니다. 지도의 중심은 항상 사용자 기기의 GPS 위치로 고정됩니다.
image.png

지도 상에 위치 표시

위치를 표시하기 위해서 "react-native-mpas" 패키지를 사용합니다.
https://github.com/react-native-community/react-native-maps

또 필요한 패키지가 있습니다. 지도에서 얻는 정보는 GPS 위치 정보입니다. 즉 위도, 경도 값입니다. 그러나 우리가 필요한 것은 해당 위치의 주소입니다. 위도, 경도값에서 주소정보로 변환하기 위해 "geocoding" 패지지를 사용합니다.
https://github.com/marlove/react-native-geocoding

먼저, 기기의 현재 위치 정보를 읽어서 세팅하는 부분과, geocoding을 초기화 하는 부분입니다.

// LocationScreen.js

import { GEOCODING_API_KEY } from 'react-native-dotenv';

// init location
  useEffect(() => {
    console.log('LocationScreen');
    // get current latitude and longitude
    const watchId = Geolocation.watchPosition(
      pos => {
        setLatitude(pos.coords.latitude);
        setLongitude(pos.coords.longitude);
      },
      error => setError(error.message)
    );

    // init geocoding
    initGeocoding();

    // unsubscribe geolocation
    return () => Geolocation.clearWatch(watchId);
  }, []);

  const initGeocoding = () => {
    Geocoder.init(GEOCODING_API_KEY, { language: language }); 
    // get intial address
    Geocoder.from(latitude, longitude)
    .then(json => {
      const addrComponent = json.results[0].address_components[1];
    })
    .catch(error => console.warn(error));
  };

구글 맵을 화면에 표시하는 내용입니다.

const showMap = () => {
    if (Platform.OS === 'android') {
      return (
        <View>
          <MapView
            style={{ height: 280, marginBottom: mapMargin }}
            provider={PROVIDER_GOOGLE}
            showsMyLocationButton
            mapType="standard"
            loadingEnabled
            showsUserLocation
            region={{
              latitude: latitude,
              longitude: longitude,
              latitudeDelta: latitudeDelta,
              longitudeDelta: longitudeDelta
            }}
            onRegionChange={onRegionChange}
            onRegionChangeComplete={onRegionChangeComplete}
            onPress={e => onMapPress(e)}
            onMapReady={() => setMapMargin(0)}
          >
            <Marker
              coordinate={{ latitude, longitude }}
            />
          </MapView>
          <View style={{ marginTop: 20 }}>
            <View style={{ flexDirection: 'row', justifyContent: 'flex-start', marginBottom: 20 }}>
              <Text style={{ paddingLeft: 5, fontSize: 20 }}>{t('LocationScreen.currentAddress')}</Text>
              <Text style={{ fontSize: 20, fontWeight: 'bold' }}>{address.display}</Text>
            </View>
            <Button
              title={t('LocationScreen.verify')}
              type="solid"
              onPress={onVerify}
            />
          </View>
        </View>  
      );
    }

안도르이드에서는 구글맵을 사용하고, 아이폰은 애플맵을 사용하기 때문에, 맵 표시가 두개로 구별됩니다. 구글맵 property에는 내 위치를 화면에 표시하는 부분, 현재 위치 버튼, 맵이 변경되었을 때 호출되는 콜백 등이 포함되어 있습니다.

여기서 react-native-maps 버그가 있습니다. 구글맵에서 현재 위치로 이동하는 버튼 표시가 안됩니다.

저도 위 내용을 참고하여 버튼이 표시되도록 했습니다. 해결방법은 맵 property에서 onMapReady={() => setMapMargin(0)}을 추가하여 맵이 사용 완료되면 mapMargin값을 업데이트 하도록 했습니다. 이 방법으로 해결이 안될 수도 있습니다. 참고로, 아이폰의 경우 이 방법으로도 안됩니다 ㅜ.ㅜ. 안될 경우 수동으로 버튼을 생성하고 처리해줘야 합니다.

위치인증 관점에서 중요한 부분은 맵이 변경되면 해당 위치의 위도, 경도값을 주소로 표시하는 부분입니다.

const onRegionChangeComplete = () => {
    // get intial address
    Geocoder.from(latitude, longitude)
    .then(json => {
      const name = json.results[0].address_components[1].short_name;
      const district = json.results[0].address_components[2].short_name;
      const city = json.results[0].address_components[3].short_name;
      const state = json.results[0].address_components[4].short_name;
      const country = json.results[0].address_components[5].short_name;
      // for address display
      let display = '';
      switch (language) {
        case 'ko':
          display = (district + ' ' + name);
          break;
        default:
          display = (name + ', ' + district);
          break;
      }
      const addr = {
        name: name,
        district: district,
        city: city,
        state: state,
        country: country,
        display: display.substring(0, 25),
        coordinate: [latitude, longitude]
      };
      setAddress(addr);
    })
    .catch(error => console.warn(error));  
  };

Geocoding결과 주소가 배열에 담겨지는데, 적절한 작업을 통해서 표시하고자 하는 주소값을 생성합니다.

위치 인증

위치 인증 방법은 간단하지만, 구현에서는 여러 상황을 고려해야 해서 좀 코드가 지저분합니다. 단순히 지역을 인증만 하는 것이 아니라, 기존 지역을 인증하면 인증 카운트를 증가시켜야 하며, 인증 지역이 변경되면 기존 지역을 지우고, 새롭게 인증 지역을 추가해야 합니다. 이 때, 데이터베이스 내용도 같이 업데이트 되어야 하구요. 이로 인해 Firebase의 Firestore를 업데이트 하는데 제약사항이 있어서 코드가 매끄럽지 않습니다. 설상가상으로 영어, 한글 동시 지원으로 주소 표시 언어에 따라 처리를 해줘야 해서 더욱 복잡합니다.

Firestore의 위치 데이터베이스 구조는 다음과 같습니다.

image.png

먼자 사용자가 "인증하기" 버튼을 클릭했을 때 처리하는 함수입니다.

세 가지 상황을 나뉩니다.

  1. 처음 주소를 인증하는 경우
  2. 기존 주소를 인증하는 경우 (반복 인증)
  3. 기존 주소와 다른 지역을 인증하는 경우 (지역 업데이트)
// LocationScreen.js

const onVerify = async () => {
    // get reference to the current user
    const { currentUser } = firebase.auth();
    const userId = currentUser.uid;
    
    //// get current region
    // user ref
    const userRef = firebase.firestore().doc(`users/${userId}`);
    // get previous region in local and english languages
    let prevRegion = null;
    let prevRegionEN = null;
    await userRef.get()
    .then(doc => {
      const temp = doc.data().regions[locationId];
      if (typeof temp !== 'undefined') {
        prevRegion = temp;
        prevRegionEN = doc.data().regionsEN[locationId];
      }
    })
    .catch(error => console.log(error));

    // check if the location is a new location
    // @note currentLocation is from navigation param, which is in local language
    if (currentLocation == '') {
      // update location
      await verifyLocation({ id: locationId, address: address, userId, newVerify: true, 
                             prevRegion: null, prevRegionEN: null, language });
      // navigate to profile screen
      navigation.navigate('ProfileContract');      
    } else if (address.display === currentLocation) { // same as the previous location
      // update location
      verifyLocation({ id: locationId, address: address, userId, newVerify: false, 
                       prevRegion, prevRegionEN, language });
      // navigate to profile screen
      navigation.navigate('ProfileContract');
    } else {
      // show modal to confirm
      Alert.alert(
        t('LocationScreen.verifyTitle'),
        t('LocationScreen.verifyText'),
        [
          { text: t('no'), style: 'cancel' },
          { text: t('yes'), onPress: async () => {
            // verify location 
            verifyLocation({ id: locationId, address: address, userId, newVerify: true, 
                                   prevRegion, prevRegionEN, language });
            // navigate to profile screen
            navigation.navigate('ProfileContract');
          }}
        ],
        { cancelable: true },
      );            
    }
  };

다음으로 실제적으로 데이터를 업데이트하는 부분입니다. 별도의 파일에서 구현되어 있습니다. 지역마다 사용자가 몇 명인지 랭킹보드에 표시하게 되어 있는데, 이 때문에 각 로컬 지역 이름을 영어로 변환하는 부분이 있습니다. 이 때문에 코드가 매우 지저분합니다.

// ProfileContext.js

// verify location with id and update on DB
const verifyLocation = dispatch => {
  return async ({ id, address, userId, newVerify, prevRegion, prevRegionEN, language }) => {
//    console.log('[dispatch verify location]', id, Object.entries(address).length, userId);
//    console.log('[dispatch verify location] prevRegion', prevRegion);
    // sanity check
    if (!userId) return;
    if (typeof id === 'undefined') return; 
    if (Object.entries(address).length === 0) return;
//    console.log('[verifyLocation] address length', Object.entries(address).length);

    dispatch({
      type: 'verify_location',
      payload: { id, address, newVerify }
    });

    // update location on db with increment of verification
    // if the location is different from the verified one, reset the verification count
    // @todo for location, use number of verification instead of votes.
    const userRef = firebase.firestore().doc(`users/${userId}`);
    userRef.collection('locations').doc(`${id}`).update({
      name: address.name,
      district: address.district,
      city: address.city,
      state: address.state,
      country: address.country,
      display: address.display, 
      coordinate: address.coordinate,
      votes: newVerify ? 1 : firebase.firestore.FieldValue.increment(1)
    });
    // delete the previous regions first
    if (newVerify && prevRegion) {
      userRef.update({
        regions: firebase.firestore.FieldValue.arrayRemove(prevRegion),
        regionsEN: firebase.firestore.FieldValue.arrayRemove(prevRegionEN)
      });
    }

    // update the regions in local language
    userRef.update({
      regions: firebase.firestore.FieldValue.arrayUnion(address.district),
      coordinates: address.coordinate
    });
        
    //// update the regions in english
    // if the local language is english, just copy the address.district
    let region = address.district;
    if (language === 'en') {
      userRef.update({
        regionsEN: firebase.firestore.FieldValue.arrayUnion(address.district),
      });
    } else {
      // get region in english
      await updateRegionState(dispatch, address.coordinate[0], address.coordinate[1], 'en')
      .then(district => {
//        console.log('update region', district);
        // set region in english
        region = district;
        // update the db
        userRef.update({
          regionsEN: firebase.firestore.FieldValue.arrayUnion(district),
        });
      })
      .catch(error => console.log(error));
    }

    //// update the regions DB if the region is new or the empty previously
    if (newVerify) {
      // get regions ref
      const regionRef = firebase.firestore().collection('regions').doc(region);
      regionRef.get()
      .then(docSnapshot => {
        if (docSnapshot.exists) {
          //// decrease the previous region by 1
          // decrease previous region if it exists
          if (prevRegionEN) {
            const prevRegionRef = firebase.firestore().collection('regions').doc(prevRegionEN);
            // decrease
            prevRegionRef.update({
              count: firebase.firestore.FieldValue.increment(-1)
            });
          }
          // increase the count by 1
          regionRef.update({
            count: firebase.firestore.FieldValue.increment(1)
          })
        } else {
          // create region
          regionRef.set({
            count: 1,
            coordinate: address.coordinate
          });
        }
      })
      .catch(error => console.log(error));  
    }
  }
};

위 코드 대부분은 주석과 코드로 설명이 됩니다. 조금 특이한 것은 Firestore의 데이터 업데이트 방식입니다.

userRef.update({
        regions: firebase.firestore.FieldValue.arrayRemove(prevRegion),
        regionsEN: firebase.firestore.FieldValue.arrayRemove(prevRegionEN)
      });

   // update the regions in local language
    userRef.update({
      regions: firebase.firestore.FieldValue.arrayUnion(address.district),
      coordinates: address.coordinate
    });

여기서는 배열을 업데이트 하는데, arrayUnion, arrayRemove를 사용합니다. arrayUnion, arryRemove의 인자를 key로 해서 해당 항목을 업데이트하거나 삭제합니다. 여기서는 지역이름으로 내용을 업데이트, 삭제하게 됩니다. 주의할 점은 이름이 없는데, arryUnion하게 되면 새롭게 배열 항목이 추가됩니다. 이 때문에 지역인증이 복잡합니다. 단순히 덮어 쓸 수 있는 방식이 아닙니다. 그래서 기존의 지역 내용을 먼저 지우고, 새롭게 지역 정보를 업데이트해야 합니다.

지역을 삭제하는 부분은 생략하겠습니다. 자세한 내용은 소스코드를 참고해주세요.
소스코드: https://github.com/EtainClub/helpus

onVerify함수에서 추가적으로 호출하는 함수가 있습니다. 한글로 된 로컬 주소명을 영어로 변경하여 별도로 데이터베이스제 저장하는 함수입니다.

const updateRegionState = async (dispatch, latitude, longitude, language) => {
//  console.log('[updateRegionState] lat, long', latitude, longitude, typeof latitude);
  const queryParams = `latlng=${latitude},${longitude}&language=${language}&key=${GEOCODING_API_KEY}`;
  const url = `https://maps.googleapis.com/maps/api/geocode/json?${queryParams}`;
  let response, data;
  try {
    response = await fetch(url);
  } catch(error) {
    throw {
      code: Geocoder.Errors.FETCHING,
      message: "Error while fetching. Check your network",
      origin: error
    };
  }
  // parse data
  try {
    data = await response.json();
  } catch(error) {
    throw {
      code: Geocoder.Errors.PARSING,
      message : "Error while parsing response's body into JSON. The response is in the error's 'origin' field. Try to parse it yourself.",
      origin : response,
    };
  }
  if (data.status === 'OK') {
    // update region state
    const region = data.results[0].address_components[2].short_name;
    dispatch({
      type: 'update_region',
      payload: region
    });
    return region;
  }
}

이로써 대략적인 지역인증 구현이 완료됩니다.


도움 주고 받는 앱 helpus - V2.1.0

앱 사용자 중 다음과 같은 도움이 가능하 사용자들이 있습니다.

  • 사용법

  • 보다 자세한 내용은 홈페이지를 참고해 주세요.
    https://etain.club

    Authors get paid when people like you upvote their post.
    If you enjoyed what you read here, create your account today and start earning FREE STEEM!