Development/Spring Boot3 (Kotlin)

[Kotlin][SpringBoot3] WebClient로 외부 api 연동 방법

Tradgineer 2023. 11. 11. 16:42

 

1. api call 결과를 활용하기 위해 RestTemplate 대신 WebClient 사용

 

RestTemplate 특징

 - Spring 3.0 부터 지원

 - RESTful 형식을 지원

 - 멀티 스레드 방식

 - Blocking I/O기반의 동기 방식

 - API Spring 4.0에서 비동기 문제를 해결하고자 AsyncRestTemplate이 등장했으나, 현재 deprecated 됨

 

WebClient 특징

 - Spring 5.0 부터 지원

 - 싱글 스레드 방식

 - Non-Blocking 방식, 동기/비동기 모두 지원

 - Reactor 기반의 Functional API (Mono, Flux)

 

WebClient 이점

 - WebClient는 동기, 비동기 호출을 모두 지원합니다. WebClient는 Non-Blocking 방식으로, 호출된 시스템의 결과를 기다리지 않고 다른 작업을 처리할 수 있습니다.

 -  동시접속자 수가 적으면 거의 동일하나 만약 동시접속자 수가 늘어나게 되면, RestTemplate에 비해 WebClient가 압도적으로 좋은 퍼포먼스를 보여줍니다. 그래서 RestTemplate보다 WebClient 사용을 권장합니다.

 

 

 

 

 

2. 의존성 세팅

 

// vim build.gradle.kts

dependencies {
    // ...
    // webclient
    implementation("org.springframework.boot:spring-boot-starter-webflux:3.1.5")
    // ...
}

 

 

 

 

 

3. 예제 소스코드 - 스케줄러, 서비스

 

// vim BybitScheduler.kt

package com.dev.kopring00.external.bybit

import com.dev.kopring00.external.bybit.exchangeinfo.entities.BybitInstrument
import com.dev.kopring00.external.bybit.exchangeinfo.services.InstrumentInfoService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component

@Component
class BybitScheduler @Autowired constructor(
    private val instrumentInfoService: InstrumentInfoService
) {
    private fun updateData() {
        val api: String = "https://api.bybit.com"
        val dataList: List<BybitInstrument> = instrumentInfoService.getDataList(api)
        for (data in dataList) {
            print(data.symbol)
        }
    }

    @Scheduled(fixedRate = 60000)
    fun scheduledUpdate() {
        updateData()
    }
}

 

// vim InstrumentInfoService.kt

package com.dev.kopring00.external.bybit.exchangeinfo.services

import com.dev.kopring00.external.bybit.exchangeinfo.entities.BybitInstrument
import com.dev.kopring00.external.bybit.exchangeinfo.entities.InstrumentsInfoData
import com.dev.kopring00.external.bybit.exchangeinfo.entities.MarginTradingData
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient

@Service
class InstrumentInfoService() {

    private val logger: Logger = LoggerFactory.getLogger(InstrumentInfoService::class.java)

    fun getDataList(apiUrl: String): List<BybitInstrument> {
        val client = WebClient.create(apiUrl)
        val result = client.get()
            .uri("/v5/market/instruments-info?category=spot")
            .retrieve()
            .bodyToMono(InstrumentsInfoData::class.java)
        try {
            val rawData = result.block()
            if (rawData != null) {
                // 정상 return인 경우
                if (rawData.retCode == 0) {
                    // 정상
                    return setDataList(rawData)
                } else if (rawData.retCode == 10001) {
                    // 없는 카테고리
                    logger.error("[ external:getData ] bybit api return message : ${rawData.retMsg} , return code : ${rawData.retCode}")
                } else {
                    // 이외 비정상
                    logger.error("[ external:getData ] bybit api unexpected return data : ${rawData}")
                }
            } else {
                // null을 받은 경우
                logger.error("[ external:getData ] bybit api return is null")
            }
        } catch (e: Exception) {
            logger.error("[ external:getData ] bybit api error message : ${e.message}")
        }

        return listOf()
    }

    private fun setDataList(rawData: InstrumentsInfoData): List<BybitInstrument> {
        var resultList: List<BybitInstrument> = listOf()
        val dataList = rawData.result?.list
        if (dataList != null) {
            for (data in dataList) {
                // isInnovation
                val isInnovationZoneToken: Boolean? = if (data.innovation == "1") {
                    true
                } else if (data.innovation == "0") {
                    false
                } else {
                    null
                }
                resultList += BybitInstrument(
                    data.symbol,
                    data.baseCoin,
                    data.quoteCoin,
                    isInnovationZoneToken,
                    if (data.status == "Trading") {
                        true
                    } else {
                        logger.warn("[ external:setDataList ] bybit status is not expected : ${data.status} (we expected only status='trading')")
                        false
                    },
                    MarginTradingData(
                        if (data.marginTrading == "both" || data.marginTrading == "normalSpotOnly") {
                            true
                        } else {
                            false
                        },
                        if (data.marginTrading == "both" || data.marginTrading == "none") {
                            null
                        } else if (data.marginTrading == "normalSpotOnly" || data.marginTrading == "utaOnly") {
                            data.marginTrading
                        } else {
                            logger.warn("[ external:setDataList ] bybit marginTrading is not expected : ${data.marginTrading} (we expected only marginTrading in ['both', 'none', 'normalSpotOnly', 'utaOnly'])")
                            data.marginTrading
                        }
                    ),
                    data.lotSizeFilter,
                    data.priceFilter
                )
            }
        }
        return resultList
    }
}

 

 

 

 

 

4. 예제 소스코드 model

 

// vim BybitInstrument.kt

package com.dev.kopring00.external.bybit.exchangeinfo.entities

data class BybitInstrument(
    val symbol: String?,
    val assetCode: String?,      // = baseCoin
    val paymentCode: String?,    // = quoteCoin
    val IsInnovationZoneToken: Boolean?,
    val isTrading: Boolean?,
    val marginTrading: MarginTradingData,
    val lotSizeFilter: LotSizeFilterData?,
    val priceFilter: PriceFilterData?
)

 

// InstrumentsInfoData.kt

package com.dev.kopring00.external.bybit.exchangeinfo.entities

data class InstrumentsInfoData (
    var retCode    : Int?        = null,
    var retMsg     : String?     = null,
    var result     : ResultData? = null,
    var retExtInfo : Map<String, String>? = null,
    var time       : Long?        = null
)

 

// LotSizeFilterData.kt

package com.dev.kopring00.external.bybit.exchangeinfo.entities

data class LotSizeFilterData (
    var basePrecision  : String? = null,
    var quotePrecision : String? = null,
    var minOrderQty    : String? = null,
    var maxOrderQty    : String? = null,
    var minOrderAmt    : String? = null,
    var maxOrderAmt    : String? = null
)

 

// vim MarginTradingData.kt

package com.dev.kopring00.external.bybit.exchangeinfo.entities

data class MarginTradingData(
    val isEnabled: Boolean,
    val data: String? = null    // both, none인 경우에는 null이고 이외 가끔 있는건 normalSpotOnly, utaOnly
)

 

// vim PriceFilterData.kt

package com.dev.kopring00.external.bybit.exchangeinfo.entities

data class PriceFilterData(
    var tickSize : String? = null
)

 

// vim ResultData.kt

package com.dev.kopring00.external.bybit.exchangeinfo.entities

data class ResultData (
    var category: String? = null,
    var list: ArrayList<SymbolData> = arrayListOf()
)

 

// vim SymbolData.kt

package com.dev.kopring00.external.bybit.exchangeinfo.entities

data class SymbolData(
    var symbol        : String?        = null,
    var baseCoin      : String?        = null,
    var quoteCoin     : String?        = null,
    var innovation    : String?        = null,
    var status        : String?        = null,
    var marginTrading : String?        = null,
    // margin 가능 : both / normalSpotOnly
    // margin 불가능 : none / utaOnly (Universal Transfer Allowance)
    // UTA : 일반적으로 특정 거래소에서만 사용되는 용어입니다. 이 상태는 투자자가 UTA만 사용하여 거래를 진행할 수 있는 상태를 의미합니다. UTA가 무엇인지, 그리고 어떤 조건에서 사용되는지는 해당 거래소의 정책에 따라 다르므로, 자세한 사항은 해당 거래소에 문의해야 합니다.
    var lotSizeFilter : LotSizeFilterData? = null,
    var priceFilter   : PriceFilterData? = null
)

 

 

 

 

 

5. bybit api의 return값 예시

 

{
    "retCode": 0,
    "retMsg": "OK",
    "result": {
        "category": "spot",
        "list": [
            {
                "symbol": "BTCUSDT",
                "baseCoin": "BTC",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.000001",
                    "quotePrecision": "0.00000001",
                    "minOrderQty": "0.000048",
                    "maxOrderQty": "71.73956243",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "2000000"
                },
                "priceFilter": {
                    "tickSize": "0.01"
                }
            },
            {
                "symbol": "ETHUSDT",
                "baseCoin": "ETH",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.00001",
                    "quotePrecision": "0.0000001",
                    "minOrderQty": "0.00062",
                    "maxOrderQty": "1229.2336343",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "2000000"
                },
                "priceFilter": {
                    "tickSize": "0.01"
                }
            },
            {
                "symbol": "XRPUSDT",
                "baseCoin": "XRP",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.01",
                    "quotePrecision": "0.000001",
                    "minOrderQty": "2.63",
                    "maxOrderQty": "4169272.461955",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "2000000"
                },
                "priceFilter": {
                    "tickSize": "0.0001"
                }
            },
            {
                "symbol": "EOSUSDT",
                "baseCoin": "EOS",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.01",
                    "quotePrecision": "0.000001",
                    "minOrderQty": "0.78",
                    "maxOrderQty": "785751.702462",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "600000"
                },
                "priceFilter": {
                    "tickSize": "0.0001"
                }
            },
            {
                "symbol": "ETHBTC",
                "baseCoin": "ETH",
                "quoteCoin": "BTC",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.001",
                    "quotePrecision": "0.000000001",
                    "minOrderQty": "0.003",
                    "maxOrderQty": "271.90273271",
                    "minOrderAmt": "0.0002",
                    "maxOrderAmt": "13.91"
                },
                "priceFilter": {
                    "tickSize": "0.000001"
                }
            },
            {
                "symbol": "XRPBTC",
                "baseCoin": "XRP",
                "quoteCoin": "BTC",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.1",
                    "quotePrecision": "0.000000001",
                    "minOrderQty": "5",
                    "maxOrderQty": "614628.14996927",
                    "minOrderAmt": "0.0001",
                    "maxOrderAmt": "10"
                },
                "priceFilter": {
                    "tickSize": "0.00000001"
                }
            },
            {
                "symbol": "DOTUSDT",
                "baseCoin": "DOT",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.001",
                    "quotePrecision": "0.000001",
                    "minOrderQty": "0.107",
                    "maxOrderQty": "486618.004866",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "2000000"
                },
                "priceFilter": {
                    "tickSize": "0.001"
                }
            },
            {
                "symbol": "XLMUSDT",
                "baseCoin": "XLM",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "both",
                "lotSizeFilter": {
                    "basePrecision": "0.1",
                    "quotePrecision": "0.000001",
                    "minOrderQty": "7.9",
                    "maxOrderQty": "5670005.670006",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "600000"
                },
                "priceFilter": {
                    "tickSize": "0.00001"
                }
            },
            ...
            {
                "symbol": "TOKENUSDT",
                "baseCoin": "TOKEN",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "none",
                "lotSizeFilter": {
                    "basePrecision": "0.01",
                    "quotePrecision": "0.0000001",
                    "minOrderQty": "66.67",
                    "maxOrderQty": "3333333",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "50000"
                },
                "priceFilter": {
                    "tickSize": "0.00001"
                }
            },
            {
                "symbol": "MEMEUSDT",
                "baseCoin": "MEME",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "none",
                "lotSizeFilter": {
                    "basePrecision": "0.01",
                    "quotePrecision": "0.00000001",
                    "minOrderQty": "1",
                    "maxOrderQty": "50000000",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "50000"
                },
                "priceFilter": {
                    "tickSize": "0.000001"
                }
            },
            {
                "symbol": "SHRAPUSDT",
                "baseCoin": "SHRAP",
                "quoteCoin": "USDT",
                "innovation": "0",
                "status": "Trading",
                "marginTrading": "none",
                "lotSizeFilter": {
                    "basePrecision": "0.01",
                    "quotePrecision": "0.0000001",
                    "minOrderQty": "1",
                    "maxOrderQty": "1515152",
                    "minOrderAmt": "1",
                    "maxOrderAmt": "50000"
                },
                "priceFilter": {
                    "tickSize": "0.00001"
                }
            }
        ]
    },
    "retExtInfo": {},
    "time": 1699687010588
}