인기 종목 시세 조회 구현 - #48
Conversation
… quotes - Implemented YahooFinancePriceClient using WebClient to fetch stock prices from Yahoo Finance API - Added YahooResponseParser to convert raw JSON responses into structured PriceSnapshot records - Created PriceSnapshot record to encapsulate ticker, price, volume, currency, and timestamp - Included a basic integration test (YahooFinancePriceClientTest) to validate API response - Configured Spring Boot application with scheduling and component scanning for batch collector
…ls and multi-store persistence - Added PriceCollectScheduler to periodically fetch stock prices using @scheduled - Introduced FetchIntervalService to apply per-ticker fetch intervals before collecting - Implemented PopularTickerService as a stub for retrieving popular ticker symbols - Developed PriceCollectorService to fetch prices via YahooFinancePriceClient and store them - Stored fetched prices into Redis (RedisPriceStore) with TTL and TimescaleDB (TimescalePriceStore) using upsert - Added robust error handling and logging across all services
- Removed legacy Yahoo Finance integration from price collection flow - Added AlphaVantageClient to fetch quotes using GLOBAL_QUOTE endpoint - Implemented AlphaVantageResponseParser to parse quote responses into PriceSnapshot objects - Updated PriceCollectorService to use Alpha Vantage client and parser - Added basic integration test for AlphaVantageClient - Formatted fetched quotes into a JSON array structure for parsing
PostgreSQL's TIMESTAMPTZ maps to java.sql.Timestamp, not Instant. Using Timestamp.from(Instant) ensures correct and explicit conversion.
- Added domain-level port interface `PriceDataProvider` to decouple app from infra - Moved `PriceSnapshot` into domain-price model to remove infra dependency - Implemented `AlphaVantageDataProvider` in infra/external as first provider * internally composes AlphaVantageClient + parser to return domain models - Updated `PriceCollectorService` to depend only on `PriceDataProvider` * no longer directly tied to AlphaVantage client/parser - Reworked unit tests (PriceCollectorServiceTest) to mock `PriceDataProvider` * provider implementations now tested in infra module independently This refactoring enables swapping between Alpha Vantage, Polygon.io, Yahoo Finance (or future providers) by configuration only, without modifying app logic.
…#35) - Define increment(ticker, window, atUtc) and topN(window, limit, nowUtc) - Add RankItem(ticker, requests) as a domain value object - Use Instant (UTC) to enable deterministic tests - Decouple domain from Redis via a clean port interface
…s.unionAndStore and template expire (#34) - Use opsForZSet().unionAndStore(...) instead of connection.zUnionStore(...) - Set TTL via StringRedisTemplate.expire(key, Duration) - Keep a 20s cache on union result key
…and enum-based window parsing (#33) - Inject PopularQueryUseCase instead of concrete PopularService (DIP) - Parse window via PopularWindow.fromQuery(...) with default "H1" (was String "1h" + valueOf) - Delegate US-only/exchange validation to application layer (removed hard-coded whitelist in controller) - Map response DTO explicitly: new PopularItemDto(i.ticker(), i.name(), i.exchange(), i.requests()) - Keep endpoint GET /api/v1/popular; params unchanged (limit default=10, exchange default=US)
- Count request hits per ticker on successful (2xx) responses for /api/v1/quotes/** and /api/v1/charts/** - Extract ticker from URI and normalize to upper-case - Increment multiple windows (M15, H1, D1, D7) through PopularCounterPort (no infra dependency) - Use afterCompletion to ensure only successful requests are counted - Wire interceptor in WebMvcConfig with explicit path patterns
| private final FetchIntervalService fetchIntervalService; | ||
| private final PriceCollectorService priceCollectorService; | ||
|
|
||
| @Scheduled(fixedRate = 1000) |
| public void increment(String ticker, PopularWindow window, Instant atUtc) { | ||
| String key = minuteKey(window, atUtc); | ||
| redis.opsForZSet().incrementScore(key, ticker, 1.0); | ||
| redis.expire(key, window.length().plusSeconds(3600)); // 여유 TTL |
…d call counter once (#34) - Add PopularHitRecorder in app layer with configurable windows (app.popular.count.windows) - Interceptor now extracts ticker only and calls recorder.recordHit(ticker, now) - Apply SRP/DIP: interceptor no longer knows concrete windows
…unterAdapter (#34) - Execute ZINCRBY and EXPIRE for multiple windows in one pipeline to reduce RTT - Keep single-increment method for compatibility
…erties (#34) - Add PopularCountProperties (app.popular.count.windows) with default [M15,H1,D1,D7] - Inject Set<PopularWindow> into PopularHitRecorder; call counter.incrementBatch once - Remove string parsing/uppercasing utility; rely on Spring Boot type conversion - Wire @EnableConfigurationProperties(PopularCountProperties) in application bootstrap - Docs: note YAML can be list or comma-separated string
… hardcoding (#34) - Add TickerExtractor interface with composite implementation - Implement UriTemplateVariablesTickerExtractor (default), plus query/header extractors - Introduce @ConfigurationProperties for candidate keys (path vars, query params, headers) - Update PopularCountingInterceptor to delegate extraction and remain thin
…g binding (#33, #35) - Introduce PopularQueryProperties (maxLimit, defaultExchange, allowedExchanges) - Add ExchangeFilter strategy (UsExchangeFilter) to encapsulate exchange rules - Add PopularItemAssembler to map ranks+meta → PopularItem with filtering - Inject Clock for deterministic time handling - Slim down PopularService to orchestration only (SRP/DIP)
- Verify single incrementBatch call with configured windows - Assert window set is passed as-is (e.g., M15, H1)
- Record hits only for 2xx responses - Delegate ticker extraction to TickerExtractor (mocked)
- UriTemplateVariablesTickerExtractor: reads {ticker}/{symbol} from path vars
- QueryParamTickerExtractor: reads ?ticker/ ?symbol
- HeaderTickerExtractor: reads X-Ticker
- CompositeTickerExtractor: respects delegate precedence
- US-group requests allow NASDAQ/NYSE/AMEX - Specific exchange requests must match exactly - Default exchange falls back to US when meta is missing
- Default params: window=H1, limit=10, exchange=US - Validate JSON mapping to PopularItemDto
| @Override | ||
| public void afterCompletion(@NotNull HttpServletRequest req, @NotNull HttpServletResponse res, @NotNull Object handler, Exception ex) { | ||
| if (!(handler instanceof HandlerMethod)) return; | ||
| if (res.getStatus() < 200 || res.getStatus() >= 300) return; |
There was a problem hiding this comment.
이러한 필드는 네이밍을 잘 정해서 private 함수로 분리하면 어떨까요?
| for (var r : ranks) { | ||
| var meta = metas.get(r.ticker()); | ||
| String name = (meta == null || meta.name() == null) ? "" : meta.name(); | ||
| String ex = (meta == null || meta.exchange() == null || meta.exchange().isBlank()) |
There was a problem hiding this comment.
비슷한 코드가 많은데 util class를 만들어서 분리하면 어떨까요?
테스트 코드 작성하기도 좋을 것 같습니다.
| @EnableConfigurationProperties(PopularQueryProperties.class) | ||
| public class PopularQueryConfig { | ||
| @Bean | ||
| public Clock clock() { |
| @PathVariable String tickerCode, | ||
| @RequestParam String interval, | ||
| @RequestParam ZonedDateTime from, | ||
| @RequestParam ZonedDateTime to) { |
There was a problem hiding this comment.
위에서 UTC 를 정의했는데 ZonedDateTime 을 쓰신것과 어떠한 연관이 있나요?
Client 에서 zone 데이터를 다루는걸 의도하셨나요??
| public List<PopularItemDto> popular( | ||
| @RequestParam(defaultValue = "H1") String window, | ||
| @RequestParam(defaultValue = "10") int limit, | ||
| @RequestParam(required = false, defaultValue = "US") String exchange |
There was a problem hiding this comment.
exchange 에 USSSSS 나 KRRRR 과 같이 의도하지 앟은 데이터가 들어오면 어떻게되나요??
| @GetMapping("/popular") | ||
| public List<PopularItemDto> popular( | ||
| @RequestParam(defaultValue = "H1") String window, | ||
| @RequestParam(defaultValue = "10") int limit, |
There was a problem hiding this comment.
limit 이 -100 이나 Long.MAX_VALUE 값이 들어오면 어떻게 되나요??
|
|
||
| @GetMapping("/popular") | ||
| public List<PopularItemDto> popular( | ||
| @RequestParam(defaultValue = "H1") String window, |
There was a problem hiding this comment.
윈도우가 예상치 못한 "윈도우" 라는 문자열이 들어오면 어떻게 되나요??
| ) { | ||
| PopularWindow w = PopularWindow.fromQuery(window); // 도메인 enum에 fromQuery 제공 | ||
| return useCase.getPopular(w, limit, exchange).stream() | ||
| .map(i -> new PopularItemDto(i.ticker(), i.name(), i.exchange(), i.requests())) |
There was a problem hiding this comment.
캡슐화를 위해 new PopularItemDto(i) 나 PopularItemDto.of(i) 와 같이 만들면 어떨까요?
| } | ||
|
|
||
| @Override | ||
| public Optional<String> extract(HttpServletRequest request, @Nullable Object handler) { |
| int top = Math.min(props.getMaxLimit(), Math.max(1, limit)); | ||
| Instant nowUtc = Instant.now(clock); | ||
|
|
||
| var ranks = counterPort.topN(window, top, nowUtc); |
There was a problem hiding this comment.
코드에 일관성이 없는데요 왜 여기선 var 를 사용하셨나요?
다른 코드에선 타입을 정확하게 지정하였는데요
| } | ||
| } | ||
|
|
||
| @Test |
| case "1h" -> H1; | ||
| case "24h", "1d" -> D1; | ||
| case "7d" -> D7; | ||
| default -> H1; |
There was a problem hiding this comment.
예상하지 못한 값이 들어왔을때 에러 처리가 있으면 좋을것 같아요~!
No description provided.