개요
여기에서는 Spring Boot + WebClient + Thymeleaf를 사용하여, 한국투자증권에서 제공하는 오픈API를 호출하여 국내지수(종합주가지수, 코스닥지수 등)를 서비스하는 예를 보여준다.
한국투자증권의 오픈API 관련해서는 https://apiportal.koreainvestment.com/ 를 참조하면 된다.
개발환경은 다음과 같다:
● Spring Tool Suite 3 (Version: 3.9.11)
● Spring Boot Version : 2.7.14
● Java : JDK 8
※ 관련글 목록: http://yellow.kr/lifeView.jsp?s=spring
예제 애플리케이션 설명
예제 프로그램은 한국투자증권에서 제공하는 REST 오픈API를 호출하여 다음과 같은 기능을 구현하였다:
● 접근 토큰 발급
● 종합주가, KOSPI200, 코스닥 지수 조회
● 종목 주식 현재가
예제 Application의 Flow는 다음과 같다. My App는 8080 port를 사용하였다.
공부를 위해 기능의 테스트에만 만족하였기 때문에 예제 애플리케이션의 완성도는 떨어진다.
WebClient
WebClient는 Spring WebFlux에서 제공하는 비동기(non-blocking) HTTP 클라이언트이다. WebClient는 리액티브 스트림을 활용하여 비동기 작업을 처리하며, 네트워크 호출을 위한 요청과 응답을 비동기적으로 처리하는 데에 적합하다.
※ 관련글 : http://yellow.kr/blog/?p=5841
Spring Boot project 생성
● eclips에서 Spring Boot project를 생성한다.
File > New > Project… > Spring Boot > Spring Starter Project
Name, Group, Package에 적당한 내용을 입력하고 [Next] 클릭
Java 8 을 사용할 수 없게 되었다. 일단 Java 17로 생성하고 이후에 pom.xml을 수정하였다.
● 필요한 Dependency들을 선택한다.
Spring Web, Spring Reactive Web, Thymeleaf를 선택한다.
[Finish] 클릭
Spring Boot Version도 3.2.1로 생성하고 이후에 pom.xml을 2.7.14로 수정하였다.
● pom.xml 수정
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.14</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yellow</groupId>
<artifactId>yellow-kisd</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>yellow-kisd</name>
<description>Spring Boot Description</description>
<properties>
<java.version>1.8</java.version>
</properties>
위에서 언급한 Spring Boot Version은 2.7.14 로, java version은 1.8로 수정하였다.
음… 이제 개발 환경 업그레이드를 해야겠다.
● Project 생성 됨
yellow-kisd 라는 이름으로 Project가 생성되었고, YellowKisdApplication.java 가 생성되었다.
package com.yellow.kisd;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class YellowKisdApplication {
public static void main(String[] args) {
SpringApplication.run(YellowKisdApplication.class, args);
}
}
예제 애플리케이션이 완성되면 Project Structure은 다음과 같다.
Maven Dependencies
pom.xml을 보면 다음과 같은 dependency를 확인할 수 있다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Model
● Body.java
package com.yellow.kisd.model;
public class Body {
private String rt_cd;
private String msg_cd;
private String msg1;
private Object output;
public String getRt_cd() {
return rt_cd;
}
public void setRt_cd(String rt_cd) {
this.rt_cd = rt_cd;
}
public String getMsg_cd() {
return msg_cd;
}
public void setMsg_cd(String msg_cd) {
this.msg_cd = msg_cd;
}
public String getMsg1() {
return msg1;
}
public void setMsg1(String msg1) {
this.msg1 = msg1;
}
public Object getOutput() {
return output;
}
public void setOutput(Object output) {
this.output = output;
}
}
● IndexData.java
package com.yellow.kisd.model;
public class IndexData {
private String rt_cd;
private String msg_cd;
private String msg1;
private Object output1;
private Object[] output2;
public Object getOutput1() {
return output1;
}
public void setOutput1(Object output1) {
this.output1 = output1;
}
public Object[] getOutput2() {
return output2;
}
public void setOutput2(Object[] output2) {
this.output2 = output2;
}
public String getRt_cd() {
return rt_cd;
}
public void setRt_cd(String rt_cd) {
this.rt_cd = rt_cd;
}
public String getMsg_cd() {
return msg_cd;
}
public void setMsg_cd(String msg_cd) {
this.msg_cd = msg_cd;
}
public String getMsg1() {
return msg1;
}
public void setMsg1(String msg1) {
this.msg1 = msg1;
}
}
● OauthInfo.java
package com.yellow.kisd.model;
public class OauthInfo {
private String grant_type;
private String appkey;
private String appsecret;
public String getGrant_type() {
return grant_type;
}
public void setGrant_type(String grant_type) {
this.grant_type = grant_type;
}
public String getAppkey() {
return appkey;
}
public void setAppkey(String appkey) {
this.appkey = appkey;
}
public String getAppsecret() {
return appsecret;
}
public void setAppsecret(String appsecret) {
this.appsecret = appsecret;
}
}
● TokenInfo.java
package com.yellow.kisd.model;
public class TokenInfo {
private String access_token;
private String token_type;
private long expires_in;
public String getAccess_token() {
return access_token;
}
public void setAccess_token(String access_token) {
this.access_token = access_token;
}
public String getToken_type() {
return token_type;
}
public void setToken_type(String token_type) {
this.token_type = token_type;
}
public long getExpires_in() {
return expires_in;
}
public void setExpires_in(long expires_in) {
this.expires_in = expires_in;
}
}
Contoller, Config 등
● KisController.java
package com.yellow.kisd;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
import reactor.util.function.Tuples;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yellow.kisd.model.Body;
import com.yellow.kisd.model.IndexData;
@Controller
public class KisController {
@Autowired
private AccessTokenManager accessTokenManager;
private final WebClient webClient;
private String path;
private String tr_id;
public KisController(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl(KisConfig.REST_BASE_URL).build();
}
@GetMapping("/")
public String index(Model model) {
return "index";
}
@GetMapping("/indices")
public String majorIndices(Model model) {
List<Tuple2<String, String>> iscdsAndOtherVariable1 = Arrays.asList(
Tuples.of("0001", "U"),
Tuples.of("2001", "U"),
Tuples.of("1001", "U")
);
Flux<IndexData> indicesFlux = Flux.fromIterable(iscdsAndOtherVariable1)
.concatMap(tuple -> getMajorIndex(tuple.getT1(), tuple.getT2()))
.map(jsonData -> {
ObjectMapper objectMapper = new ObjectMapper();
try {
return objectMapper.readValue(jsonData, IndexData.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
});
List<IndexData> indicesList = indicesFlux.collectList().block();
model.addAttribute("indicesKor", indicesList);
model.addAttribute("jobDate", getJobDateTime());
return "indices";
}
public String getStringToday() {
LocalDate localDate = LocalDate.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
return localDate.format(formatter);
}
public String getJobDateTime() {
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return now.format(formatter);
}
public Mono<String> getMajorIndex(String iscd, String fid_cond_mrkt_div_code) {
if (fid_cond_mrkt_div_code.equals("U")) {
path = KisConfig.FHKUP03500100_PATH;
tr_id = "FHKUP03500100";
} else {
path = KisConfig.FHKST03030100_PATH;
tr_id = "FHKST03030100";
}
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path(path)
.queryParam("fid_cond_mrkt_div_code", fid_cond_mrkt_div_code)
.queryParam("fid_input_iscd", iscd)
.queryParam("fid_input_date_1", getStringToday())
.queryParam("fid_input_date_2", getStringToday())
.queryParam("fid_period_div_code", "D")
.build())
.header("content-type","application/json")
.header("authorization","Bearer " + accessTokenManager.getAccessToken())
.header("appkey",KisConfig.APPKEY)
.header("appsecret",KisConfig.APPSECRET)
.header("tr_id",tr_id)
.retrieve()
.bodyToMono(String.class);
}
@GetMapping("/equities/{id}")
public Mono<String> CurrentPrice(@PathVariable("id") String id, Model model) {
String url = KisConfig.REST_BASE_URL + "/uapi/domestic-stock/v1/quotations/inquire-price?fid_cond_mrkt_div_code=J&fid_input_iscd=" + id;
return webClient.get()
.uri(url)
.header("content-type","application/json")
.header("authorization","Bearer " + accessTokenManager.getAccessToken())
.header("appkey",KisConfig.APPKEY)
.header("appsecret",KisConfig.APPSECRET)
.header("tr_id","FHKST01010100")
.retrieve()
.bodyToMono(Body.class)
.doOnSuccess(body -> {
model.addAttribute("equity", body.getOutput());
model.addAttribute("jobDate", getJobDateTime());
})
.doOnError(result -> System.out.println("*** error: " + result))
.thenReturn("equities");
}
}
● KisConfig.java
package com.yellow.kisd;
import org.springframework.context.annotation.Configuration;
@Configuration
public class KisConfig {
public static final String REST_BASE_URL = "https://openapi.koreainvestment.com:9443";
public static final String WS_BASE_URL = "ws://ops.koreainvestment.com:21000";
public static final String APPKEY = "XXXXXXXXXXXXXXXXXXXXX"; // your APPKEY
public static final String APPSECRET = "XXXXXXXXXXXXXXXXXXXXXXX"; // your APPSECRET
public static final String FHKUP03500100_PATH = "/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice";
public static final String FHKST03030100_PATH = "/uapi/overseas-price/v1/quotations/inquire-daily-chartprice";
}
● AccessTokenManager.java
package com.yellow.kisd;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import com.yellow.kisd.model.OauthInfo;
import com.yellow.kisd.model.TokenInfo;
@Component
public class AccessTokenManager {
private final WebClient webClient;
public static String ACCESS_TOKEN;
public static long last_auth_time = 0;
public AccessTokenManager(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl(KisConfig.REST_BASE_URL).build();
}
public String getAccessToken() {
if (ACCESS_TOKEN == null) {
ACCESS_TOKEN = generateAccessToken();
System.out.println("generate ACCESS_TOKEN: " + ACCESS_TOKEN);
}
return ACCESS_TOKEN;
}
public String generateAccessToken() {
String url = KisConfig.REST_BASE_URL + "/oauth2/tokenP";
OauthInfo bodyOauthInfo = new OauthInfo();
bodyOauthInfo.setGrant_type("client_credentials");
bodyOauthInfo.setAppkey(KisConfig.APPKEY);
bodyOauthInfo.setAppsecret(KisConfig.APPSECRET);
Mono<TokenInfo> mono = webClient.post()
.uri(url)
.header("content-type", "application/json")
.bodyValue(bodyOauthInfo)
.retrieve()
.bodyToMono(TokenInfo.class);
TokenInfo tokenInfo = mono.block();
if (tokenInfo == null) {
throw new RuntimeException("액세스 토큰을 가져올 수 없습니다.");
}
ACCESS_TOKEN = tokenInfo.getAccess_token();
return ACCESS_TOKEN;
}
}
View(Thymeleaf)
● index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<title>KISD 테스트</title>
</head>
<body>
<div class="container">
<div th:replace="/nav.html :: fragment-nav"></div>
<p><br></p>
<p>Contents</p>
</div>
</body>
</html>
● nav.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="fragment-nav">
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">옐로우의 세계 - Market</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/indices">지수</a>
</li>
</ul>
<form action="/equities/" onsubmit="this.action = this.action + this.stockCode.value; this.submit();" class="d-flex" role="search">
<input class="form-control me-2" type="search" name="stockCode" placeholder="종목코드" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav>
</div>
</html>
● indices.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<title>주요 지수</title>
</head>
<body>
<div class="container">
<div th:replace="/nav.html :: fragment-nav"></div>
<div class="h4 pb-2 mb-4 text-danger border-bottom border-info"></div>
<div><span>조회시간: </span><span th:text="${jobDate}" ></span></div>
<div class="card">
<div class="card-header">
<h2>국내 지수</h2>
</div>
<div class="card-body">
<table class="table">
<tbody>
<tr th:each="indexData : ${indicesKor}">
<td th:text="${indexData.output1.hts_kor_isnm}" ></td>
<td th:text="${indexData.output1.bstp_nmix_prpr}"></td>
<td th:text="${indexData.output1.bstp_nmix_prdy_vrss}" th:style="${indexData.output1.prdy_vrss_sign<'3'?'color:red':(indexData.output1.prdy_vrss_sign>'3'?'color:blue':'color:black')}"></td>
<td th:text="${indexData.output1.bstp_nmix_prdy_ctrt}" th:style="${indexData.output1.prdy_vrss_sign<'3'?'color:red':(indexData.output1.prdy_vrss_sign>'3'?'color:blue':'color:black')}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="h4 pb-2 mb-4 text-danger border-bottom border-info"></div>
</div>
</body>
</html>
● equities.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<title>오늘의 주가</title>
</head>
<body>
<div class="container">
<div th:replace="/nav.html :: fragment-nav"></div>
<div class="h4 pb-2 mb-4 text-danger border-bottom border-info"></div>
<div><span>조회시간: </span><span th:text="${jobDate}" ></span></div>
<div class="card">
<div class="card-header">
<h2 th:text="${equity.stck_shrn_iscd}"></h2>
</div>
<div class="card-body">
<div>
<span class="fs-1" th:text="${equity.stck_prpr}"></span>원
<span th:text="${equity.prdy_vrss}" th:style="${equity.prdy_vrss_sign<'3'?'color:red':(equity.prdy_vrss_sign>'3'?'color:blue':'color:black')}"></span>
<span th:text="${equity.prdy_ctrt}" th:style="${equity.prdy_vrss_sign<'3'?'color:red':(equity.prdy_vrss_sign>'3'?'color:blue':'color:black')}"></span>
</div>
</div>
</div>
<div class="h4 pb-2 mb-4 text-danger border-bottom border-info"></div>
<div class="card">
<div class="card-body">
<table class="table">
<tbody>
<tr>
<td>PER</td>
<td th:text="${equity.per}"></td>
<td>EPS</td>
<td th:text="${equity.eps}"></td>
</tr>
<tr>
<td>PBR</td>
<td th:text="${equity.pbr}"></td>
<td>BPS</td>
<td th:text="${equity.bps}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
예제 애플리케이션 실행
Project를 선택한 후, Run AS > Spring Boot App 를 하면 내장 Tomcat 서버에 배포된다. 브라우저에서 다음의 URL을 실행한다.
● http://localhost:8080
● http://localhost:8080/indices
[지수]를 클릭하면…
● http://localhost:8080/equities/005930
검색창에 종목코드, 예를 들어 005930(삼성전자)을 입력하면…