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
}