단어만 들어도 내가 이걸 할수 있을까 싶은 기능이다. 뭐랄까...내 영역이 아닌 느낌? 할 엄두도 못냈고 할 생각조차 못했다. 하지만 멘토링 시간에 멘토님이 사용자 접속 위치를 web API를 통해 받아낼 수 있다고 하신 말 한마디때문에 시도해보기 시작했다.
나의 목표는 소박하다.
- 사용자 접속 위치에 해당하는 액티비티 가져오기
- 접속 위치에서 가장 가까운 곳에 위치값 추가 설정하기
우선 사용자 접속위치는 기본적으로 구현하고 추가적으로 당근마켓처럼 주변 위치를 받아오는 것을 구현해볼 계획이다. 물론 당근마켓처럼 가까운 곳부터 먼 곳까지 영역을 세세하게 설정할 순 없지만 가장 가까운 구역을 받아오는 걸로 약간 체험해보는 것이다.
이걸 쓰면 내가 접속한 위치가 정확하게 00시 00동으로 나오는게 아니다. 정확하게 말하면 위도와 경도값으로 확인이 된다. 여기에서 관건은 이 값을 어떻게 주소로 변환하고 주변 지역까지 설정하냐는 것이다.
navigator.geolocation.getCurrentPosition(()=>{})
브라우저의 navigator객체를 불러와 정보를 확인할 수 있다.

위의 결과를 콘솔로 확인하면 위치와 타임스탬프를 리턴한다. 여기에서 위도, 경도에 해당하는latitude와 longitude를 사용하겠다. 이제 이 값을 활용해 주소를 받아오면 된다.
뭔가를 새로 설치해서 주소로 변환하는 것은 고민해봐야하는 사안이다. 지금 우리 서비스에서 지도를 활용하는 곳은 detail페이지에서 주소를 카카오맵으로 변환해 보여주는 부분이다. 나도 따로 설치하지 않고 카카오맵을 활용해서 시도해보려고 한다.
우선 카카오맵은 서비스 전체에 스크립트태그를 넣어주는 방법도 있지만 우리는 전체에서는 필요하지 않기 때문에 필요한 부분에서만 사용하겠다.
useEffect(() => {
if (window.Kakao && window.Kakao.maps) {
onLoadKakaoMap();
} else {
const mapScript = document.createElement('script');
mapScript.async = true;
mapScript.src = `//dapi.Kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_Kakao_API_KEY}&autoload=false&libraries=services,clusterer,drawing`;
document.head.appendChild(mapScript);
mapScript.addEventListener('load', onLoadKakaoMap);
return () => {
mapScript.removeEventListener('load', onLoadKakaoMap);
document.head.removeChild(mapScript);
};
}
}, [onLoadKakaoMap]);
내가 작성한 코드는 아니고 카카오맵에서 제공하는 방식이다. 그리고 나는 이미 다른 팀원분이 작성해주신 코드가 있어서 해당 코드를 가져와서 사용했다. 동작 방식은 간단하다. 스크립트 태그를 미리 넣어줄수가 없기 때문에 useEffect를 통해 직접 삽입해주는 것이다.
이제 스크립트 태그를 넣어준 이후에는 카카오맵을 불러오면 된다.
const onLoadKakaoMap = useCallback(() => {
window.Kakao.maps.load(() => {
const geocoder = new window.Kakao.maps.services.Geocoder();
navigator.geolocation.getCurrentPosition((i) => {
geocoder.coord2RegionCode(i.coords.longitude, i.coords.latitude, getAddress);
});
});
}, []);
원래 이 코드를 통해 지도를 생성하고 지도 세팅을 해주는 곳이지만 나는 지도는 필요없고 카카오맵에서 지원하는 기능만 뽑아서 사용할 것이기 때문에 별도의 지도 세팅을 해주지 않았다. 우리가 필요한 것은 Kakao.maps.services.Geocoder()부분이다. 이 기능은 주소와 좌표간의 변환 서비스를 제공해준다. 그래서 아까 작성한 navigator.geolocation.getCurrentPosition의 첫번째 인자로 성공했을때의 콜백을 넣어주게 되는데 여기에서 위도 경도값을 넣어주면 된다.
coord2RegionCode은 3개의 인자를 받는데 첫번째는 경도, 두번째는 위도, 세번째는 실행할 함수이다. 세번째 인자로 경도와 위도를 토대로 변환한 결과를 활용해주는 것이다.

결과는 이렇게 나온다. 여기에서는 우리는 region_1depth_name와 region_2depth_name를 활용해서 우리가 사용하는 로케이션 형식으로 맞춰줄 것이다.
const getAddress = (mapResult: any, mapStatus: any) => {
if (mapStatus === window.kakao.maps.services.Status.OK) {
const currentLocation = `${mapResult[0].region_1depth_name} ${mapResult[0].region_2depth_name}`;
const singleLocation = [currentLocation];
const jsonLocation = JSON.stringify(singleLocation);
localStorage.setItem('location', jsonLocation);
}
}
로케이션 값은 모든 사용자마다 다르기 때문에 localstorage에 저장해서 계속 사용하도록 설정해줬다. 참고로 navigator의 위치값은 초기 위치에서 일정 거리 이상 멀어지면 다시 위치를 반환해준다. 그리고 추후에 로케이션은 가까운 위치까지 포함할 예정이라 배열로 생성시켜줬다. 그리고 배열이나 객체는 localstrage에 바로 저장이 안되는 관계로 JSON형식으로 변환해서 넣어줬다.
이제 큰 산이 남아있다. 근처 지역 기능을 구현하기 위해서는 현재 우리가 설정한 지역들과 현재 위치의 거리를 계산해 가까운 곳을 추려내야한다. 그래서 이 기능도 카카오맵에서 지원을 하는지 찾아보았고 다른 곳에서도 구현한 기능이 있는지 찾아봤지만 없었다. 우리가 사용가능한 것은 두 지점간의 거리를 구하는 방법밖에 없었다.
두 지점의 거리를 구하기 위해선 모든 지역의 좌표값이 필요하다. 다행히 우리는 세부 주소까지는 지원하지 않고 시군구까지만 지원한다. 그래서 모든 지역에 대략적인 좌표값을 넣어줬다.
서울특별시: {
list: [
{ state: '종로구', location: { latitude: 37.5703, longitude: 126.9791 } },
{ state: '중구', location: { latitude: 37.5636, longitude: 126.9976 } },
{ state: '용산구', location: { latitude: 37.5326, longitude: 126.99 } },
{ state: '성동구', location: { latitude: 37.5503, longitude: 127.039 } },
{ state: '광진구', location: { latitude: 37.5482, longitude: 127.0724 } },
...
하지만 약간의 문제가 있었다. 만약 모든 지역에 관해서 가까운 지역을 지원한다면 광역시 이하의 지역에서는 곤란한 부분이 있다. 광역시 이하의 도시는 구까지 지원하지 않는다. 그래서 만약 모든 지역을 지원하기에는 현재 우리 서비스의 설정 범위가 너무 넓다. 예를 들어 서울에서는 가까운 지역이 옆 구이지만 경기도에서는 가까운 지역이 옆 시로 확장되는 것이다. 이건 너무 범위가 넓다는 생각에 광역시 이상에서만 지원해야 한다는 판단이 들었다.
그래서 이 현상을 해결하기 위해 지역마다 id값을 부여해줬다. 그래서 한자리의 id는 광역시 이상의 지역으로 근처 지역기능을 지원하고 id가 두자리, 10 이상의 경우에는 현재 위치만 지원하도록 하는 것이다.
const getAddress = (mapResult: any, mapStatus: any) => {
if (mapStatus === window.kakao.maps.services.Status.OK) {
const currentLocation = `${mapResult[0].region_1depth_name} ${mapResult[0].region_2depth_name}`;
const { id, list } = LOCATIONS[mapResult[0].region_1depth_name];
if (id > 10) {
const singleLocation = [currentLocation];
const jsonLocation = JSON.stringify(singleLocation);
localStorage.setItem('location', jsonLocation);
return;
}
let firstDistance = 0;
let secondeDistance = 0;
let nearByLocation;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < list.length; i++) {
if (mapResult[0].region_2depth_name !== list[i].state) {
const distance = calculateDistanceInMeters(
mapResult[0].y,
mapResult[0].x,
list[i].location.latitude,
list[i].location.longitude,
);
secondeDistance = Number((distance / 1000).toFixed(2));
if (!firstDistance) {
firstDistance = secondeDistance;
} else if (firstDistance > secondeDistance) {
firstDistance = secondeDistance;
nearByLocation = list[i].state;
}
}
}
const userLocationList = [
currentLocation,
`${mapResult[0].region_1depth_name} ${nearByLocation}`,
];
const jsonLocation = JSON.stringify(userLocationList);
localStorage.setItem('location', jsonLocation);
}
};
그렇게해서 작성한 코드이다. 엄청 길어보이지만 간단하게 설명하자면
const { id, list } = LOCATIONS[mapResult[0].region_1depth_name];
카카오맵을 통해 받은 지역을 기준으로 지역 상수값에서 사용자의 위치에 해당하는 하위 지역들을 가져온다.
if (id > 10) {
const singleLocation = [currentLocation];
const jsonLocation = JSON.stringify(singleLocation);
localStorage.setItem('location', jsonLocation);
return;
그리고 id가 10이상인 경우에는 한개의 위치만 로컬스토리지에 설정시켜준다.
그 외에는 근처 지역을 찾는 과정을 거치게 되는데 결국 배열 전체를 순회하며 모든 위치를 비교해야하기 때문에 for문을 작성해줬다.
let firstDistance = 0;
let secondeDistance = 0;
let nearByLocation;
우선 3개의 변수를 설정해준다. firstDistance의 값과 secondeDistance을 비교해 더 가까운 위치에 있는 지역을 nearByLocation에 설정해주는 것이다. 이제 두 지점의 거리를 구하는 식을 작성해준다.
const calculateDistanceInMeters = (
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number => {
const R = 6371e3;
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
export default calculateDistanceInMeters;
이건 내가 작성한 로직은 아니고 이미 두 지점의 거리를 구하는 공식을 가져온 것이다. 지구의 반지름과 위도 경도를 통해 도출한 두 지점을 계산해서 리턴시키는 식이다.
secondeDistance = Number((distance / 1000).toFixed(2));
이렇게 나온 값을 km로 변환하고 소수점 두자리를 제외하고 버려준다. 그리고 secondeDistance설정해준 이후에
if (!firstDistance) {
firstDistance = secondeDistance;
} else if (firstDistance > secondeDistance) {
firstDistance = secondeDistance;
nearByLocation = list[i].state;
}
firstDistance의 값이 없는 경우에는 우선적으로 해당 값을 넣어준다. 그리고 firstDistance의 값이 있을때에는 secondeDistance의 값을 비교해서 지역을 설정해준다. 이렇게 모든 지역을 순회하고 나면 두번째로 작은 값이 남게 될 것이다.
const userLocationList = [
currentLocation,
`${mapResult[0].region_1depth_name} ${nearByLocation}`,
];
const jsonLocation = JSON.stringify(userLocationList);
localStorage.setItem('location', jsonLocation);
그리고 현재 위치와 가까운 위치를 배열로 만들어줘서 로컬스토리지에 넣어주면 된다.
useEffect(() => {
const load = async (location: string[]) => {
const result = await getActivitiesByLocation({
location: location[0],
secondeLocation: location[1],
size: 5,
});
setRecommentList([...result.activities]);
};
if (userLocation) {
load(userLocation);
}
}, [userLocation]);
그리고 상위 컴포넌트에서 localstorage값을 props로 넘겨주고 해당 로케이션에 해당하는 데이터를 불러오면 된다.
참고로 데이터를 불러오는 로직은
export const getActivitiesByLocation = async ({
location,
secondeLocation,
size = 5,
}: {
location: string;
secondeLocation?: string;
size?: number;
}): Promise<{ activities: ActivityWithUserAndFavoCount[] }> => {
try {
let activities: ActivityWithUserAndFavoCount[];
const baseQuery = {
take: size,
include: {
user: {
select: {
id: true,
nickname: true,
email: true,
image: true,
tags: true,
createdAt: true,
},
},
_count: {
select: {
favorites: true,
},
},
},
};
const firstactivities = await db.activity.findMany({
...baseQuery,
where: { location },
orderBy: {
createdAt: 'desc',
},
});
activities = [...firstactivities];
if (secondeLocation) {
const secondeActivities = await db.activity.findMany({
...baseQuery,
where: { location: secondeLocation },
orderBy: {
createdAt: 'desc',
},
});
activities = [...activities, ...secondeActivities];
}
return {
activities,
};
} catch (error) {
throw new Error('활동을 가져오는 중에 에러가 발생하였습니다.');
}
};
로케이션이 한개인 경우와 두개인 경우가 있기 때문에 두가지 경우를 조건부로 넣어주었다.
이제 이렇게 받아온 데이터를 슬라이드로 만들어주면 된다. 레퍼런스를 애플에서 사용하는 슬라이드를 활용했다.

이렇게 끝을 반쯤 걸치게 만들어줘서 다음 슬라이드가 있음을 암시하는 것이다.

그래서 나도 같은 디자인으로 만들어줬다. 현재는 옆으로 넘기는 버튼은 없다. 모바일에서는 터치로 슬라이드하기 때문에 문제없지만 데스크탑에서는 가로 스크롤이 안되는 경우가 있어서 추후에 드래그 이벤트나 버튼을 추가해줘야할 것 같다.
지금 우리집에서는 잘 동작한다. 아마 서울이나 광역시에서는 잘 동작할텐데 다른 도시에서는 어떻게 동작할지 궁금하다. 아마 오류가 조금씩 생기지 않을까 싶다. 구상할때는 어떻게 해야하나 고민이 많았는데 막상 해보니 크게 어려운 것은 없었다. 하지만 이 코드가 최선인가에 대해서는 잘 모르겠다. 더 나은 방법이 있을 것 같은데 지금은 이렇게까지 밖에 못하겠다. 계속 고민해보고 비슷한 기능이 있으면 좀 자세히 봐야겠다.
이 기능을 추가하고 나서 오류가 생기는 건지 원래도 오류가 있었던 건지 헷갈리는데 로케이션으로 액티비티를 불러오면 리스트가 이상한 경우가 많다. 새로고침하면 원래대로 돌아오는것 같던데 어떤 문제때문인지 디버깅을 해봐야 할것 같다. 완성도를 높이기 위해 더 노력해보자