배경
지난 추석 연휴, 우리 프로젝트가 드디어 사용자 유치를 위한 홍보 활동을 시작했다.
어떻게 홍보할 것인가에 대한 고민이 많았는데, 그 중 한 가지 솔루션은 '흥미로운 지도를 만들어 홍보'하는 것이었다.
서비스 초기에 컨텐츠 생성자를 먼저 유치하기란 쉽지 않고, 흥미롭게 들어와보는 사람이라도 조금 생긴다면 성공하는 것이기 때문이다.
이 프로젝트와 유사한 지향점을 가진, '대동타코야끼여지도', '대동풀빵여지도'를 활용하기로 했다.
몇년 전 구글맵을 기반으로 활성화되었던 오픈맵인데 현재는 많이 잊혀진 것 같았다.
이 데이터들을 우리 지도에 가져온 뒤 사용자들을 유입시켜 더 편한 방식으로 지도를 리부트시키고 싶었다.
(오래된 데이터를 자발적으로 업데이트하는 등)
팀원들과의 회의 결과, 날씨가 추워지는 요즘 '붕어빵'에 초점을 맞추어
대동풀빵여지도의 붕어빵 데이터를 가져와 '대동붕어빵여지도'를 우리 서비스에 게시하기로 했다.
(물론 이 과정에서 원 저작물의 사용 가능 여부도 확인했다.)
어떻게 데이터를 저장하는 게 좋을까?
대동풀빵여지도에는 1000개 이상의 전국 데이터가 있었고, 이 중 우리가 원하는 붕어빵(붕어빵, 잉어빵 분류) 데이터만 800개였다.
이 데이터를 저장하기 위해 해결해야 할 문제들은 다음과 같았다.
1. 구글맵에서 제공하는 KML 파일을 변환해야 한다.
2. 해당 장소 데이터에 우리가 필요로 하는 도로명주소, 법정동코드는 존재하지 않는다.
3. 도메인 간 연관 관계로 인해 DB에 직접 SQL문을 실행하기에는 너무 복잡하다.
1. GoogleMap 데이터 변환 (KMZ, KML)
GoogleMap은 저장된 장소 정보들에 대해 KMZ, KML 파일을 제공한다.
KML은 Keyhole Markup Language로, 구글 어스, 구글 지도 및 기타 응용 프로그램에 쓰이는 XML 기반의 마크업 언어 스키마이다.지형 정보 모델링 및 표현, 2차원 지도, 3차원 지구 지도에도 쓰인다.
KMZ는 KML의 압축 파일이다.
대동풀빵여지도의 구글맵을 다운로드받아 KML 파일을 열어보면 다음과 같은 내용을 확인할 수 있다.
<Placemark>
<name>경성대 2번출구 잉어빵</name>
<description><![CDATA[경성대 2번출구 잉어빵<br>팥 슈크림 세개천원 존맛<br>]]></description>
<styleUrl>#icon-seq2-0-0-1A237E</styleUrl>
<Point>
<coordinates>
129.101167,35.138032,0
</coordinates>
</Point>
</Placemark>
처음에는 Java의 XMLParser를 사용해 원하는 태그의 값들을 뽑아오는 코드를 작성했다.
하지만 해당 데이터 추출 후 처리해주어야 하는 작업(역지오코딩)도 많았기 때문에
빠르고 가볍게 코드를 작성하고 적용할 수 있도록 Python을 사용했다.
Python에서도 이를 추출할 수 있는 라이브러리를 지원하고 있었다.
# KML 문자열을 파싱
root = ET.fromstring(kml_content)
# KML의 Namespace
namespace = {'kml': '<http://www.opengis.net/kml/2.2>'}
# Placemark 요소를 찾음
placemarks = root.findall('.//kml:Placemark', namespace)
for placemark in placemarks:
name = placemark.find('.//kml:name', namespace)
coordinates = placemark.find('.//kml:coordinates', namespace)
... 등등 뽑아내고 싶은 것들
원하는 내용들을 뽑아내 텍스트 파일로 쉽게 저장할 수 있었다.
output_file_path = os.path.join(output_directory, 'output.txt')
# 텍스트 파일로 저장
with open(output_file_path, 'w') as output_file:
output_file.write(resultContent) # resultContent 에다가 kmz 에서 빼온 content 들 순서대로 담았음
2. 데이터 변환 시 역지오코딩 호출
위 KML에서 얻을 수 있는 정보는 placeMark의 이름, 좌표, 설명 이 전부였다.
하지만 우리 데이터에서 핀은 도로명 주소와 법정동 코드 정보도 가지기에, 이 정보가 추가로 필요했다.
일반적으로 사용자가 핀을 추가할 때는, 클라이언트가 Tmap API를 통해 좌표값을 역지오코딩해 해당 정보들을 제공해준다.
따라서 이번에는 직접 Tmap 역지오코딩 API를 호출해, 현재 추출한 데이터들에 필요한 정보를 보완하기로 했다.
역지오코딩이란, 지리 좌표를 사람이 읽을 수 있는 주소로 변환하는 과정을 말한다.
지오코딩이 주소를 지리적 좌표로 변환하는 과정을 말한다면, 이에 대한 반대 개념인 것이다.
chatGPT의 도움을 받아 아래와 같은 함수를 작성해 사용했다.
def reverse_geocode_tmap(api_key, longitude, latitude):
base_url = "<https://apis.openapi.sk.com/tmap/geo/reversegeocoding>"
params = {
"version": 1,
"format": "json",
"lat": latitude,
"lon": longitude,
"appKey": api_key,
}
response = requests.get(base_url, params=params)
result = response.json()
return [result["addressInfo"]["fullAddress"], result["addressInfo"]["legalDongCode"]]
위 함수로 필요한 정보를 가져오고, 기존 구글맵 데이터에서 description이 없는 경우 내용을 추가해주고,
모든 데이터가 완전하지 않기 때문에 예외가 발생하는 경우 다음 placeMark로 넘어가도록 하였다.
for placemark in placemarks:
try:
name = placemark.find('.//kml:name', namespace).text.strip()
coordinates = placemark.find('.//kml:coordinates', namespace).text.strip()
description = placemark.find('.//kml:description', namespace)
realDescription = ""
position = coordinates.split(",")
geoResult = reverse_geocode_tmap(appKey, position[0], position[1])
print(geoResult)
if description is None:
realDescription = str(name) + "에 대한 정보를 추가해주세요!"
else:
realDescription = description.text.strip()
resultContent = resultContent + "Name: " + str(name) + "\\n"
resultContent = resultContent + "Description: " + str(realDescription) + "\\n"
resultContent = resultContent + "Address: " + str(geoResult[0]) + "\\n"
resultContent = resultContent + "LegalDongCode: " + str(geoResult[1]) + "\\n"
resultContent = resultContent + "Coordinates: " + str(coordinates) + "\\n"
resultContent = resultContent + "---\\n"
except Exception as e:
print(f"occured exception {e}")
결과는 다음과 같다. 파이썬을 잘 다루는 팀원 매튜가 멋지게 진행해주었다 👍
Name: 경성대 2번출구 잉어빵
Description: 경성대 2번출구 잉어빵<br>팥 슈크림 세개천원 존맛<br>����확인 및 좌표 추가/2019.01.26<br>35.13803, 129.10116
Address: 부산광역시 남구 대연동 77
LegalDongCode: 2629010600
Coordinates: 129.101167,35.138032,0
---
Name: 가양 홈플러스 근처 잉어빵
Description: 가양역 1번출구 지나서 홈플러스 진입 직전,강남잉어빵<br>슈크림 팥 10개 2000원 (섞어서 가능) 개존맛탱이 슈크림강추 타이밍늦으면 오래기다려야함<br>����확인 및 좌표 추가/2018.12.23<br>37.56358, 126.85053
Address: 서울특별시 강서구 가양동 17-5
LegalDongCode: 1150010400
Coordinates: 126.850534,37.563589,0
---
... 쭉쭉!
3. API 요청으로 연관 관계 고려 없이 데이터를 저장하자
이제 얻어낸 데이터들을 우리 서비스에 저장하기만 하면 된다.
그동안 더미 데이터를 저장할 때에는, SQL 문을 만들어 한 번에 실행하기도 했다.
하지만 800건의 데이터에 대해, Location(좌표값 정보)을 먼저 만들고 그다음 Pin을 만드는 일은 여간 번거로운 게 아닐 것이다.
이에 대한 결론은 간단했다. 사용자가 핀을 추가할 때처럼, API 요청을 호출하면 된다.
내부의 연관 관계 사정을 생각할 필요 없이 편하게 추가하라고 API가 있는 거니까.
API 호출을 위한 도구 선택
Java 진영에서 사용할 수 있는 API 호출 도구를 사용하기로 했다.
간단한 작업에 거대한 도구를 쓸 필요는 없다고 생각해, HttpURLConnection을 사용해 빠르게 구현하려고 했다.
하지만 생각보다 쉽지 않았다.
Multipart 형식의 요청은 어떻게 보내야 하지?
우리의 Pin 추가 API는 단순히 Json만 받는 형태가 아니었다.
아래처럼 Multipart 형식으로, 핀에 추가할 이미지 파일들과 기본 요청 정보(이름, 설명, ..) Json을 함께 받는 형식이었다.
@LoginRequired
@PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<Void> add(
AuthMember member,
@RequestPart(required = false) List<MultipartFile> images,
@RequestPart PinCreateRequest request
) {
long savedId = pinCommandService.save(member, images, request);
return ResponseEntity.created(URI.create("/pins/" + savedId))
.build();
}
HttpURLConnection을 사용해 직접 Request 객체를 작성하는데, 위와 같은 요청 형식을 맞추는 게 생각처럼 쉽지 않았다.
그래서 잘 만들어진 바퀴를 사용하고자, Spring의 WebClient를 사용했다.
소셜 로그인 기능을 구현할 때, HttpClient를 사용한 적이 있다. 보다 깔끔한 코드로 작성할 수 있었다.
하지만 지금은 프로덕션 코드를 작성하는 것이 아니고 인터페이스, 클래스 분리 없이 빠르게 코드를 작성해 실행을 확인할 필요가 있었다.
그래서 WebClient를 선택했다.
Spring 프로젝트를 만들어서 아래와 같은 코드를 작성했다.
(짧은 시간 안에 실행 확인부터 급하게 구현하다보니 클린 코드, 확장성은 지키지 못한 부분이 많다)
Spring 프로젝트를 실행한 뒤 PostMan에서 /pins API를 호출하면,
프로젝트에 있는 fileName에 해당하는 텍스트 파일을 요청 객체로 파싱하여 우리 서비스의 API 호출을 해주는 흐름이다.
@RestController
public class WebController {
private static ObjectMapper objectMapper = new ObjectMapper();
private final PinService pinService;
private String accessToken;
public WebController(
@Value("${authorization.access-token}")
String accessToken,
PinService pinService
) {
this.accessToken = accessToken;
this.pinService = pinService;
}
@PostMapping("/pins")
public void insert(Long topicId, String fileName) throws Exception {
URI url = UriComponentsBuilder.fromHttpUrl("https://mapbefine.com/api/pins")
.build()
.toUri();
WebClient webClient = WebClient.builder().build();
List<PinCreateRequest> pins = pinService.parsePins(topicId, fileName);
pins.forEach(pin -> {
try {
sendPost(url, webClient, pin);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
});
}
private void sendPost(URI url, WebClient webClient, PinCreateRequest pinCreateRequest) throws JsonProcessingException {
MultiValueMap<String, HttpEntity<?>> multipartBody = buildMultipartBody(pinCreateRequest);
try {
Mono<String> responseBody = webClient.post()
.uri(url)
.header(AUTHORIZATION, "Bearer " + accessToken)
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(multipartBody))
.retrieve()
.onStatus(
HttpStatus.INTERNAL_SERVER_ERROR::equals,
response -> response.bodyToMono(String.class).map(Exception::new))
.bodyToMono(String.class);
System.out.println(responseBody.block());
} catch (Exception exception) {
exception.printStackTrace();
}
System.out.println("done");
}
}
MultipartBodyBuilder를 사용하니, 아래와 같이 손쉽게 Request 객체를 만들 수 있었다.
아래 링크를 참고했다.
Upload a File with WebClient | Baeldung
private MultiValueMap<String, HttpEntity<?>> buildMultipartBody(PinCreateRequest pinCreateRequest) throws JsonProcessingException {
String pinJson = objectMapper.writeValueAsString(pinCreateRequest);
MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder();
multipartBodyBuilder.part("request", pinJson, MediaType.APPLICATION_JSON);
return multipartBodyBuilder.build();
}
먼저 하나의 데이터만 테스트로 요청을 보내봤을 때, 데이터가 잘 들어가는 것을 확인했다.
결과
클릭 한 번으로, 연관 관계가 복잡한 800개의 데이터를 1분 안에 넣는 데 성공했다.
요청을 보낼 때, 브라우저를 통해 발급받은 Access Token을 사용하기 때문에 만료 시간 전에 빠르게 수행하는 것이 중요했다.
그래서 데이터를 보내기 전에 역지오코딩까지 미리 해서 데이터 파일을 완전하게 만들어놓은 것도 좋은 선택이었다.
앞으로도 외부의 장소 데이터를 사용해 서비스 운영 측에서 지도를 만들 수도 있기 때문에
자동화 방식으로 진행할 필요가 있다고 생각했고, 목적에 대해 성공적으로 구현한 것 같다.
또, 개발/운영 서버 환경 분리의 소중함을 느꼈다. 개발 서버에서 먼저 안심하고 테스트해볼 수 있었기 때문이다.
그렇지만 당장은 빠른 처리가 목적이었기 때문에 깔끔한 코드 작성이나 확장성, 성능 고도화에는 집중하지 않았다.
하지만 텍스트 파일 대신 CSV 파일을 쓰고, 데이터 파일 <-> API 호출 로직 사이 불필요한 변환 과정을 줄여서
저장하는 데 걸리는 시간을 단축시키면 더 좋을 것 같다!
대동붕어빵여지도에서 우리 동네 붕어빵을 확인하고 싶다면? https://mapbefine.com/topics/62
'우아한테크코스 > 프로젝트' 카테고리의 다른 글
팀 프로젝트 Logback 로깅 환경 개선기 (환경 분리, 롤링 설정 등) (0) | 2023.10.14 |
---|---|
[레벨3] 프로젝트 괜찮을지도 1주차 회고 (2) | 2023.07.01 |