[Spring Boot] 한국투자증권 오픈API 호출

개요

여기에서는 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(삼성전자)을 입력하면…


[Spring Boot] 한국투자증권 오픈API 호출

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.