나의 목표는 서버에서 전달받은 위도, 경도 정보를 활용하여 지도 사진을 띄우는 것이었다.
이를 수행하기 위해서는 네이버 지도 API가 제일 적합하다고 생각했다.
API 세팅부터 Flutter에서 실제로 지도를 띄우는 방법까지 자세히 알아보자!
먼저 네이버 클라우드 플랫폼-Maps 링크로 이동하여 이용 신청을 눌러준다.
다음으로 Application 등록을 해준다.

위의 사진을 보면 여러 종류의 API가 있는데 간단하게 아래와 같이 생각하면 된다.
Dynamic Map: 조작 가능한 지도 (줌, 이동 등)
Static Map: 이미지 형태의 지도 (단순 미리보기용)
Directions 5: 최대 5개 경유지 길찾기
Directions 15: 최대 15개 경유지 길찾기
Geocoding: 주소 → 좌표 변환
Reverse Geocoding: 좌표 → 주소 변환
나는 특별한 조작 없이 이미지만 띄울 것이기 때문에 Static Map으로 설정해주었다.
또한 나는 서비스 환경 등록에서 Android 앱 패키지 이름과 iOS Bundle ID를 설정해주었다.
Client ID와 Client Secret 저장하기
이용 신청한 API의 인증 정보를 누르면 아래와 같이 Client ID와 Client Secret을 확인할 수 있다.
이를 .env파일에 등록해주자!

이제 기본적인 세팅은 끝이 났으니 API 요청을 해보자.
MAP 전체 API 개요를 참고하면 본인의 지도 타입에 맞게 가이드를 선택해 볼 수 있고 나는 Static Map을 사용할 것이기 때문에 Static Map 가이드를 참고하였다.
처음에 나는 아래와 같이 코드를 작성해주었다.
Widget build(BuildContext context) {
final clientId = dotenv.env['NAVER_MAP_CLIENT_ID'] ?? '';
final clientSecret = dotenv.env['NAVER_MAP_CLIENT_SECRET'] ?? '';
print('clientId: $clientId');
print('clientSecret: $clientSecret');
const width = 600;
const height = 400;
final mapUrl =
'https://maps.apigw.ntruss.com/map-static/v2/raster?center=$longitude,$latitude&w=$width&h=$height';
return Image.network(
mapUrl,
headers: {
'x-ncp-apigw-api-key-id': clientId,
'x-ncp-apigw-api-key': clientSecret,
},
errorBuilder: (context, error, stackTrace) {
print('지도 로딩 실패: $error');
print('stackTrace: $stackTrace');
return const Text("지도 로딩 실패");
},
);
}
하지만 지도는 뜨지 않고 401 에러가 났다.
알고보니.env 파일에서 키 이름 오탈자 때문에 실제 값이 제대로 불러와지지 않아 401 에러가 발생했다.
이 경우 .env 파일과 코드에서 사용되는 변수명이 정확히 일치하는지 꼭 확인해야 한다.
그런데 401 에러를 해결하니까 403 에러가 떴다.
검색했을 때는 해당 사이트에서 특정 국가나 대상을 차단했을시 나오는 현상일 수도 있어 VPN이나 프록시 우회등으로 IP를 우회에서 접속하라고 했다.
근데 한국을 차단했을리는 없고.. 차단을 당해서 에러가 나는 것 같지는 않았다.
그래서 나는 일단 가이드 예시에 나와있는 것처럼 터미널로 API 요청을 해서 문제점을 찾아보고자 했다.
아래 명령어에서 나의 client ID와 client secret을 직접 넣어 요청을 해보았다.
그런데 왠걸 에러가 안나고 map.jpg에 지도 이미지가 잘 뜨는 것이었다!
이로써 추가적인 문제는 없었고 나의 요청 코드가 문제였다는 것을 알게 되었다.
curl --location --request GET 'https://maps.apigw.ntruss.com/map-static/v2/raster?w=300&h=300¢er=127.1054221,37.3591614&level=11' \
--header 'x-ncp-apigw-api-key-id: {client ID}' \
--header 'x-ncp-apigw-api-key: {client secret}' \
--output map.jpg
403에러가 났던 것은 이전 코드에서 headers가 실제로 반영되지 않아서 그랬다.
Flutter의 Image.network()는 내부적으로 NetworkImage를 사용하는데 이건 headers를 전달하더라도 플랫폼에 따라 적용되지 않거나 무시될 수 있다고 한다.
따라서 http 패키지로 직접 요청을 보내고 받은 바이트 데이터를 Image.memory()를 통해 렌더링하는 방식으로 전환했다.
http.get()으로 지도 이미지의 URL에 직접 요청을 보내고 응답 받은 이미지 바이트를 Image.memory()로 그려 지도 이미지를 정상적으로 띄울 수 있었다.
Future<Widget> _loadMapImage() async {
final clientId = dotenv.env['NAVER_MAP_CLIENT_ID'] ?? '';
final clientSecret = dotenv.env['NAVER_MAP_CLIENT_SECRET'] ?? '';
const width = 350;
const height = 350;
final mapUrl =
'https://maps.apigw.ntruss.com/map-static/v2/raster?center=$longitude,$latitude&level=15&w=$width&h=$height&markers=type:d|size:mid|pos:$longitude%20$latitude|viewSizeRatio:0.7&scale=2';
try {
final response = await http.get(
Uri.parse(mapUrl),
headers: {
'X-NCP-APIGW-API-KEY-ID': clientId,
'X-NCP-APIGW-API-KEY': clientSecret,
},
);
if (response.statusCode == 200) {
Uint8List imageBytes = response.bodyBytes;
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(imageBytes),
);
} else {
print('지도 로딩 실패: ${response.statusCode}');
print('응답 본문: ${response.body}');
return Text('지도 로딩 실패: ${response.statusCode}');
}
} catch (e, stackTrace) {
print('지도 로딩 예외 발생: $e');
print('stackTrace: $stackTrace');
return const Text('지도 로딩 실패');
}
}
아래는 내가 사용한 요청 url이고 이에 대해서 설명해보겠다.
https://maps.apigw.ntruss.com/map-static/v2/raster
?center=$longitude,$latitude
&level=15
&scale=2
&w=$width&h=$height
&markers=type:d|size:mid|pos:$longitude%20$latitude|viewSizeRatio:0.7
center: 지도 중심 좌표 (longitude, latitude)level: 줌 레벨 (숫자가 클수록 축소됨)w, h: 이미지의 너비, 높이 (픽셀 단위)scale: 고해상도 설정 (1 or 2)markers: 마커 옵션type:d: 기본형 마커size:mid: 중간 크기pos:$longitude $latitude: 마커 위치viewSizeRatio: 마커 크기 비율 (0.7배)
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
class MapImageView extends StatelessWidget {
final String latitude;
final String longitude;
const MapImageView({
super.key,
required this.latitude,
required this.longitude,
});
Future<Widget> _loadMapImage() async {
final clientId = dotenv.env['NAVER_MAP_CLIENT_ID'] ?? '';
final clientSecret = dotenv.env['NAVER_MAP_CLIENT_SECRET'] ?? '';
const width = 350;
const height = 350;
final mapUrl =
'https://maps.apigw.ntruss.com/map-static/v2/raster?center=$longitude,$latitude&level=15&w=$width&h=$height&markers=type:d|size:mid|pos:$longitude%20$latitude|viewSizeRatio:0.7&scale=2';
try {
final response = await http.get(
Uri.parse(mapUrl),
headers: {
'X-NCP-APIGW-API-KEY-ID': clientId,
'X-NCP-APIGW-API-KEY': clientSecret,
},
);
if (response.statusCode == 200) {
Uint8List imageBytes = response.bodyBytes;
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(imageBytes),
);
} else {
print('지도 로딩 실패: ${response.statusCode}');
print('응답 본문: ${response.body}');
return Text('지도 로딩 실패: ${response.statusCode}');
}
} catch (e, stackTrace) {
print('지도 로딩 예외 발생: $e');
print('stackTrace: $stackTrace');
return const Text('지도 로딩 실패');
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Widget>(
future: _loadMapImage(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Text('에러 발생: ${snapshot.error}');
} else {
return snapshot.data!;
}
},
);
}
}