들어가며
안녕하세요. 현재 크래프톤 정글에서 "넌! 런"이라는 러닝앱을 개발하고 있습니다. GPS 경로를 저장하고 이미지로 보여줘야 하는 문제로 Google Static Maps API를 사용하게 되었습니다. 그 과정 속에서 겪었던 문제에 대해 적어봤습니다.
Google Static Maps API 사용하기 이전엔..
https://namamim.tistory.com/114
[Spring]지도 경로 로딩 최적화 문제 해결하기 (feat. PostGIS, Ramer-Douglas-Peucker)
들어가며안녕하세요. 현재 크래프톤 정글에서 "넌! 런"이라는 러닝앱을 개발하고 있습니다. GPS를 많이 사용하는 러닝앱 특성상 수많은 좌표들을 최적화하는 문제에 직면하여 고생한 내용을 글
namamim.tistory.com
트랙의 경로를 리스트 형식으로 보여주는 기능이 있었습니다. 처음엔 직관적으로 서버로부터 모든 트랙의 좌표 배열을 불러와서 맵뷰에 이를 직접 그리는 방식으로 구현했습니다. 당연하게도 트랙의 길이가 엄청 긴 것이 있다면 화면이 그려지는 속도가 급격히 느려졌습니다. 10개 정도되는 트랙에도 불구하고 10초 이상의 로딩이 걸렸습니다. 해결하기 위해 Ramer-Douglas-Peucker (RDP) 알고리즘을 사용해서 경로 단순화로 임시방편 처리를 하였습니다. 하지만 트랙이 많아지면 똑같은 문제가 발생할 것은 뻔하기에 근본적인 구조를 바꾸기로 했습니다. 그것이 트랙의 이미지를 찍어주는 Google Static Maps API였습니다.
Google Static Maps API가 뭔가요??

Google Static Maps API는 웹페이지에 자바스크립트나 동적 페이지 로딩 없이 간단한 HTTP 요청을 통해 정적인 지도 이미지를 삽입할 수 있게 해주는 서비스입니다. 사용자는 URL에 원하는 지도의 위치, 크기, 확대 수준 등 다양한 매개변수를 포함하여 요청하면, 구글 서버가 해당 조건에 맞는 지도 이미지를 생성하여 반환합니다. 즉, 지도 경로 렌더링에서 단순 이미지로 변경되기에 클라이언트에서의 부하가 매우 적어집니다.
비용은 얼마나 드나요??

월별로 비용을 측정합니다. Google Static Maps API를 포함한 모든 Google Maps Platform를 사용한 횟수를 총합으로 계산하며 월별 10,000회까지는 무료로 사용할 수 있습니다. 그 이후엔 1,000개마다 비용을 측정합니다. "넌!런" 프로젝트에선 사용하고도 충분할 양이라 Google Static Maps API를 선택하기로 했습니다.
Google Static Maps API 작동 방식
URL 매개변수
https://maps.googleapis.com/maps/api/staticmap?parameters
URL 매개변수를 요청에 넣어주면 그의 반환값으로 지도 이미지가 나타납니다. 이 URL 매개변수는 필수인 것도 있고 선택사항인 것도 있습니다. 이 URL 매개변수를 포함해서 보내기 전에 올바르게 URL 인코딩이 되어야 합니다. 예를 들어 ! * ' ( ) ; : @ & = + $ , / ? % # [ ] 이런 특수문자들은 이미 예약된 문자로 제어 또는 텍스트 문자열을 의미합니다. 그리고 영어를 제외한 특수 문자, UTF-8 문자들은 2자리 16진수 값으로 인코딩 됩니다. (공백은 +로 인코딩) ? and the Mysterians -> %3F+and+the+Mysterians 이런 식으로 되는 셈이죠. 인코딩을 간과하게 되면 큰 문제가 생깁니다. 아래에 그 때 겪은 문제를 적어놓았습니다..
대표적인 필수 URL 매개변수
- center(마커가 없는 경우 필수): 지도의 모든 가장자리에서 등거리에 있는 지도의 중심을 정의, {latitude,longitude} 쌍으로 되어있음
- zoom(마커가 없는 경우 필수): 지도의 확대/축소 수준을 결정. 1=세계, 5=대륙, 10=도시, 15=거리, 20=건물
- size: 지도 이미지의 직사각형 크기를 정의. {horizontal_value}x{vertical_value}, 500x400
- key: 구글 API 키
이 글에서 사용한 추가 URL 매개변수
- format: 결과 이미지의 형식을 정의. GIF, JPEG, PNG 가능.
- path: 연결된 두 개 이상의 지점으로 구성된 단일 경로를 정의하여 지정된 위치의 이미지에 오버레이. 파이프 문자 (|)로 구분된 점 정의 문자열 또는 경로의 위치 선언 내에 enc: 접두사를 사용하여 인코딩된 다중선 객체를 사용.
path=color:0x0000ff|weight:5|40.737102,-73.990318|40.749825,-73.987963|40.752946,-73.987384|40.755823,-73.986397
Google Static Maps API을 Spring에서 사용해보자
이미지를 저장하는 것엔 다양한 방식이 존재하지만 무난하게 AWS S3에 이미지를 저장해두고 DB엔 URL을 두고 불러오는 방식을 택했습니다. 이렇게 되면 DB에선 URL만 불러오게 되는거죠.
먼저 Google Maps API Key를 받아야 합니다.
Google Maps Platform | Google for Developers
수백만 개의 웹사이트와 앱이 Google Maps Platform을 사용하여 사용자에게 효과적인 서비스 환경을 제공하고 있습니다.
developers.google.com
위 사이트에서 결제 계정을 만드시고 API 키를 만드시면 됩니다. 이 글에선 생략합니다. 얻은 API 키는 application.properties에 저장해두고 사용하시면 좋습니다.
static-maps.google.api-key: api키
static-maps.google.base-url: https://maps.googleapis.com/maps/api/staticmap
그리고 이를 이용해서 API를 호출할 Service나 Util을 만들어주면 됩니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class StaticMapService {
@Value("${static-maps.google.api-key}")
private String googleApiKey;
@Value("${static-maps.google.base-url}")
private String baseUrl;
private final PolylineEncoder polylineEncoder;
public byte[] generateTrackThumbnail(List<CoordinateDto> trackPoints, int width, int height) {
try {
String encodedPath = encodePolyline(trackPoints);
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl)
.queryParam("size", String.format("%dx%d", width, height))
.queryParam("path", "enc:" + encodedPath) // 'enc:' 접두사는 그대로 붙여서 파라미터로
.queryParam("key", googleApiKey)
.queryParam("format", "jpg");
// encode()를 사용하여 안전하게 인코딩된 URI를 생성
URI uri = builder.build().encode().toUri();
log.info("만들어진 썸네일 url" + uri);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<byte[]> response = restTemplate.getForEntity(uri, byte[].class);
return response.getBody();
} catch (Exception e) {
throw new RuntimeException("Static map 생성 실패", e);
}
}
private String encodePolyline(List<CoordinateDto> points) {
return polylineEncoder.encodeTrackPoints(points);
}
}
URL 인코딩의 중요성
여기서 중요한 점은 Static Maps API를 요청할 때 만들 URL은 UriComponentsBuilder로 만들어야 합니다.
왜냐하면 위에서 잠깐 언급한 URL 인코딩 때문입니다.
처음에 사용했던 코드는 다음과 같습니다.
public byte[] generateTrackThumbnail(List<CoordinateDto> trackPoints, int width, int height) {
try {
String encodedPath = encodePolyline(trackPoints);
String url = String.format(
"%s?size=%dx%d&path=enc:%s&key=%s&format=jpg",
baseUrl, width, height, encodedPath, googleApiKey
);
log.info("만들어진 썸네일 url" + url);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<byte[]> response = restTemplate.getForEntity(url, byte[].class);
return response.getBody();
} catch (Exception e) {
throw new RuntimeException("Static map 생성 실패", e);
}
}
직접 String으로 하드코딩한 셈이나 다름이 없는데요.
이렇게 만들어도 작동은 합니다.
하지만 특정 경우에 Static Map를 생성하지 못한 경우가 있었습니다.
java.lang.RuntimeException: Static map 생성 실패
at com.running.you_run.running.service.StaticMapService.generateTrackThumbnail(StaticMapService.java:42) ~[main/:na]
....
Caused by: java.lang.IllegalArgumentException: Not enough variable values available to expand 'E'
at org.springframework.web.util.UriComponents$VarArgsTemplateVariables.getValue(UriComponents.java:370) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.HierarchicalUriComponents$QueryUriTemplateVariables.getValue(HierarchicalUriComponents.java:1098) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.UriComponents.expandUriComponent(UriComponents.java:263) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.HierarchicalUriComponents.lambda$expandQueryParams$6(HierarchicalUriComponents.java:456) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.util.UnmodifiableMultiValueMap.lambda$forEach$0(UnmodifiableMultiValueMap.java:115) ~[spring-core-6.2.8.jar:6.2.8]
at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:986) ~[na:na]
at org.springframework.util.MultiValueMapAdapter.forEach(MultiValueMapAdapter.java:179) ~[spring-core-6.2.8.jar:6.2.8]
at org.springframework.util.UnmodifiableMultiValueMap.forEach(UnmodifiableMultiValueMap.java:115) ~[spring-core-6.2.8.jar:6.2.8]
at org.springframework.web.util.HierarchicalUriComponents.expandQueryParams(HierarchicalUriComponents.java:452) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.HierarchicalUriComponents.expandInternal(HierarchicalUriComponents.java:441) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.HierarchicalUriComponents.expandInternal(HierarchicalUriComponents.java:53) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.UriComponents.expand(UriComponents.java:172) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.DefaultUriBuilderFactory$DefaultUriBuilder.build(DefaultUriBuilderFactory.java:459) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.util.DefaultUriBuilderFactory.expand(DefaultUriBuilderFactory.java:204) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:800) ~[spring-web-6.2.8.jar:6.2.8]
at org.springframework.web.client.RestTemplate.getForEntity(RestTemplate.java:442) ~[spring-web-6.2.8.jar:6.2.8]
at com.running.you_run.running.service.StaticMapService.generateTrackThumbnail(StaticMapService.java:38) ~[main/:na]
... 115 common frames omitted
Caused by: java.lang.IllegalArgumentException: Not enough variable values available to expand 'E'
위 에러는 쉽게 말해 E 를 변수로 쓸 수 없다고 나오는 에러입니다. 엥..? 왜 E가 뜬금없이 나오는 걸까요? 변수라하면 파라미터 변수를 말하는데 저희는 E라는 변수를 사용하지 않았습니다. 이는 encodePolyine 이라는 메서드에서 시작됐었습니다. 경로를 인코딩을 할 때, 우연치 않게 {EA+12% ... } 이런 식으로 인코딩이 됐던 겁니다. 근데 URL에선 이를 변수 바인딩으로 사용됩니다. 중괄호 안에 변수를 넣을 수 있는 겁니다. 하지만 저희는 저런 변수를 넣지 않았죠. 단지 파라미터로 넣을 값이었을 뿐입니다. 이를 URL의 동적 치환 이라고 부릅니다.
이를 해결하기 위해선 URL로 미리 인코딩한 뒤에 쿼리 파라미터로 넘겨야 합니다. 그러니까 encodPolyine으로 경로를 인코딩한 뒤에 또 다시 URL에 맞게 인코딩을 하고 나서 요청을 보내면 변수로 인식될 일이 없어지는 겁니다. UriComponentsBuilder나 URLEncoder를 사용해서 미리 URL에 맞게 인코딩을 하면 됩니다.
결과물

출처
https://developers.google.com/maps/documentation/maps-static/start?hl=ko
https://developers.google.com/maps/billing-and-pricing/pricing?hl=ko
'서버 공부 > Spring' 카테고리의 다른 글
| [Spring]heapdump를 이용해서 메모리 누수(Memory Leak) 찾아보기 feat. Eclipse Memory Analyzer (0) | 2025.10.22 |
|---|---|
| [Spring]부하테스트 후 서버 성능 개선해보기(feat. Valkey) (8) | 2025.08.12 |
| [Spring]지도 경로 로딩 최적화 문제 해결하기 (feat. PostGIS, Ramer-Douglas-Peucker) (8) | 2025.07.12 |
| Spring에서 HTTP 요청이 들어오면 처리되는 전체적인 흐름 (0) | 2025.02.24 |
| [Spring+MongoDB]한 컬렉션에서 중복된 필드값 검증하기(feat. 고유 인덱스) (0) | 2025.02.13 |