본문 바로가기

사이드프로젝트/(240808)이거왜오름?

이거왜오름?거래량 순위 받아오기

거래량 순위를 받아오기위해서 증권사 api를 써야하는데,한국투자증권 api를 사용할거임

https://apiportal.koreainvestment.com/apiservice/apiservice-domestic-stock-ranking#L_6df56964-f22b-43d4-9457-f06264018e5b

 

KIS Developers

REST 국내주식 수익자산지표 순위[v1_국내주식-090] 기본정보 Method GET 실전 Domain https://openapi.koreainvestment.com:9443 모의 Domain 모의투자 미지원 URL /uapi/domestic-stock/v1/ranking/profit-asset-index Format   Content-Ty

apiportal.koreainvestment.com

문제는 api키를 받아야하는데,이거를위해서 회원가입을 해야하는데,pc에서는 못하는거같고+계좌계설까지 해야함 ㅋㅋ

내가 api키받으려고 계좌계설한건 또 처음이다

 

모바일에서 한국투자증권 앱을 받고,계좌만들면서 회원가입한다음,pc에서 스마트폰인증으로 들어가서,한국투자증권의 메뉴에서 인증-pc인증으로 qr코드 찍고 로그인할수있음

그다음 api신청가서 api키를 신청하면됨

스마트폰에서 사진찍으려니까 힘들어서 스크린샷은 안찍었음

 

api키를 받고 급등락 순위 api를 보면

https://apiportal.koreainvestment.com/apiservice/apiservice-domestic-stock-ranking#L_c3b78a4a-de38-43fb-a78d-4018b1ea4d4f

 

KIS Developers

REST 국내주식 수익자산지표 순위[v1_국내주식-090] 기본정보 Method GET 실전 Domain https://openapi.koreainvestment.com:9443 모의 Domain 모의투자 미지원 URL /uapi/domestic-stock/v1/ranking/profit-asset-index Format   Content-Ty

apiportal.koreainvestment.com

넣어줘야하는 리퀘스트헤더와,쿼리파라미터들이 쭉 있음

그중에서 required가 y인(필수인)것들만 챙겨가면됨

 

근데 중간에 보면 리퀘스트 헤더에 authorization가 있고,이게 액세스 토큰을 요구하는데,무려 필수값임

도대체 순위api를 호출하는데 api키랑 시크릿키도 줬는데 왜 토큰을 달라고하는건지는 전혀 모르겠지만,받아쓰는입장에서 어쩌겠음 달라면 줘야지

한투api중에서 토큰을 받는 api는

https://apiportal.koreainvestment.com/apiservice/oauth2#L_fa778c98-f68d-451e-8fff-b1c6bfe5cd30

 

KIS Developers

WEBSOCKET 실시간 (웹소켓) 접속키 발급[실시간-000] 기본정보 Method POST 실전 Domain https://openapi.koreainvestment.com:9443 모의 Domain https://openapivts.koreainvestment.com:29443 URL /oauth2/Approval Format JSON Content-Type  개

apiportal.koreainvestment.com

이건 post로,리퀘스트헤더없이 리퀘스트바디로 값들을 넣어서 던져줘야함

 

 

이제 다 봤으니,대충 설계하고 구현을 해야함

이 한투api들을 기존에 있던 KoreanApiFetcher에 붙이기에는 너무 결이 다름(퍼블릭인터페이스가 다름)

그런데 확실히 restTemplate를 생성하고,url을 설정하고,api를 호출하는 결 자체는 같음

즉,인터페이스를 구현하는식으로 확장하긴 어려운데,그렇다고 쌩으로 복붙해서 구현하기엔 중복같아보임

 

이렇때 사용할수있는게 합성임

내부구현을 재사용하고싶을때 제일 유용한게 합성이고,

거기다가 새로운 기능들을 추가하는 타이밍,즉 리팩토링하기 제일 좋은 타이밍이니,공통부분인 api호출부분을 떼어내서,변경포인트를 한군데로 줄일거임

 

기존에 저런 api호출과 관련된 부분을 처리하던곳은 KoreanApiFetcher추상클래스였음

여기서 저 코드들을 떼어낸다음,새로운 클래스로 생성할거임

필요한게,레스트템플릿 생성,url빌드,api패치 3개니(추가로 노드에서 값꺼내는거도 추가했음)

package rkrk.whyprice.util

import com.fasterxml.jackson.databind.JsonNode
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
import org.springframework.http.ResponseEntity
import org.springframework.util.MultiValueMap
import org.springframework.web.client.RestTemplate
import java.net.URI

interface ApiUtil {
    fun createRestTemplate(
        connectTimeout: Long = 3,
        readTimeout: Long = 3,
    ): RestTemplate

    fun buildUrl(
        baseUrl: String,
        queryParams: MultiValueMap<String, String>?,
        encodeType: Boolean = true,
    ): URI

    fun fetchApiResponse( // 리팩터링
        restTemplate: RestTemplate,
        url: URI,
        httpMethod: HttpMethod,
        httpEntity: HttpEntity<*>?,
    ): ResponseEntity<String>

    fun extractNodeValue(
        node: JsonNode,
        key: String,
    ): String
}

 

이런식으로 인터페이스를 만들고(바로 구현체 만들어도 될거같아보이지만,이런식으로 인터페이스를 거치는게 나중에 테스트하기좋고 여러모로 편함)

package rkrk.whyprice.util

import com.fasterxml.jackson.databind.JsonNode
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
import org.springframework.http.ResponseEntity
import org.springframework.util.MultiValueMap
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
import java.net.URI
import java.time.Duration

class ApiUtilImpl : ApiUtil {
    override fun createRestTemplate(
        connectTimeout: Long,
        readTimeout: Long,
    ): RestTemplate {
        val restTemplate =
            RestTemplateBuilder()
                .setConnectTimeout(Duration.ofSeconds(connectTimeout))
                .setReadTimeout(Duration.ofSeconds(readTimeout))
                .build()
        return restTemplate
    }

    override fun buildUrl(
        baseUrl: String,
        queryParams: MultiValueMap<String, String>?,
        encodeType: Boolean,
    ): URI {
        val url =
            UriComponentsBuilder
                .fromHttpUrl(baseUrl)
                .queryParams(queryParams)
                .build(encodeType)
                .toUri()
        return url
    }

    override fun fetchApiResponse(
        restTemplate: RestTemplate,
        url: URI,
        httpMethod: HttpMethod,
        httpEntity: HttpEntity<*>?,
    ): ResponseEntity<String> {
        val response =
            restTemplate.exchange(
                url,
                httpMethod,
                httpEntity,
                String::class.java,
            )
        return response
    }

    override fun extractNodeValue(
        node: JsonNode,
        key: String,
    ): String {
        try {
            return node.get(key).asText()
        } catch (e: Exception) {
            throw NoSuchElementException("${javaClass.name}에서 해당하는 노드의 키값이 없음 키:$key ")
        }
    }
}

이렇게 원래있던 코드들을 가져와서 매개변수부분만 조금 손댔음

그리고 원래 있던 코드에

abstract class KoreanApiFetcher(
    private val apiUtil: ApiUtil,
) : AssetFetcher {
    override fun fetch(crNo: String): Map<String, String> {
        val restTemplate = apiUtil.createRestTemplate()

        val url = apiUtil.buildUrl(getBaseUrl(), createQueryParams(crNo, ApiConfig.getOpenApiKey()))

        return apiCall(restTemplate, url, crNo)
    }
...
}

이렇게 di받아서 호출하는식으로 처리했고,테스트를 돌리니 테스트가 성공했으니 리팩터링이 끝난거임

이제 저걸가지고 토큰생성api를 만들거임

토큰생성api는 딱히 상속계층을 만들거같지않아서 바로 구현클래스로 만들었음

class KoreanInvTokenFetcher(
    private val apiUtil: ApiUtil,
) {
    fun fetch(): String {
        val restTemplate = apiUtil.createRestTemplate()
        val url = apiUtil.buildUrl(getBaseUrl(), null, false)
        val response = apiUtil.fetchApiResponse(restTemplate, url, HttpMethod.POST, createHttpEntity())
        return response.body!!
    }
    //todo response에서 access_token만 추출해서 반환하도록 수정

    private fun getBaseUrl(): String = "https://openapi.koreainvestment.com:9443/oauth2/tokenP"

    private fun createHttpEntity(): HttpEntity<String> {
        val headers = HttpHeaders()
        headers.set("content-type", "application/x-www-form-urlencoded")
        return HttpEntity(createRequestBody(), headers)
    }

    private fun createRequestBody(): String {
        val requestBody =
            mapOf(
                "grant_type" to "client_credentials",
                "appkey" to ApiConfig.getKoreaInvKey(),
                "appsecret" to ApiConfig.getKoreaSecretKey(),
            )
        return jacksonObjectMapper().writeValueAsString(requestBody)
    }
}

 

일단 apiUtil을 di받아서,fetch에서 해당 클래스를 가지고 실행하는 껍데기를 만든다음,거기에 해당하는 값들을 채우는 함수를 만드는식으로 진행했음

이때 주의할건,RequestBody가 Json형태로 들어가야함

그래서 만약 EGW00002가 뜬다면,요청의 형식등에 문제가 있다는거니까 확인해봐야함

 

일단 통신이 되는지 확인하기위해 파싱은 하지않고 통신까지만 코드를 작성했음

이제 테스트에 스터디테스트를 만들어서 실행해보면

class KoreanInvTokenFetcherTest {
    @Test
    fun fetchTest() {
        // Given
        val koreanTokenFetcher = KoreanInvTokenFetcher(ApiUtilImpl())

        // When
        val koreanToken = koreanTokenFetcher.fetch()

        // Then
        print(koreanToken)
    }
}

제대로 응답이 오는걸 확인할수 있음

이제 응답에서 액세스토큰만 추출해서 리턴하게 하면됨(통신도 자주 안할테니 저장하지 않고 그냥 호출할때마다 요청할 생각)

KoreanInvTokenFetcher에 추출메서드를 만들고

private fun extractResponseValue(
    response: ResponseEntity<String>,
    key: String,
): String {
    val om = jacksonObjectMapper()
    val readTree = om.readTree(response.body)
    try {
        return readTree[key].asText()
    } catch (e: IndexOutOfBoundsException) {
        throw NoSuchElementException("${javaClass.name}가 응답 추출에 실패함 ${response.headers.eTag}")
    }
}

해당메서드를 호출하면 끝

fun fetch(): String {
    val restTemplate = apiUtil.createRestTemplate()
    val url = apiUtil.buildUrl(getBaseUrl(), null, false)
    val response = apiUtil.fetchApiResponse(restTemplate, url, HttpMethod.POST, createHttpEntity())
    return extractResponseValue(response, "access_token")
}

이제 테스트를 돌려보면 토큰만 빼내는걸 볼수있음

만약 토큰을 저장해야하면,expired도 같이 빼내서 저장하면 될듯

 

이제 등락api를 만들어야함

이건 좀 여러갈래로 확장될 가능성이 있어보이니,인터페이스를 만들고 구현체를 만들자

인터페이스는

interface RankFetcher {
    fun fetch(): List<String>
}

단순하게 만들었음

구현체는 apiUtil을 di받으면서,api가 요청했던 값들을 다 넣으면됨

class HighRisersFetcher(
    private val apiUtil: ApiUtil,
) : RankFetcher {
    override fun fetch(): List<String> {
        val restTemplate = apiUtil.createRestTemplate()
        val url = apiUtil.buildUrl(getBaseUrl(), createQueryParams())
        val httpEntity = createHttpEntity()
        val response = apiUtil.fetchApiResponse(restTemplate, url, HttpMethod.GET, httpEntity)
        print(response)
        return extractResponseAsList(response)
        // return response.body.lines()
    }

    private fun createHttpEntity(): HttpEntity<String> {
        val headers = HttpHeaders()
        headers.set("content-type", "application/json; charset=utf-8")
        headers.set("authorization", KoreanInvTokenFetcher(apiUtil).fetch())
        headers.set("appkey", ApiConfig.getKoreaInvKey())
        headers.set("appsecret", ApiConfig.getKoreaSecretKey())
        headers.set("tr_id", "FHPST01700000")
        headers.set("custtype", "P")

        return HttpEntity<String>("", headers)
    }

    private fun getBaseUrl(): String = "https://openapi.koreainvestment.com:9443/uapi/domestic-stock/v1/ranking/fluctuation"

    private fun createQueryParams(): MultiValueMap<String, String> {
        val resMap: MultiValueMap<String, String> = LinkedMultiValueMap()
        resMap["fid_cond_mrkt_div_code"] = "J"
        resMap["fid_cond_scr_div_code"] = "20170"
        resMap["fid_input_iscd"] = "0000"
        resMap["fid_rank_sort_cls_code"] = "0"
        resMap["fid_input_cnt_1"] = "0"
        resMap["fid_prc_cls_code"] = "1"
        resMap["fid_trgt_cls_code"] = "0"
        resMap["fid_trgt_exls_cls_code"] = "0"
        resMap["fid_div_cls_code"] = "0"
        return resMap
    }

    private fun extractResponseAsList(response: ResponseEntity<String>): List<String> {
        val itemNode = extractNodeList(response)
        val resList = mutableListOf<String>()
        for (node in itemNode) {
            resList.add(apiUtil.extractNodeValue(node, "hts_kor_isnm"))
        }
        return resList
    }

    private fun extractNodeList(response: ResponseEntity<String>): List<JsonNode> {
        val om = jacksonObjectMapper()
        val readTree = om.readTree(response.body)
        try {
            return readTree
                .get("output")
                .toList()
        } catch (e: IndexOutOfBoundsException) {
            throw NoSuchElementException("${javaClass.name}가 노드 추출에 실패함 ${response.headers.eTag}")
        }
    }
}

 

여기서 특이하다 할만한건,

private fun createHttpEntity(): HttpEntity<String> {
    val headers = HttpHeaders()
    headers.set("content-type", "application/json; charset=utf-8")
    headers.set("authorization", KoreanInvTokenFetcher(apiUtil).fetch())
    headers.set("appkey", ApiConfig.getKoreaInvKey())
    headers.set("appsecret", ApiConfig.getKoreaSecretKey())
    headers.set("tr_id", "FHPST01700000")
    headers.set("custtype", "P")

    return HttpEntity<String>("", headers)
}

여기서 KoreanInvTokenFetcher를 바로 생성해서 사용한건데,원래는 내부에서 new로 생성하는게 좋지않은 패턴이지만

이 클래스 특성상 두클래스가 한몸같은 느낌이기도 하고,나중에 다른 api로 바꾸게된다면 저 부분이 사라지면 사라졌지 변경이 일어나진 않을거라는 판단하에 바로 new를 때렸음

 

이제 테스트를 돌려보면

class HighRisersFetcherTest {
    @Test
    fun fetchTest() {
        // Given
        val highRisersFetcher = HighRisersFetcher(ApiUtilImpl())

        // When
        val highRisers = highRisersFetcher.fetch()

        // Then
        print(highRisers)
    }
}

실패하는걸 볼수있음

메시지를 보면,토큰을 보낼때 Bearer를 안붙여줘서 저러는거같음

private fun createHttpEntity(): HttpEntity<String> {
    val headers = HttpHeaders()
    headers.set("content-type", "application/json; charset=utf-8")
    headers.set("authorization", "Bearer "+KoreanInvTokenFetcher(apiUtil).fetch())
    headers.set("appkey", ApiConfig.getKoreaInvKey())
    headers.set("appsecret", ApiConfig.getKoreaSecretKey())
    headers.set("tr_id", "FHPST01700000")
    headers.set("custtype", "P")

    return HttpEntity<String>("", headers)
}

Bearer를 추가하고 보내면

api호출은 성공했지만,바디가 텅비어있는걸 볼수있음

아마 지금시간이(작성시간) 오후 9시라서,장이 서지 않아서 비어있는거 같음

 

그러니 나중에 장이 열리고나서 다시 테스트를 해야겠음

 

현재시간 오전 11시,장이 열려도 똑같이 빈 리턴이 들어옴

api문서와 예제를 자세히 보니

입력값없을때라는게 빈칸을 넣으라는 소리라는거였음

private fun createQueryParams(): MultiValueMap<String, String> {
    val resMap: MultiValueMap<String, String> = LinkedMultiValueMap()
    resMap["fid_cond_mrkt_div_code"] = "J"
    resMap["fid_cond_scr_div_code"] = "20170"
    resMap["fid_input_iscd"] = "0000"
    resMap["fid_rank_sort_cls_code"] = "0"
    resMap["fid_input_cnt_1"] = "0"
    resMap["fid_prc_cls_code"] = "1"
    resMap["fid_trgt_cls_code"] = "0"
    resMap["fid_trgt_exls_cls_code"] = "0"
    resMap["fid_div_cls_code"] = "0"

    resMap["fid_rsfl_rate1"] = ""
    resMap["fid_rsfl_rate2"] = ""
    resMap["fid_input_price_1"] = ""
    resMap["fid_input_price_2"] = ""
    resMap["fid_vol_cnt"] = ""

    return resMap
}

이렇게 다시 채워주고 날려보면

성공하는걸 볼수있음