Spring Boot WebFlux로 data.go.kr에서 주가 시세 가져오기
Git Code 버전 : 0.0.5-SNAPSHOT
Spring Boot와 WebFlux를 활용해 data.go.kr에서 주가 데이터를 가져오는 기능을 구현한 내용을 정리한다. 우선 Webflux를 build.gradle에 추가시켜 해당 라이브러리를 프로젝트에 포함시킨다.
- build.gradle
implementation 'org.springframework.boot:spring-boot-starter-webflux'
RESTful interface로 데이터 요청 파라미터 관리를 위한 dto class를 저장할 src/main/java/io/persional/stock 아래 dto 폴더를 생성한다. dto 폴더 아래 OpenApiReqParam.java 파일을 아래와 같이 생성해 준다.
- OpenApiReqParam.java
package io.personal.stock.dto;
import org.springframework.util.LinkedMultiValueMap;
import lombok.Data;
@Data
public class OpenApiReqParam {
private String endPointURL; // RESTful 요청 서비스 End Point URL
private String detailService; // End Point에서 제공하는 서비스명
private LinkedMultiValueMap<String, String> queryParam; // data 요청시 data.go.kr에서 정의한 파라미터 저장 변수
}
Webflux의 WebClient를 사용하여 data를 가져오는 Service를 구현한다. 아래코드에 인코딩 관련 method를 구현했는데 data.go.kr에서 제공한 인코딩 된 인증키를 사용하면 key값이 이상하게 변경되면서(일부 특수문자들이 다르게 변경이 되는 문제가 있어 디코딩된 값을 직접 Encoding 해서 사용하기 위해 구현한 method이다.
- OpenApiService.java
package io.personal.stock.service;
import io.personal.stock.dto.OpenApiReqParam;
public interface OpenApiService {
public String getOpenApiData(OpenApiReqParam reqParam);
public String encodingString(String targetString);
public String encodingString(String targetString, String encodeType);
}
- OpenApiServiceImpl.java
package io.personal.stock.service.impl;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.DefaultUriBuilderFactory;
import io.personal.stock.dto.OpenApiReqParam;
import io.personal.stock.service.OpenApiService;
import lombok.extern.log4j.Log4j2;
@Log4j2
@Service
public class OpenApiServiceImpl implements OpenApiService {
public String getOpenApiData(OpenApiReqParam reqParam) {
DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(reqParam.getEndPointURL());
factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE);
WebClient webClient = WebClient.builder()
.uriBuilderFactory(factory)
.baseUrl(reqParam.getEndPointURL())
.build();
log.debug("getOpenApiData getEndPointURL : {}", reqParam.getEndPointURL());
log.debug("getOpenApiData getDetailService : {}", reqParam.getDetailService());
log.debug("getOpenApiData serviceKey : {}", reqParam.getQueryParam().get("serviceKey"));
String ret = webClient.get()
.uri(uriBuilder -> uriBuilder
.path(reqParam.getDetailService())
.queryParams(reqParam.getQueryParam())
.build())
.retrieve()
.bodyToMono(String.class)
.block();
return ret;
}
public String encodingString(String targetString) {
return encodingString(targetString, "UTF-8");
}
public String encodingString(String targetString, String encodeType) {
String encodingString = "";
try {
encodingString = URLEncoder.encode(targetString, encodeType);
} catch (UnsupportedEncodingException e) {
log.error("UnsupportedEncodingException {}", e.toString());
} catch (Exception e) {
log.error("EncodingString error : {}", e.getMessage());
}
return encodingString;
}
}
OpenApiReqParam에 담는 정보는 data.go.kr 호출에 필요한 파라미터 중 인증키와 End Point 정보를 가져오기 위해 사전에 JPA를 사용하여 DB에서 정보를 가져오기 위한 Service를 추가로 구현한다. 이를 위해 기존에 method 추가 없이 정의만 한 Repository Interface에 아래와 같이 findByCategory를 추가한다.
myBatis의 경우는 "select * from config where category = ''" 이런 SQL문을 직접 작성해 반영해야 한다. 그러나 JPA의 경우는 findBy가 where 절의 역할을 하고 칼럼명을 그대로 입력하면(Camel 표기법으로 필드명 네이밍함) JPA가 알아서 앞선 SQL문으로 DB를 조회 데이터를 반환한다.
- ConfigRepository
@Repository
public interface ConfigRepository extends JpaRepository<Config, Long> {
List<Config> findByCategory(String category); // 이번에 추가
}
추가된 Repository method를 사용해 인증키와 data.go.kr의 RESTful URL 주소를 읽어오는 Service를 ConfigService에 추가해 준다.
- ConfigService.java
public HashMap<String, String> getConfigData(String category); // 추가
- ConfigServiceImpl.java
@Autowired
OpenApiService openApiService;
public HashMap<String, String> getConfigData(String category) {
List<Config> list = configRepo.findByCategory(category);
HashMap<String, String> data = new HashMap<>();
list.forEach(conf -> {
if (conf.getConfName().equals("CALLBACK_URL")) {
data.put(conf.getConfName(), conf.getConfValue());
} else if (conf.getConfName().equals("AUTH_KEY")) {
data.put(conf.getConfName(), openApiService.encodingString(conf.getConfValue()));
}
});
log.debug("baseUri : {}", data.get("CALLBACK_URL"));
log.debug("servicekey : {}", data.get("AUTH_KEY"));
return data;
}
위 기능을 테스트하기 위한 Controller인 StockController.java 아래와 같이 추가해 주고 stock.html 파일을 추가해 준다.
- StockController.java
package io.personal.stock.controller;
import java.util.HashMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.personal.stock.dto.OpenApiReqParam;
import io.personal.stock.service.ConfigService;
import io.personal.stock.service.OpenApiService;
import lombok.extern.log4j.Log4j2;
@Log4j2
@Controller
public class StockController {
@Autowired
ConfigService configService;
@Autowired
OpenApiService openApiService;
@GetMapping(value = "/stock")
public String stockMain(Model model) {
return "stock";
}
@GetMapping(value = "/stock/getStockPriceInfo")
@ResponseBody
public ResponseEntity<JsonNode> getStockPriceInfo(@RequestParam String itmsNm, @RequestParam int pageNo,
@RequestParam int numOfRows) {
// DB로부터 data.go.kr 접속을 위한 인증키, URL 정보를 가져온다.
HashMap<String, String> data = configService.getConfigData("STOCK_INFO");
LinkedMultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("serviceKey", data.get("AUTH_KEY"));
params.add("numOfRows", Integer.toString(numOfRows));
params.add("resultType", "json");
params.add("itmsNm", openApiService.encodingString(itmsNm, "UTF-8"));
OpenApiReqParam reqParam = new OpenApiReqParam();
reqParam.setEndPointURL(data.get("CALLBACK_URL"));
reqParam.setDetailService("/getStockPriceInfo");
reqParam.setQueryParam(params);
String response = openApiService.getOpenApiData(reqParam);
log.debug("response : {}", response);
ObjectMapper objectMapper = new ObjectMapper();
JsonNode items = null;
try {
JsonNode jsonNode = objectMapper.readTree(response);
items = jsonNode.path("response").path("body").path("items").path("item");
log.debug("items string : {}", items.toString());
if (items.isMissingNode()) {
log.warn("items 노드를 찾을 수 없습니다.");
return ResponseEntity.notFound().build(); // 404 Not Found 응답
}
} catch (JsonProcessingException e) {
log.error("JSON 파싱 오류: {}", e.getMessage());
return ResponseEntity.badRequest().body(null); // 400 Bad Request 응답
} catch (Exception e) {
log.error("기타 오류: {}", e.toString());
return ResponseEntity.internalServerError().body(null); // 500 Internal Server Error 응답
}
return ResponseEntity.ok(items);
}
}
- stock.html
<!DOCTYPE html>
<html lang="ko">
<head th:replace="~{layouts/header :: headerFragment(~{::title})}">
<title>Stock</title>
</head>
<body class="sb-nav-fixed">
<nav th:replace="~{layouts/top :: top-nav}"></nav>
<div id="layoutSidenav">
<!-- Left Side Menu-->
<div th:replace="~{layouts/left-side :: side-nav}"></div>
<div id="layoutSidenav_content">
<main>
<div class="container-fluid px-4">
<h1 class="mt-4">Stock</h1>
<ol class="breadcrumb mb-4">
<li class="breadcrumb-item active">Stock</li>
</ol>
<div class="row">
<div class="md-4">
<input
type="text"
id="itmsNm"
placeholder="종목명을 입력하세요"
autocomplete="off"
list="companiesList" />
<datalist id="companiesList"> </datalist>
<select id="numOfRows" name="selbox">
<option value="60">60</option>
<option value="90">90</option>
<option value="180">180</option>
<option value="365">365</option>
<option value="730">730</option>
</select>
<button id="getStockInfo" class="btn-primary">조회</button>
</div>
</div>
<div class="row"></div>
<div class="row">
<div class="md-12">
<div class="card-header">
<i class="fas fa-table me-1"></i>
DataTable Example
</div>
<div class="card-body">
<table id="itemTable" class="display data-table">
<thead>
<tr>
<th>기준일</th>
<th>이름</th>
<th>종가</th>
<th>전일등락</th>
<th>시가</th>
<th>고가</th>
<th>저가</th>
<th>거래량</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<footer th:replace="~{layouts/footer :: footerFragment}"></footer>
</div>
</div>
<th:block th:replace="~{layouts/scripts :: scriptFragment}"></th:block>
<!-- ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<script type="text/javascript">
$(document).ready(function () {
var table = $("#itemTable").DataTable({
destroy: true, // 테이블을 재초기화할 수 있도록 설정
searching: false, // 검색 기능 비활성화
paging: true, // 페이지네이션 활성화
ordering: true, // 정렬 활성화
});
$("#getStockInfo").click(function (e) {
console.log("버튼이 클릭되었습니다."); // 로그를 통해 클릭 이벤트 확인
getStockPriceInfo(table);
});
const $itmsNm = $("#itmsNm");
initializeInputEvents($itmsNm);
});
function getStockPriceInfo(table) {
if ($("#itmsNm").val() === "") {
alert("종목명을 입력해주세요!");
return;
}
let jsonData = {
itmsNm: $("#itmsNm").val(),
numOfRows: $("#numOfRows").val(),
pageNo: 1,
};
$.ajax({
url: "/stock/getStockPriceInfo",
type: "GET",
data: jsonData,
success: function (items) {
table.clear().draw();
// items 배열을 순회하며 HTML 문자열 생성
items.forEach(function (item) {
table.row
.add([
item.basDt,
item.itmsNm,
item.clpr,
item.vs,
item.mkp,
item.hipr,
item.lopr,
item.mrktTotAmt,
])
.draw();
});
$("#itemTable").DataTable();
},
error: function (xhr, status, error) {
alert("데이터를 가져오는 데 실패했습니다.");
},
});
}
function initializeInputEvents($input) {
$input.on("input", handleInputChange);
}
function handleInputChange() {
const inputValue = $(this).val();
console.log("입력된 값:", inputValue); // 입력된 값 출력
// 추가적인 로직을 여기에 구현할 수 있습니다.
}
</script>
</body>
</html>
이번 코드를 작성하면서 지난번엔 finance로 uri를 생성했는데 이번에는 stock으로 변경했다 지난번 코드를 올리다 보니 잘못된 부분을 수정한다.
- left-side.com
<!-- 이전 코드 -->
<a class="nav-link" href="/finance">
<div class="sb-nav-link-icon"><i class="fas fa-tachometer-alt"></i></div>
주가조회
</a>
<!-- 수정 코드 -->
<a class="nav-link" href="/stock">
<div class="sb-nav-link-icon"><i class="fas fa-tachometer-alt"></i></div>
주가조회
</a>
Code를 실행시키고 주가 "조회 메뉴"를 클릭하면 아래와 같은 창이 나온다. 종목명 입력 text input 박스에 조회할 상장 종목을 넣고 조회 버튼을 클릭한다.(콤보 박스의 숫자는 읽어올 기간의 데이터이다.)
- 실행 전
- 실행 결과