[React+Typescript] 카카오맵 API를 활용하여 덕질지도 완성하기
개요
지난 9월 UNITHON 10회차가 끝나고,
회고록은 작성했지만 사용했던 기술에 대한 설명이 부족하다고 느껴져 작성하게 되었다.
기획서는 여기를 참고하면 될 것 같고,
지금부터는 내가 맡은 부분인 지도 부분을 어떻게 활용했는지 작성해보려고 한다.
기능 쪼개기
우선 내가 하고자 하는 기능을 하나의 퀘스트를 깬다 생각하고 작성해보았다.
그랬더니 아래와 같이 정리할 수 있었다.
우리의 주 목적은 지도를 활용해 내 최애가 다녀간 장소,
최애의 맛집 혹은 최애의 생일을 축하 할 수 있는 생일 카페에 대한 정보를 제공하는 것이다.
나는 이 지도를 활용해 사용자에게 정보를 제공해야한다.
지도를 화면에 띄웁시다!
첫 번째, 지도를 화면에 띄우기 위해 지도 API를 활용해야했다.
네이버 지도, 구글 지도.. 등 다양한 지도 API가 있지만 카카오 로그인과 통일하여 사용하기 위해 카카오맵 API를 활용하기로 했다.
우선 API key가 필요했기 때문에 발급 받기로 했다. 필자는 이 글을 참고 하였다.
발급한 키는 환경 변수에 등록하여 주었고, index.html에 아래와 같이 작성해주었다.
<!doctype html>
<html lang="en">
<head>
// 다른 부분 생략 ...
<title>Deokjideokji</title>
<script
type="text/javascript"
src="//dapi.kakao.com/v2/maps/sdk.js?appkey=REACT_APP_MAP_KEY"
></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
그리고 <Map/> 컴포넌트를 활용해 기본 지도를 띄워보기로 했다.
import React from 'react';
import { Map, MapMarker } from 'react-kakao-maps-sdk';
const HomePage = () => {
return (
<Map
center={{ lat: 37.525121, lng: 126.96339 }}
style={{ width: '100%', height: '100%' }}
>
</Map>
);
};
여기서 center은 처음 시작 위치를 뜻한다.
이렇게 하면 지도가 나타날 줄 알았지만, 아래와 같은 에러가 발생했다.
Property 'Kakao' does not exist on type 'Window & typeof globalThis'. Did you mean 'kakao'?ts(2551)
구글링으로 검색 결과, 타입 에러라고한다. TypeScript가 window 객체에서 Kakao 객체를 인식하지 못해서 발생하는 오류다. 이를 해결하기 위해 타입스크립트 컴파일러에게 window 전역 객체에 Kakao 객체의 실존함을 알려야한다. 그래서 declare global 키워드를 활용하여 Window 인터페이스에 ant 타입의 kakao 객체가 존재하는 것으로 덮어써줬다. 그 코드는 아래와 같이 나타낼 수 있다.
declare global {
interface Window {
kakao: any;
}
}
그 결과 아래와 같이 지도를 나타낼 수 있었다.
그리고 HomeLayout을 감싸 앱뷰로 볼 수 있도록 나타내주었다.
import React from 'react';
import { Map, MapMarker } from 'react-kakao-maps-sdk';
import { styled } from 'styled-components';
declare global {
interface Window {
kakao: any;
}
}
const HomePage = () => {
return (
<HomePageLayout>
<Map
center={{ lat: 37.525121, lng: 126.96339 }}
style={{ width: '100%', height: '100%' }}
onClick={initState}
>
</Map>
</HomePageLayout>
);
};
const HomePageLayout = styled.div`
width: 100%;
height: 100%;
background-color: red;
position: relative;
`;
export default HomePage;
화면은 이미 만들어진 것 가져왔다.
특정 장소에 커스텀 마커 띄우기
지도에 장소를 등록하고 분류하기 위해 커스텀 마커를 띄워야 한다. 우리는 총 3종류의 커스텀 마커를 가지고 있고, 나는 이 마커를 피그마로 부터 SVG로 export했다.
그리고 지도의 마커를 참고해 커스텀 마커를 만들어 주었다. <MapMarker/> 컴포넌트의 image속성에 svg를 추가해주었고, 우리가 알고 있는 몇 가지의 정보를 더미 데이터로 넣어주었다.
import React, { useState } from 'react';
import { Map, MapMarker } from 'react-kakao-maps-sdk';
import { styled } from 'styled-components';
import { RECORD_DUMMY_DATA } from 'db/records';
declare global {
interface Window {
kakao: any;
}
}
const HomePage = () => {
const [records, setRecords] = useState<IRecord[]>(RECORD_DUMMY_DATA);
const [selectedGroup, setSelectedGroup] = useState<
'BTS' | '뉴진스' | '블랙핑크' | '세븐틴' | null
>(null);
return (
<HomePageLayout>
<Map
center={{ lat: 37.525121, lng: 126.96339 }}
style={{ width: '100%', height: '100%' }}
onClick={initState}
>
{records
?.filter((record, idx) => {
return selectedGroup ? selectedGroup === record.group : true;
})
.map((loc) => {
const latlng = {
lat: loc.place.latitude,
lng: loc.place.longitude,
};
return (
<MapMarker
key={`${loc.place.name}-${latlng}`}
position={latlng}
image={{
src: `/assets/svg/${loc.place.type}.svg`,
size: { width: 35, height: 35 },
}}
title={loc.place.name}
onClick={() => {
setFocused(loc);
}}
/>
);
})}
)}
</Map>
</HomePageLayout>
);
};
const HomePageLayout = styled.div`
width: 100%;
height: 100%;
background-color: red;
position: relative;
`;
export default HomePage;
export const RECORD_DUMMY_DATA = [
{
purpose: 0,
group: '블랙핑크',
member: '제니',
place: {
id: 1,
name: '트래버틴',
address: '서울 용산구 한강로3가 40-317',
latitude: 37.524860512837265,
longitude: 126.96206847145102,
type: 'Cafe',
img: '"http://place.map.kakao.com/239691324"',
},
},
{
purpose: 0,
group: '뉴진스',
member: '다니엘',
place: {
id: 1296535041,
name: '그린코너',
address: '서울 용산구 한강로3가 40-317',
latitude: 37.5256273611397,
longitude: 126.962366741869,
type: 'Cafe',
img: 'http://place.map.kakao.com/1296535041',
},
},
{
purpose: 0,
group: '아이즈원',
member: '안유진',
place: {
id: 1,
name: '금돼지 식당',
address: '서울특별시 중구 신당동 다산로 149',
latitude: 37.55723,
longitude: 127.011697,
type: 'Restaurant',
img: 'https://lh5.googleusercontent.com/p/AF1QipO3y3BGlTnfVRVE4NBEql3F1b0UvHetXSysx-K2=w718-h538-p-k-no',
},
},
{
purpose: 0,
group: 'BTS',
member: '제이홉',
place: {
id: 2,
name: '카페 차품집',
address: '서울시 용산구 한강대로 10길 11-50',
latitude: 37.524538,
longitude: 126.964816,
type: 'BirthCafe',
img: 'https://pbs.twimg.com/media/F51tfX5aYAIUSqw?format=jpg&name=large',
},
},
{
purpose: 0,
group: '세븐틴',
member: '버논',
place: {
id: 3,
name: '시애틀 에스프레소',
address: '서울 용산구 한강대로11길 4',
latitude: 37.525121,
longitude: 126.96339,
type: 'BirthCafe',
img: 'https://pbs.twimg.com/media/F6nCtXoboAAsvGQ?format=jpg&name=medium',
},
},
{
purpose: 0,
group: '블랙핑크',
member: '제니',
place: {
id: 4,
name: '열봉부엌',
address: '서울특별시 용산구 원효로1가 43-26',
latitude: 37.539248,
longitude: 126.96925,
type: 'Restaurant',
img: 'https://lh5.googleusercontent.com/p/AF1QipNwHCyM8i2cMbkLHfscoQcAccWTpyRNErBBSPMB=w408-h544-k-no',
},
},
{
purpose: 1,
group: '뉴진스',
member: '민지',
place: {
id: 5,
name: '당스',
address: '서울특별시 마포구 상수동 독막로15길 13-5',
latitude: 37.524425159784,
longitude: 126.961546047561,
type: 'Restaurant',
img: 'https://lh5.googleusercontent.com/p/AF1QipOwigu_OjZLIlPDvYJBJgRZtXCvqN_btqdPCqTl=w444-h240-k-no',
},
},
{
purpose: 0,
group: 'BTS',
member: '제이홉',
place: {
id: 6,
name: '미미옥',
address: '서울특별시 용산구 한강대로15길 27',
latitude: 37.526372,
longitude: 126.962946,
type: 'Restaurant',
img: 'https://lh5.googleusercontent.com/p/AF1QipM1WPYOsjDEhptJMnyRlhCB7UtE6l66VHem6ADo=w408-h510-k-no',
},
},
{
purpose: 1,
group: '블랙핑크',
member: '로제',
place: {
id: 6,
name: '디지엔콤',
address: '서울특별시 용산구 한강대로15길 27',
latitude: 37.522995723541655,
longitude: 126.96296875386713,
type: 'Restaurant',
img: 'http://place.map.kakao.com/12845358',
},
},
];
위 코드를 적용하면 아래와 같이 나타난다. 아주 예쁘게 커스텀 마커를 띄웠다.
장소를 클릭했을 때 정보를 볼 수 있는 컴포넌트 띄우기
각 커스텀 마커가 있는 장소를 클릭하게 되면 그 장소에 대한 정보를 나타내 줄 컴포넌트가 필요했다. focused라는 상태를 하나 만들어서 조건부 렌더링을 해주었다.
import React, { useState } from 'react';
import { Map, MapMarker } from 'react-kakao-maps-sdk';
import { styled } from 'styled-components';
import { IPlace, IRecord } from 'utils/interface';
import { RECORD_DUMMY_DATA } from 'db/records';
declare global {
interface Window {
kakao: any;
}
}
const HomePage = () => {
const [focused, setFocused] = useState<IRecord | null>(null);
const [records, setRecords] = useState<IRecord[]>(RECORD_DUMMY_DATA);
const [selectedGroup, setSelectedGroup] = useState<
'BTS' | '뉴진스' | '블랙핑크' | '세븐틴' | null
>(null);
return (
<HomePageLayout>
<Map
center={{ lat: 37.525121, lng: 126.96339 }}
style={{ width: '100%', height: '100%' }}
onClick={initState}
>
{records
?.filter((record, idx) => {
return selectedGroup ? selectedGroup === record.group : true;
})
.map((loc) => {
const latlng = {
lat: loc.place.latitude,
lng: loc.place.longitude,
};
return (
<MapMarker
key={`${loc.place.name}-${latlng}`}
position={latlng}
image={{
src: `/assets/svg/${loc.place.type}.svg`,
size: { width: 35, height: 35 },
}}
title={loc.place.name}
onClick={() => {
setFocused(loc);
}}
/>
);
})}
{focused && (
<LocationContainer>
1
</LocationContainer>
)}
</Map>
</HomePageLayout>
);
};
const HomePageLayout = styled.div`
width: 100%;
height: 100%;
background-color: red;
position: relative;
`;
const LocationContainer = styled.div`
width: 100%;
position: absolute;
bottom: 120px;
display: flex;
justify-content: center;
padding: 0 24px;
z-index: 4;
`;
export default HomePage;
테스트를 위해 1이라는 숫자를 넣었고, 아래와 같이 화면이 나타났다.
상세 주소 컴포넌트 속 해시태그 만들기
각 카테고리 별로 커스텀 마커를 클릭하면 상세한 주소를 보여주는 컴포넌트에 정보를 구성해볼 것이다. 우선 해시태그를 하나씩 만들고,
아까 위코드에서 테스트로 "1"을 넣은 부분에 <LocationInfo/>라는 컴포넌트를 생성하고, focused가 되었을 때 나타나도록 해주자.
<LocationInfo focused={focused} shadow />
import React from 'react';
import styled, { css } from 'styled-components';
import { Body1_1, Body2_3 } from 'styles/font';
import { IPlace, IRecord } from 'utils/interface';
import { Hashtag } from './Hashtag';
import { MemberHashtag } from './MemberHashtag';
export const LocationInfo = ({
focused,
shadow = false,
}: {
focused: IRecord | null;
shadow?: boolean;
}) => {
return (
<LocationInfoContainer shadow={shadow}>
<Col>
<Row>
<img
src={focused?.place.img}
style={{ width: '46px', height: '46px', borderRadius: '10px' }}
/>
<LocationInfoTop>
<Body1_1>{focused?.place.name}</Body1_1>
<Body2_3>{focused?.place.address}</Body2_3>
</LocationInfoTop>
</Row>
<Row>
<MemberHashtag group={focused?.group} name={focused?.member} />
<Hashtag type={focused?.place.type} />
</Row>
</Col>
</LocationInfoContainer>
);
};
const LocationInfoContainer = styled.div<{ shadow: boolean }>`
z-index: 3;
background-color: white;
width: 100%;
height: 120px;
padding: 12px;
justify-content: space-between;
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
${({ shadow }) =>
shadow &&
css`
box-shadow: 3px 3px 3px 3px ${({ theme }) => theme.colors.gray02};
`}
`;
const Col = styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
`;
const Row = styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 11px;
`;
const LocationInfoTop = styled.div`
height: 50px;
display: flex;
flex-direction: column;
`;
그렇게 하면 아래와 같이 나타낼 수 있다.
그룹별로 필터링 하기
이제 모든 정보를 제공할 준비는 끝났다. 각 그룹 별로 필터링을 해주고 나타나도록 하면 된다. 우선 나는 <GroupFilter/>라는 컴포넌트를 따로 만들어주었다.
<GroupFilter
selectedGroup={selectedGroup}
setSelectedGroup={setSelectedGroup}
/>
그리고 selectedGroup과 setSelectedGroup을 props로 넘겨주었다.
아까 만들어둔 더미 데이터로 그룹을 지정하고, 선택했을 때 해당하는 데이터만 보여주도록 했다.
import React from 'react';
import { styled } from 'styled-components';
const GroupFilter = ({
selectedGroup,
setSelectedGroup,
}: {
selectedGroup: 'BTS' | '뉴진스' | '블랙핑크' | '세븐틴' | null;
setSelectedGroup: React.Dispatch<
React.SetStateAction<'BTS' | '뉴진스' | '블랙핑크' | '세븐틴' | null>
>;
}) => {
return (
<GroupFilterWrapper>
<GroupFilterBtn
$selected={selectedGroup === 'BTS'}
onClick={() => setSelectedGroup('BTS')}
>
BTS
</GroupFilterBtn>
<GroupFilterBtn
$selected={selectedGroup === '뉴진스'}
onClick={() => setSelectedGroup('뉴진스')}
>
뉴진스
</GroupFilterBtn>
<GroupFilterBtn
$selected={selectedGroup === '블랙핑크'}
onClick={() => setSelectedGroup('블랙핑크')}
>
블랙핑크
</GroupFilterBtn>
<GroupFilterBtn
$selected={selectedGroup === '세븐틴'}
onClick={() => setSelectedGroup('세븐틴')}
>
세븐틴
</GroupFilterBtn>
</GroupFilterWrapper>
);
};
const GroupFilterWrapper = styled.div`
display: flex;
gap: 1.15rem;
display: flex;
position: absolute;
top: 10px;
z-index: 2;
margin: 0 2.76rem;
margin-top: 1.5rem;
`;
const GroupFilterBtn = styled.button<{ $selected: boolean }>`
display: flex;
padding: 0.6912rem 1.8432rem;
justify-content: center;
align-items: center;
gap: 0.4608rem;
border-radius: 1.3824rem;
border: 1.152px solid #171717;
background: ${({ $selected }) => ($selected ? '#74FAB9' : '#FFFFFF')};
box-shadow: 0px 0px 11.52px 0px rgba(0, 0, 0, 0.1);
font-size: calc(10px + 1vw);
`;
export default GroupFilter;
그 결과 각 필터링 값에 대한 결과를 아래와 같이 얻을 수 있었다.
결론
카카오 맵 API를 띄운 경험은 있지만, 이렇게 직접적으로 활용해본 것은 이번이 처음인데 유익했던 경험이 되었던 것 같다. 지도 API가 무겁다는 말을 많이 들었지만, 생각했던 것 보다 괜찮아서 다음에 또 다른 방향으로 사용해보고 싶다.