주식 모의투자 프로젝트를 시작하면서 주식 정보를 제공하는 OPEN API를 찾던 중 다양한 API를 제공하는 KIS 한국투자증권 API를 알게되었고 이 프로젝트에 사용하게 되었습니다. (해당 프로젝트는 스프링부트 기반입니다)
API사용법은 다음의 링크에 잘 나와있습니다.
https://apiportal.koreainvestment.com/apiservice-summary
KIS Developers
한국투자증권 Open API 포탈
apiportal.koreainvestment.com
1. 한국투자증권 계정 만들기
간단하다. 휴대폰 어플로 계정, 계좌를 만들고 인증합니다. (휴대폰 어플을 통한 QR인증이 제일 간단함..)
2. KIS Developers 서비스 신청
KIS Developers 서비스 신청 페이지로 들어가서 가입한 계정으로 로그인한 뒤 QR코드로 PC인증을 진행합니다.

서비스 신청을 한 후 엑세스 토큰 발급에 필요한 key와 secret은 잘 보관해둡니다.(유효기간 1년)
3. API 사용
한국투자증권의 모든 API를 사용하려면 헤더에 엑세스토큰을 넣어주어야합니다. 엑세스 토큰 발급 API또한 잘 명시되어있습니다.
엑세스토큰의 유효기간은 24시간이며 발급 제한이 있어서 서버에 저장하는 방식으로 진행하였습니다.
토큰 관리
24시간마다 만료되는 이 토큰들을 어떻게 관리해야 할까?
단순한 접근의 문제점:
- API를 호출할 때마다 토큰 발급 API를 먼저 호출하면, 모든 요청이 2배의 시간을 소요합니다.
- API 호출 제한 (Rate Limit): KIS API는 초당/분당 호출 횟수가 제한되어 있습니다. 사용자가 몰릴 때마다 인증 요청을 보내면, 이 제한에 걸려 서비스 자체가 차단될 가능성이 있습니다.
해결책: 캐싱(Caching)
- 한 번 발급받은 토큰을 24시간 동안 재사용하는 방식으로 진행하였습니다.
- db 대신 인메모리 기반 redis를 사용하여 빠른 속도로 토큰을 가져오게 하였습니다.
서비스 코드에서 api호출에 필요한 토큰 요청 과정은 다음과 같습니다
- Redis 확인 → 토큰이 있으면 바로 반환
- 토큰 없음 → KIS API 서버에 새 토큰 요청
- 토큰 발급 → Redis에 24시간-5분 유효시간으로 저장
- 토큰 반환 → 다른 서비스에서 사용
먼저 KIS 오픈API 호출에 필요한 기본 URL과 인증키를 관리하는 객체를 만들었습니다.
@ConfigurationProperties(prefix = "kis.api")
public record KisApiProperties(
String url,
String appkey,
String appsecret
) {}
다음은 엑세스토큰을 관리하는 객체입니다.
public class KisTokenManager {
private final StringRedisTemplate redisTemplate;
private final WebClient webClient;
private final KisApiProperties properties;
// Redis에 저장할 키 이름
private static final String ACCESS_TOKEN_KEY = "kis:access_token";
private static final String APPROVAL_KEY_KEY = "kis:approval_key";
// KIS가 정한 토큰 유효시간 (24시간) - 5분 여유
private static final Duration TOKEN_VALIDITY = Duration.ofHours(24).minusMinutes(5);
// 다른 서비스에서 Access Token을 요청할 때 호출
public String getAccessToken() {
String token = redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
if (token == null) {
token = fetchNewAccessToken(); // 토큰이 없으면 KIS에 새로 요청
}
return token;
}
// KIS 인증 서버에 접속해 새 Access Token을 발급받고 Redis에 저장
private String fetchNewAccessToken() {
log.info("KIS에서 새 Access Token을 발급받습니다.");
Map<String, String> requestBody = Map.of(
"grant_type", "client_credentials",
"appkey", properties.appkey(),
"appsecret", properties.appsecret()
);
// KIS API 서버에 비동기 POST 요청
Mono<Map> responseMono = webClient.post()
.uri(properties.url() + "/oauth2/tokenP") // 토큰 발급 URL
.bodyValue(requestBody)
.retrieve()
.bodyToMono(Map.class); // 응답을 Map으로 받음
// 24시간중 한번 받기 때문에 비동기로 받을 필요가 없어서 동기적으로 받음
Map<String, Object> response = responseMono.block();
String newToken = (String) Objects.requireNonNull(response).get("access_token");
log.info("새 Access Token 발급 성공");
// Redis에 유효시간만큼 새 토큰 저장
redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, newToken, TOKEN_VALIDITY);
return newToken;
}
// 다른 서비스에서 웹소켓 접속 요청할 때 호출
public String getApprovalKey() {
String key = redisTemplate.opsForValue().get(APPROVAL_KEY_KEY);
if (key == null) {
key = fetchNewApprovalKey(); // 키가 없으면 KIS에 새로 요청
}
return key;
}
// KIS 인증 서버에 접속해 새 Approval Key를 발급받고 Redis에 저장
private String fetchNewApprovalKey() {
log.info("KIS에서 새 Approval Key를 발급받습니다.");
Map<String, String> requestBody = Map.of(
"grant_type", "client_credentials",
"appkey", properties.appkey(),
"secretkey", properties.appsecret()
);
Mono<Map> responseMono = webClient.post()
.uri(properties.url() + "/oauth2/Approval") // 웹소켓 접속키 발급 URL
.header("Content-Type", "application/json; charset=utf-8")
.bodyValue(requestBody)
.retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
response -> response.bodyToMono(String.class)
.flatMap(body -> {
log.error("KIS Approval Key 발급 실패. Status: {}, Body: {}",
response.statusCode(), body);
return Mono.error(new RuntimeException("Approval Key 발급 실패: " + body));
}))
.bodyToMono(Map.class);
Map<String, Object> response = responseMono.block();
String newKey = (String) Objects.requireNonNull(response).get("approval_key");
log.info("새 Approval Key 발급 성공");
redisTemplate.opsForValue().set(APPROVAL_KEY_KEY, newKey, TOKEN_VALIDITY);
return newKey;
}
}
외부 API와 통신하는 객체 RestTemplate VS WebClient
토큰을 요청하는 등 API를 호출하는 객체는 전통적인 블로킹 방식의 RestTemplate 대신 비동기 기반인 Spring WebFlux 스택 위에서 동작하는 WebClient를 사용하였습니다.
Spring WebFlux가 뭔지는 다음에 다루겠습니다.
장점은 다음과 같습니다
- KIS API에 요청을 보낸 직후, 응답을 기다리지 않고 스레드가 즉시 다른 일을 하러 감. 나중에 KIS로부터 응답이 오면 다른 스레드가 그 응답을 처리
- 적은 수의 스레드로도 대량의 동시 요청을 효율적으로 처리할 수 있어, 리소스 효율이 높고 확장성에 유리함
- 추후에 도입할 실시간 웹소켓을 처리하는 STOMP나 WebSocketHandler는 비동기(Non-Blocking) 기반인 Spring WebFlux 스택 위에서 동작하기 때문에 일관성을 맞추고자 선택
이제 API 호출을 해보겠습니다
거래대금 상위 종목만 필터링하는 서비스 코드를 구현하였습니다.
1. 목표: 상위 주식 목록 가져오기
탐색 탭에는 시장에서 가장 인기 있는(거래대금이 높은) 종목들을 보여줘야 합니다. KIS API에는 다행히 '거래량 순위' API (/uapi/domestic-stock/v1/quotations/volume-rank)가 존재하며, 이 API는 파라미터 설정을 통해 '거래대금' 순위도 조회가 가능했습니다.
2. 문제점: 원하지 않는 데이터 (ETF, ETN)
하지만 이 API를 호출해보니 API는 개별 주식(ST, FS)뿐만 아니라 ETF, ETN 등 모든 상품을 섞어서 반환했습니다.
우리 서비스는 주식 모의투자에 초점을 맞추고 있기 때문에 ETF 상품을 제외하고 순수 주식(ST, FS)만을 필터링할 방법이 필요했습니다.
3. 해결책: API 응답 결과를 우리 DB와 비교하기
해결책은 1일 1회 .mst 파일(주식 마스터 파일)로 갱신하는 Stock 테이블을 필터로 활용하였습니다. 사전에 서비스에 필요한 모든 주식 종목들을 파싱하여 저장하는 로직을 구현하였습니다.
- Stock 테이블에는 .mst 파싱 시 scrt_grp_cls_code가 ST (주권) 또는 FS (외국주권)인 종목만 저장되어 있다.
- KIS 순위 API를 호출하여 ETF 등이 포함된 전체 순위 목록을 일단 모두 받는다.
- API 응답 목록의 종목 코드들이 우리 Stock 테이블에 존재하는지 비교한다.
- Stock 테이블에 존재하는(즉, ST 또는 FS인) 종목들만 사용자에게 반환한다.
핵심 코드: StockRankingService
// StockRankingService.java
public Mono<List<StockRankingDto>> getAmountTopStocks(int limit) {
String accessToken = kisTokenManager.getAccessToken();
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/uapi/domestic-stock/v1/quotations/volume-rank")
.queryParam("FID_COND_MRKT_DIV_CODE", "J")
.queryParam("FID_COND_SCR_DIV_CODE", "20171")
.queryParam("FID_INPUT_ISCD", "0000")
.queryParam("FID_DIV_CLS_CODE", "0")
.queryParam("FID_BLNG_CLS_CODE", "3") // 거래금액순
// ... 기타 파라미터 ...
.build())
.header("authorization", "Bearer " + accessToken)
.header("appkey", kisApiProperties.appkey())
// ... 기타 헤더 ...
.retrieve()
.bodyToMono(KisRankingResponseDto.class) // 1차 DTO로 파싱
.map(response -> parseAmountRankingResponse(response, limit)); // 2차 DTO 리스트로 변환
}
WebClient를 사용해 KIS API를 비동기(Mono)로 호출합니다. .block()을 사용하지 않아 스레드 효율성을 높였습니다.
DTO 파싱
// StockRankingService.java
// KIS 응답 DTO를 서비스 DTO(StockRankingDto)로 변환
private List<StockRankingDto> parseAmountRankingResponse(KisRankingResponseDto response, int limit) {
if (!"0".equals(response.rtCd())) {
// API 에러 처리
throw new RuntimeException("KIS API 오류: " + response.msg1());
}
List<KisStockDataDto> stockDataList = parseOutputData(response.output()); // 유연한 파싱
return stockDataList.stream()
.limit(limit)
.map(this::mapKisDataToStockRankingDto) // 최종 DTO로 매핑
.toList();
}
DB 필터링
// StockRankingService.java
// DB에 있는 종목(ST, FS)만 필터링
private List<StockRankingDto> filterStocksInDatabase(List<StockRankingDto> stocks, int limit) {
// API 응답에서 종목 코드 리스트 추출
List<String> stockCodes = stocks.stream()
.map(StockRankingDto::stockCode)
.toList();
// DB에서 해당 종목 코드들이 존재하는지 확인
Set<String> existingStockCodes = stockRepository.findByCodeIn(stockCodes).stream()
.map(Stock::getCode)
.collect(Collectors.toSet());
// DB에 존재하는 종목만 필터링하여 반환
return stocks.stream()
.filter(stock -> existingStockCodes.contains(stock.stockCode()))
.limit(limit)
.toList();
}
결과는 다음과 같이 잘 나온것을 확인했습니다.
[
{
"stock_code": "000660",
"stock_name": "SK하이닉스",
"volume": 4789397,
"amount": 2784161632500,
"market_type": "KOSPI",
"current_price": 580000,
"change_amount": -13000,
"change_rate": "-2.19",
"change_sign": "FALL"
},
{
"stock_code": "005930",
"stock_name": "삼성전자",
"volume": 22908083,
"amount": 2246165668512,
"market_type": "KOSPI",
"current_price": 97900,
"change_amount": -1300,
"change_rate": "-1.31",
"change_sign": "FALL"
},
{
"stock_code": "486990",
"stock_name": "노타",
"volume": 22522053,
"amount": 1270467203325,
"market_type": "KOSDAQ",
"current_price": 55400,
"change_amount": 5650,
"change_rate": "11.36",
"change_sign": "RISE"
},
{
"stock_code": "034020",
"stock_name": "두산에너빌리티",
"volume": 8514588,
"amount": 662849213650,
"market_type": "KOSPI",
"current_price": 77900,
"change_amount": -1400,
"change_rate": "-1.77",
"change_sign": "FALL"
},
{
"stock_code": "035720",
"stock_name": "카카오",
"volume": 9719245,
"amount": 611615816900,
"market_type": "KOSPI",
"current_price": 62800,
"change_amount": 2100,
"change_rate": "3.46",
"change_sign": "RISE"
},
....
위의 과정을 통해 KIS API가 제공하는 API를 사용하여 원하는 정보를 얻을 수 있게 되었습니다.
다음 편에서는 이렇게 필터링된 상위 종목 목록을 실제로 화면에 뿌려주고 이 종목들의 실시간 시세를 WebSocket과 STOMP를 이용해 프론트엔드까지 어떻게 중계하는지 다뤄보겠습니다.
'백엔드' 카테고리의 다른 글
| [모의 투자 사이트 개발기] 체결 로직 구상하기 (0) | 2025.11.19 |
|---|---|
| [모의 투자 사이트 개발기] 실시간 주식 시세 중계 시스템 구축하기 (0) | 2025.11.14 |
| 프로젝트에 Rag 도입하기 (0) | 2025.09.29 |
| [동시성 제어] 좌석 예매 동시성 문제 해결하기 (3) | 2025.08.27 |
| [RabbitMQ + Celery] 메세지큐와 워커를 활용한 비동기처리 (8) | 2025.08.07 |