코딩/Spring Boot

Spring Boot WebFlux로 data.go.kr에서 주가 시세 가져오기

skyoon 2025. 1. 16. 19:41

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 박스에 조회할 상장 종목을 넣고 조회 버튼을 클릭한다.(콤보 박스의 숫자는 읽어올 기간의 데이터이다.)

  • 실행 전

  • 실행 결과