구현 이유
이동 소요 시간, 가격, 주택 유형 등 사용자 조건에 맞춘 부동산 매물 추천 프로젝트를 진행하기 위해 먼저 부동산 매물들의 매매, 전/월세 실거래가 정보들이 필요했습니다. 그래서 공공데이터포털에서 Open Api를 사용해 데이터를 수집하기로 했습니다.
사용 스택
- Spring boot 3.x
- Kotlin
- MongoDB
- RestClient(SpringBoot 3.2, Spring 6.1 이상부터 지원)
MongoDB 선택 이유비정형 데이터의 필요성
1. 비정형 데이터의 필요
매매는 거래 금액이, 전/월세는 보증금, 월세 금액이 필요한데 RDB를 사용하면 필요 이상으로 정규화가 필요해 편의상 MongoDB를 생각했습니다.
2. 읽기 성능의 중요성
서비스의 특성상 CRUD 중 Read의 사용 빈도가 굉장히 높고 나머지 기능들은 굉장히 적은 사용량이 예상됩니다. 또 복잡한 조인관계가 필요하지 않을 때 MongoDB가 좋은 선택이라 생각합니다.
공공데이터의 Open Api를 사용해보자
공공데이터 포털
국가에서 보유하고 있는 다양한 데이터를『공공데이터의 제공 및 이용 활성화에 관한 법률(제11956호)』에 따라 개방하여 국민들이 보다 쉽고 용이하게 공유•활용할 수 있도록 공공데이터(Datase
www.data.go.kr
먼저 부동산 매물들이 실거래가들을 수집하기 위한 Api들 입니다. 사용한 API들은
- 아파트 매매 실거래가 상세 자료
- 아파트 전월세 실거래가 자료
- 연립다세대 매매 실거래가 자료
- 연립다세대 전월세 실거래가 자료
- 오피스텔 매매 실거래가 자료
- 오피스텔 전월세 실거래가 자료
- 단독다가구 매매 실거래가 자료
- 단독다가구 전월세 실거래가 자료
아파트 매매 실거래가 자료는 지번에 *처리가 되어 일부분밖에 제공을 안합니다. 그래서 상세 자료를 써야 전체 지번 정보를 받아올 수 있으니 참고 바랍니다.
데이터 사용을 위한 활용신청
API를 사용하기 위해 먼저 활용신청을 먼저 해야합니다. 활용신청 버튼은 2곳에서 할 수 있습니다.
1. 목록 페이지에서 오른쪽 아래 신청 버튼
2.상세 페이지의 오른쪽 위에 신청 버튼
제가 사용하는 데이터는 심의여부가 자동승인이라 활용목적만 간단히 입력해도 간단하게 처리가 됩니다. 그럼 이제 신청했던 목록들로 화면이 이동되면서 완료됩니다.
신청 목록에서 사용할 데이터 상세 페이지로 들어가면 저희가 필요한 End Point, 인증키들, 참고문서를 확인할 수 있습니다.
RestClient를 사용한 HTTP 요청 보내기(Encoding Key)
Spring에서 HTTP요청을 보내는 방법은 RestTemplate, WebClient ,RestClient 3가지가 있습니다. SpringBoot 3.2 이하의 버전을 사용하시는 분들은 WebClient를 추천드립니다. 공식문서에서도 RestTemplate보다 WebClient를 권장했으며 최신 버전에서는 RestClient가 개발됬으니 사용하시면 됩니다.
보내는 방법은 크게 3가지가 있습니다.
UriComponentsBuilder
@Component
class OpenApiClient {
@Value("\${openApi.secretKey}")
lateinit var secretKey: String
fun sendRequest(cityCode: Int, dealDate: Int) {
val uri = UriComponentsBuilder
.fromHttpUrl("https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade")
.queryParam("serviceKey", secretKey)
.queryParam("LAWD_CD", cityCode) // ex) 11110
.queryParam("DEAL_YMD", dealMonth) // ex) 202410
.queryParam("numOfRows", 1000)
.build(true) // encoded
.toUri()
val response = RestClient.create()
.get()
.uri(uri)
.retrieve()
.body(String::class.java)
}
}
공공데이터에 Get 요청을 보내 응답을 받는 코드입니다. 공공데이터에서 인증키는 Encoding 유무에 따라 2가지를 제공하는데 개인적으론 Encoding Key를 사용하는걸 추천드립니다. 이유는 Decoding Key를 사용할 때 자세히 설명하겠습니다.
먼저 현재 코드는 UriComponentBuilder를 사용해 URI를 조립 후 사용하는 코드입니다.
UriComponentsBuilder는 실행될 때 Encoding을 자동으로 실행합니다. 그래서 Encoding Key를 사용하면 인코딩이 2번되서 오류가 발생합니다. 그러니 build(true)를 사용해 인코딩 유무를 알려줍시다.
아니면 RestClient를 사용할 때 Encoding이 됬다는 걸 설정해서 작동시키는 방법도 있습니다.
Custom RestClient
@Component
class OpenApiClient {
@Value("\${openApi.secretKey}")
lateinit var secretKey: String
fun sendRequest(cityCode: Int, dealDate: Int) {
val uriBuilderFactory = DefaultUriBuilderFactory().also {
it.encodingMode = DefaultUriBuilderFactory.EncodingMode.NONE
}
val restClient = RestClient.builder()
.baseUrl("https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade")
.uriBuilderFactory(uriBuilderFactory)
.build()
val response = restClient.get()
.uri { uriBuilder -> uriBuilder
.queryParam("serviceKey", secretKey)
.queryParam("LAWD_CD", cityCode)
.queryParam("DEAL_YMD", dealMonth)
.queryParam("numOfRows", 100)
.build()
}
.retrieve()
.body(String::class.java)
}
}
Custom RestClient를 사용해 EncodingMode.NONE을 설정하여 추가적인 인코딩을 하지 않는 방법입니다. 상세한 요청 제어가 필요할 때 추가적으로 설정하여 사용하시면 됩니다.
URI Constuctor
가장 간단하게 요청을 보내는 방법입니다. URI class를 사용해서 바로 URI를 만들어서 변수로 넘겨버리면됩니다.
fun sendRequest(cityCode: CityCode, dealDate: Int) {
val uri = URI("https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade?serviceKey=${Secret.ENCODING_SERVICE_KEY.value}&LAWD_CD=${cityCode.code}&DEAL_YML=${dealDate}&numOfRows=9999")
val response = RestClient.create()
.get()
.uri(uri)
.retrieve()
.body(String::class.java)
println(response)
}
RestClient를 사용한 HTTP 요청 보내기(Decoding Key)
Decoding Key를 쓰더라도 결국 인코딩을 해서 요청을 보내게됩니다. 그래서 인코딩을 언제, 누가 하냐에 따라 코드가 달라지게됩니다.
UriComponentsBuiler는 자동으로 인코딩을 해준다고 알고있으니 build()를 하면 알아서 인코딩해서 요청을 보낼 줄 알았습니다. 왜냐면 build method의 기본값이 false이기 때문입니다. 하지만 Decoding Key를 넣고 실행을 시켜보면 등록되지 않은 인증키라는 에러가 발생합니다.
fun sendRequest() {
val uri = UriComponentsBuilder.fromHttpUrl("https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade")
.queryParam("serviceKey", Secret.DECODING_SERVICE_KEY)
.queryParam("LAWD_CD", 11110)
.queryParam("DEAL_YMD", 202411)
.queryParam("numOfRows", 9999)
.build()
.toUri()
val response = RestClient.create()
.get()
.uri(uri)
.retrieve()
.body(String::class.java)
println(uri)
println(response)
}
실제로 시도해 후 URI를 출력해보면 ServiceKey에 들어있는 + 가 인코딩인 안됩니다. 몇몇 특수문자들은 uri에서 예약어로 사용되어 시스템에 혼동을 줄 수 있어 인코딩이 되어야하는데 현재 인코딩이 안되어있다고 설정했는데도 제대로 인코딩이 안된걸 확인할 수 있습니다.
해결방법은 인코딩 해서 파람에 넣으면 됩니다. 그리고 인코딩을 했으니 다시 build(true)로 바꿔서 보내면 정상 응답을 받을 수 있습니다.
import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class OpenApi {
fun sendRequest() {
val encodingServiceKey = URLEncoder.encode(Secret.DECODING_SERVICE_KEY.value, StandardCharsets.UTF_8.toString())
val uri = UriComponentsBuilder.fromHttpUrl("https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade")
.queryParam("serviceKey", encodingServiceKey)
.queryParam("LAWD_CD", 11110)
.queryParam("DEAL_YMD", 202411)
.queryParam("numOfRows", 9999)
.build(true) // true로 변경
.toUri()
val response = RestClient.create()
.get()
.uri(uri)
.retrieve()
.body(String::class.java)
println(uri)
println(response)
}
}
UriBuilder는 왜 +를 인코딩 안해줄까
Encoding, Decoding Key들의 사용 방법을 알아봤으니 이제 왜 +가 인코딩이 안되는지 알아봅시다.
val response = RestClient.create(BASE_URL)
.get()
.uri {
it.queryParam("serviceKey", Secret.DECODING_SERVICE_KEY.value)
.queryParam("LAWD_CD", 11110)
.queryParam("DEAL_YMD", 202411)
.queryParam("numOfRows", 9999)
.build()
}
.retrieve()
.body(String::class.java)
먼저 RestClient의 uri메서드를 살펴봅시다. uri메서드를 살펴보면
public interface UriSpec<S extends RequestHeadersSpec<?>> {
S uri(URI uri);
S uri(String uri, Object... uriVariables);
S uri(String uri, Map<String, ?> uriVariables);
S uri(String uri, Function<UriBuilder, URI> uriFunction);
S uri(Function<UriBuilder, URI> uriFunction);
}
4개의 생성방법이 있습니다. 그리고 저희는 UriComponentsBuilder를 사용해 접근한 첫 번째, 함수를 넘겨준 5번째 방법에 대해 알아봤습니다.
public RestClient.RequestBodySpec uri(Function<UriBuilder, URI> uriFunction) {
return this.uri((URI)uriFunction.apply(DefaultRestClient.this.uriBuilderFactory.builder()));
}
public RestClient.RequestBodySpec uri(URI uri) {
this.uri = uri;
return this;
}
해당 코드들의 구현부로 이동하면 5개의 코드들이 있는데 저희가 사용하는 2개에 대해서만 알아보기 위해 나머지는 제외했습니다.
먼저 URI를 넘겨주는 코드를 보면 일반적인 update 코드입니다.
근데 함수를 넘겨주는 uri 메서드를 보면 Function<T, R>을 매개변수로 받는데 이것은 Java의 표준 인터페이스로 입력타입, UriBuilder와 반환 타입 URI가 설정된 형태입니다. 그리고 apply를 통해 UriBuilder를 변환 후 URI로 타입 캐스팅 캐스팅 해 최종적으로 원하는 반환값을 설정합니다.
그리고 DefaultRestClient.this.uriBuilderFactory.builder() 의 builder() 메서드를 들어가 구현부를 들여다보면
DefaultUriBuilderFactory를 확인할 수 있습니다.
Custom RestClient를 사용해 EncodingMode.NONE을 설정할 때 봤던 반가운 녀석입니다.
들어가보면 결국 저희는 UriComponentsBuilder의 존재를 확인할 수 있습니다. 이로서 저희는 RestClient에서 URI를 함수형태로 작성할 때 UriComponentsBuilder를 사용해 URI를 생성한다는 걸 확인했습니다.
public UriBuilder builder() {
return new DefaultUriBuilder("");
}
public DefaultUriBuilder(String uriTemplate) {
this.uriComponentsBuilder = this.initUriComponentsBuilder(uriTemplate);
}
UriComponentsBuilder는 왜 +를 인코딩 안해줄까
그럼 이제 UriComponentsBuilder를 또 들여다봅시다.
val uri = UriComponentsBuilder
.fromHttpUrl("https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade")
.queryParam("serviceKey", Secret.DECODING_KEY.value)
.queryParam("LAWD_CD", cityCode)
.queryParam("DEAL_YMD", dealDate)
.queryParam("numOfRows", 9999)
.build()
.encode() // 여기
.toUri()
public final UriComponents encode() {
return this.encode(StandardCharsets.UTF_8);
}
HierarchicalUriComponents
public HierarchicalUriComponents encode(Charset charset) {
if (this.encodeState.isEncoded()) {
return this;
} else {
String scheme = this.getScheme();
String fragment = this.getFragment();
String schemeTo = scheme != null ? encodeUriComponent(scheme, charset, HierarchicalUriComponents.Type.SCHEME) : null;
String fragmentTo = fragment != null ? encodeUriComponent(fragment, charset, HierarchicalUriComponents.Type.FRAGMENT) : null;
String userInfoTo = this.userInfo != null ? encodeUriComponent(this.userInfo, charset, HierarchicalUriComponents.Type.USER_INFO) : null;
String hostTo = this.host != null ? encodeUriComponent(this.host, charset, this.getHostType()) : null;
BiFunction<String, Type, String> encoder = (s, type) -> { // encoder
return encodeUriComponent(s, charset, type);
};
PathComponent pathTo = this.path.encode(encoder);
MultiValueMap<String, String> queryParamsTo = this.encodeQueryParams(encoder); // 여기
return new HierarchicalUriComponents(schemeTo, fragmentTo, userInfoTo, hostTo, this.port, pathTo, queryParamsTo, HierarchicalUriComponents.EncodeState.FULLY_ENCODED, (UnaryOperator)null);
}
}
static String encodeUriComponent(String source, Charset charset, Type type) {
if (!StringUtils.hasLength(source)) {
return source;
} else {
Assert.notNull(charset, "Charset must not be null");
Assert.notNull(type, "Type must not be null");
byte[] bytes = source.getBytes(charset); // 아스키코드로 변환
boolean original = true;
byte[] var5 = bytes;
int var6 = bytes.length;
int var7;
int b;
for(var7 = 0; var7 < var6; ++var7) {
b = var5[var7];
if (!type.isAllowed(b)) { // isAllow를 통한 구문 규칙 확인
original = false;
break;
}
}
if (original) {
return source;
} else {
ByteArrayOutputStream baos = new ByteArrayOutputStream(bytes.length);
byte[] var13 = bytes;
var7 = bytes.length;
for(b = 0; b < var7; ++b) {
byte b = var13[b];
if (type.isAllowed(b)) {
baos.write(b);
} else {
baos.write(37);
char hex1 = Character.toUpperCase(Character.forDigit(b >> 4 & 15, 16));
char hex2 = Character.toUpperCase(Character.forDigit(b & 15, 16));
baos.write(hex1);
baos.write(hex2);
}
}
return StreamUtils.copyToString(baos, charset);
}
}
}
private MultiValueMap<String, String> encodeQueryParams(BiFunction<String, Type, String> encoder) {
int size = this.queryParams.size();
MultiValueMap<String, String> result = new LinkedMultiValueMap(size);
this.queryParams.forEach((key, values) -> {
String name = (String)encoder.apply(key, HierarchicalUriComponents.Type.QUERY_PARAM);
List<String> encodedValues = new ArrayList(values.size());
Iterator var6 = values.iterator();
while(var6.hasNext()) {
String value = (String)var6.next();
encodedValues.add(value != null ? (String)encoder.apply(value, HierarchicalUriComponents.Type.QUERY_PARAM) : null); // 여기
}
result.put(name, encodedValues);
});
return CollectionUtils.unmodifiableMultiValueMap(result);
}
UriComponentsBuilder의 encode 메서드를 들어가보면 함수형 인터페이스인 encoder가 있습니다.
그리고 저희가 찾던 encodeQueryParams 메서드도 있습니다.
이제 인코딩의 흐름을 알아보면 encoder에서 인코딩 방식(CharSet)을 설정 후 해당 방식을 사용할 때 사용할 수 없는 특수문자들을 type.isAllowed메서드를 통해 확인 후 인코딩합니다.
그래서 밑에 encodeQueryParams 메서드를 보면 encoder.apply를 통해 함수형 인터페이스에 설정된 함수를 실행하고 해당 함수에 QUERY_PARM 타입을 넘겨주면서 해당 타입에서 쓸 수 없는 특수을 특정합니다.
QUERY_PARAM {
public boolean isAllowed(int c) {
if (61 != c && 38 != c) {
return this.isPchar(c) || 47 == c || 63 == c; // 여기
} else {
return false;
}
}
},
protected boolean isPchar(int c) {
return this.isUnreserved(c) || this.isSubDelimiter(c) || 58 == c || 64 == c; // isSubDelimiter()
}
protected boolean isSubDelimiter(int c) {
return 33 == c || 36 == c || 38 == c || 39 == c || 40 == c || 41 == c || 42 == c || 43 == c || 44 == c || 59 == c || 61 == c;
}
해당 타입의 메서드들을 들어가보면 특수문자들의 허용 목록들이 아스키코드로 표현되있습니다.
해당 아스키코드들을 다시 특수문자로 변환하면
33 | ! | 느낌표 |
36 | $ | 달러 |
39 | ' | 작은 따옴표 |
40 | ( | 왼쪽 괄호 |
41 | ) | 오른쪽 괄호 |
42 | * | 별표 |
43 | + | 더하기 |
44 | , | 쉼표 |
59 | ; | 세미콜론 |
다음과 같은 표가 나옵니다. 61, 38은 isSubDelimitier에서 존재하지만 isAllowed에서 불허되니 제외했습니다. 그럼 실제로 더 특수문자들을 UriComponentsBuilder에서 인코딩을 안해주는지 확인해봅시다.
fun main() {
val uri = UriComponentsBuilder.fromHttpUrl("https://localhost:8080")
.queryParam("special", "!$'()*,;")
.build()
.encode()
.toUri()
println(uri)
}
다음과 같이 uri를 조립 후 실행해보면
실제로 입력한 특수문자들이 그대로 출력되는 것을 확인할 수 있습니다.
이렇게 URI의 param에서는 다양한 특수문자가 허용되는데 이유는 하위 구분자라는 이름으로 예약어와 같이 고유한 의미와 용도를 가지기 때문입니다. + 는 공백을 나타내기 위해 사용되고 ,는 list로 값을 넘길 때 여러 값을 하나의 파라미터로 전달하는 방식으로 사용되는 등 각각의 의미가 있기 때문입니다.
결론
공공데이터에 http요청을 날릴 때는 어차피 인코딩된 인증키를 사용해 요청을 보내기 때문에 처음부터 인코딩된 인증키를 사용하는 게 좋은 것 같습니다 :)
Xml을 Json으로 변환해보자
드디어 Http요청에서 벗어나 데이터 변환을 시도해봅시다.
정상적인 요청 방법을 알아봤으니 이에 대한 응답을 확인해 보면 xml로 응답을 받습니다. 하지만 저희는 Json형태로 변환 후 최종적으로 저희가 데이터를 저장하기 위한 Object가 필요합니다. 그럼 Xml to Json 부터 해봅시다.
라이브러리 추가
implementation 'org.json:json:20231013'
Xml을 Json으로 변환할 때 사용되는 라이브러리입니다. 버전은 원하시는 버전을 선택하시면됩니다.
JsonNode
Xml 형식의 응답을 보면 태그의 중첩으로 이루어져있는데 이것을 저희는 JsonNode형식으로 가져와 path 메서드를 통해 접근할겁니다.
val json = XML.toJSONObject(xml)
val rootNode = ObjectMapper().readTree(json.toString())
rootNode.path("OpenAPI_ServiceResponse")
.takeIf { !it.isMissingNode }
?.let { errorNode ->
logger.error { xml }
val authMsg = errorNode.path("cmmMsgHeader").path("returnAuthMsg").asText()
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "AuthMsg: $authMsg")
}
val itemsNode = rootNode.path("response")path("body")path("items")path("item")
return when {
itemsNode.isMissingNode || (itemsNode.isArray && itemsNode.size() == 0) -> emptyList()
itemsNode.isArray -> itemsNode.toList()
else -> listOf(itemsNode)
}
rootNode를 설정 후 바로 예외처리를 했습니다.
이후 item node경로로 들어가 list들을 반환해줍니다. xml을 json으로 변경 시 단일 item이면 해당 값을 리스트가 아닌 객체로 변환하기 때문에 else구문에서 list로 변경해줘야합니다. 그리고 값에 대한 추가는 없을 예정이니 Array형태를 list로 변경해 저희가 사용하지 편한 형태로 변경해줍니다.
이제 item에 대한 JsonNode list가 만들어졌으니 해당 list에서 값을 빼서 Entity로 변환해 사용하면됩니다 :)
fun from(jsonNode: JsonNode, state: String, city: String, buildingType: BuildingType) = Rent(
state = state,
city = city,
district = jsonNode.path("umdNm").asText(),
jibun = jsonNode.path("jibun").asText(),
buildingType = buildingType,
buildYear = jsonNode.path("buildYear").asInt(),
exclusiveArea = jsonNode.path("excluUseAr").asDouble().toInt(),
floor = jsonNode.path("floor").asInt(),
monthlyAmount = (jsonNode.path("monthlyAmount").asText().takeIf { it.isNotBlank() }?.replace(",", "")?.toInt() ?: 0),
deposit = jsonNode.path("deposit").asText().replace(",", "").toInt(),
startDate = LocalDate.now(),
endDate = LocalDate.now(),
dealDate = LocalDate.of(
jsonNode.path("dealYear").asInt(),
jsonNode.path("dealMonth").asInt(),
jsonNode.path("dealDay").asInt()
),
)
kotlin을 사용한 생성자라 Java와는 다른 형태입니다. Java에서도 생성자나 정적 팩토리 메서드 패턴으로 객체를 생성하시면됩니다!!
후기
이번 기회에 인증키 때문에 URI의 인코딩에 대해 많이 공부하게 됬습니다. MVC패턴을 사용하며 외부 API를 사용하는 기회도 적었고 특수문자를 인코딩 해 요청에 담은 경험은 이번이 처음이라 시간도 걸리고 많은 걸 배워갔습니다. 이후에도 이번 포스팅처럼 깊에 파고들어 구조를 이해하는데 도움이 될 것 같습니다.