티스토리 뷰

https://ekeprl.tistory.com/69

 

[Spring AI] (1) : Google Gemini 연동 - 뉴스 요약 구현

https://ekeprl.tistory.com/68 [Spring Security - JWT] (6) : 재발급 / 로그아웃 API + Postman 테스트https://ekeprl.tistory.com/67 [Spring Security - JWT] (5) : Refresh Token 구현https://ekeprl.tistory.com/66 [Spring Security - JWT] (4) : 로그

ekeprl.tistory.com

이전 포스팅에이어 이번에는 Yahoo Finance를 연동해 실시간 주가 조회기능을 구현해보려 한다.

 

1. build.gradle.kts

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

 

먼저 WebClient 사용을 위한 의존성을 추가한다.

 

2. WebClientConfig.kt

@Configuration
class WebClientConfig {
    @Bean
    fun webClient(): WebClient {
        return WebClient.create()
    }
}

 

Config 클래스에서 Bean으로 등록하고, 필요한 곳에서 생성자를 주입받기 위해 작성했다.

 

3. StockModel 추가

data class StockModel(
    val symbol: String,
    val price: Double,
    val change: Double,      // 전일대비 가격
    val changePercent: Double // 전일대비 %
)

 

data클래스를 추가해 주고,

 

4. StockService 작성

@Service
class StockService(
    private val webClient: WebClient = WebClient.create()

) {
    private val logger = LoggerFactory.getLogger(NewsService::class.java)
    // 단일 종목 조회
    fun fetchPrice(symbol: String): StockModel {
        val url = "https://query1.finance.yahoo.com/v8/finance/chart/$symbol"

        val response = webClient.get()
            .uri(url)
            .retrieve()
            .bodyToMono(Map::class.java)
            .block() ?: throw RuntimeException("$symbol 데이터 없음")

        logger.info("RAW RESPONSE: $response")  // 추가
        val meta = ((response["chart"] as Map<*, *>)["result"] as List<*>)
            .first()
            .let { it as Map<*, *> }["meta"] as Map<*, *>

        val price = (meta["regularMarketPrice"] as? Number)?.toDouble() ?: 0.0
        val previousClose = (meta["previousClose"] as? Number)?.toDouble() ?: 0.0
        val change = price - previousClose
        val changePercent = if (previousClose != 0.0) (change / previousClose) * 100 else 0.0

        return StockModel(
            symbol = symbol,
            price = price,
            change = Math.round(change * 100) / 100.0,
            changePercent = Math.round(changePercent * 100) / 100.0
        )
    }

    // 여러 종목 동시 조회 - CompletableFuture
    fun fetchPrices(symbols: List<String>): List<StockModel> {
        val futures = symbols.map { symbol ->
            CompletableFuture.supplyAsync { fetchPrice(symbol) }
        }

        CompletableFuture.allOf(*futures.toTypedArray()).join()

        return futures.map { it.get() }
    }
}

 

서비스에서는 단일 / 여러 종목을 동시 조회하는 두 가지의 메서드를 작성했다.

 

(1) 단일 종목 조회 > fetchPrice()

 

  • webClient.get(). uri(url). retrieve() — Yahoo Finance 비공식 엔드포인트 호출한다.
  • bodyToMono(Map::class.java) — 응답을 Map 형태로 받는다.
  • . block() — 비동기 응답을 동기적으로 기다려서 값 추출한다.

Yahoo Finance의 응답은 chart > result > meta 구조로 중첩되어 있다. 따라서 필요한 값만 꺼내 사용한다.

** 또한 응답에 가격차이/백분율이 없어 

change = 현재가 - 전일종가,changePercent = (change / 전일종가) * 100 두 가지를 이용해 계산했다. **

 

(2) 다중 종목 조회 > fetchPrices()

 

  • symbols.map {... } — 종목 리스트를 순회하면서 각각 CompletableFuture.supplyAsync로 비동기 실행한다.
  • 이 시점에서 모든 종목 조회가 동시에 시작된다 (별도 스레드)
  • CompletableFuture.allOf(*futures.toTypedArray()). join() — 모든 Future가 끝날 때까지 대기한다.
  • futures.map { it.get() } — 각 Future의 결괏값 추출한다.

** 순차로 호출하면 종목 수만큼 누적이 되지만, 병렬실행을 채택하여 가장 느린 요청 하나 기준으로 끝나도록 했다.

 

5. StockController

@RestController
@RequestMapping("/stock")
class StockController(
    private val stockService: StockService
) {

    // 단일 조회
    @GetMapping("/{symbol}")
    fun getPrice(@PathVariable symbol: String): ApiResponse<StockModel> {
        return ApiResponse.success(stockService.fetchPrice(symbol))
    }

    // 다중 조회 - ?symbols=AAPL,BTC-USD,005930.KS
    @GetMapping
    fun getPrices(@RequestParam symbols: List<String>): ApiResponse<List<StockModel>> {
        return ApiResponse.success(stockService.fetchPrices(symbols))
    }
}

 

 

 

(1) 단일 조회 > GET방식 /stock/{symbol}

@PathVariable로 URL 경로의 {symbol} 값을 받아 StockService.fetchPrice()에 그대로 넘겨준다.

 

(2) 다중 조회 > GET방식 /stock? symbols=~~

@RequestParam으로 쿼리 파라미터를 받는데, 타입을 List <String>으로 받으면 Spring이 콤마(,)로 구분된 값을 자동으로

리스트로 변환해 준다.

 

6.ApiResponse.kt

data class ApiResponse<T>(
    val success: Boolean,
    val message: String?,
    val data: T?,
) {
    companion object {
        fun <T> success(data: T? = null) = ApiResponse(
            success = true,
            message = null,
            data = data,
        )

        fun error(message: String) = ApiResponse<Unit>(
            success = false,
            message = message,
            data = null,
        )
    }
}

 

단일 / 다중 조회 두 메서드 전부 ApiResponce로 감싸서 반환한다.

따라서 일관된 포맷으로 통일해서 반환하게 작성했다.

 

7. 포스트맨 실행결과

 

(0) access Token발급

https://ekeprl.tistory.com/66

 

[Spring Security - JWT] (4) : 로그인 API 및 Postman 테스트

https://ekeprl.tistory.com/65 [Spring Security - JWT] (3) : JwtFilter이전 포스팅을 통해 JWT 토큰의 생성과 검증을 담당하는 JwtUtil을 작성했다.https://ekeprl.tistory.com/64 이번 포스팅은 매 요청마다 JWT 토큰을 검증

ekeprl.tistory.com

 

(1) 단일 조회 

 

단일 조회 테스트

 

(2) 다중 조회

 

다중 조회 테스트

 

이상으로 포스팅을 마치겠습니다.

 

감사합니다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함