-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 차트 조회 성능 향상을 위한 Caffeine 캐시 도입 #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2d87a04
358f7f2
cf2c8ba
c6cfc88
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.coinflow.chart.cache.hot; | ||
|
|
||
| import com.coinflow.domain.ohlc.snapshot.OhlcCandleSnapshot; | ||
| import com.coinflow.event.kline.KlineEvent; | ||
| import java.time.Instant; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| public record OhlcHotWindow( | ||
| List<OhlcCandleSnapshot> finalizedCandles, | ||
| KlineEvent liveCandle, | ||
| Instant synchronizedAt, | ||
| long eventVersion | ||
| ) { | ||
|
|
||
| public OhlcHotWindow { | ||
| finalizedCandles = List.copyOf(finalizedCandles); | ||
| } | ||
|
|
||
| public Optional<KlineEvent> liveCandleOptional() { | ||
| return Optional.ofNullable(liveCandle); | ||
| } | ||
|
|
||
| public List<OhlcCandleSnapshot> findFinalizedRange(long toExclusive, int limit) { | ||
| List<OhlcCandleSnapshot> matches = finalizedCandles.stream() | ||
| .filter(candle -> candle.epochSeconds() < toExclusive) | ||
| .toList(); | ||
| int fromIndex = Math.max(0, matches.size() - limit); | ||
| return new ArrayList<>(matches.subList(fromIndex, matches.size())); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,136 @@ | ||||||||||||||||||||||||||||||
| package com.coinflow.chart.cache.hot; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import com.coinflow.domain.ohlc.constant.OhlcWindowPolicy; | ||||||||||||||||||||||||||||||
| import com.coinflow.domain.ohlc.snapshot.OhlcCandleSnapshot; | ||||||||||||||||||||||||||||||
| import com.coinflow.event.kline.KlineEvent; | ||||||||||||||||||||||||||||||
| import com.github.benmanes.caffeine.cache.Cache; | ||||||||||||||||||||||||||||||
| import com.github.benmanes.caffeine.cache.Caffeine; | ||||||||||||||||||||||||||||||
| import io.micrometer.core.instrument.MeterRegistry; | ||||||||||||||||||||||||||||||
| import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; | ||||||||||||||||||||||||||||||
| import java.time.Instant; | ||||||||||||||||||||||||||||||
| import java.time.LocalDateTime; | ||||||||||||||||||||||||||||||
| import java.time.ZoneOffset; | ||||||||||||||||||||||||||||||
| import java.util.ArrayList; | ||||||||||||||||||||||||||||||
| import java.util.Comparator; | ||||||||||||||||||||||||||||||
| import java.util.LinkedHashMap; | ||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||
| import java.util.Optional; | ||||||||||||||||||||||||||||||
| import java.util.concurrent.TimeUnit; | ||||||||||||||||||||||||||||||
| import java.util.concurrent.atomic.AtomicBoolean; | ||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Component; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Component | ||||||||||||||||||||||||||||||
| public class OhlcHotWindowStore { | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private static final int MAX_CACHE_KEYS = 500; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private final Cache<String, OhlcHotWindow> cache; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public OhlcHotWindowStore(MeterRegistry meterRegistry) { | ||||||||||||||||||||||||||||||
| cache = Caffeine.newBuilder() | ||||||||||||||||||||||||||||||
| .maximumSize(MAX_CACHE_KEYS) | ||||||||||||||||||||||||||||||
| .expireAfterAccess(10, TimeUnit.MINUTES) | ||||||||||||||||||||||||||||||
| .recordStats() | ||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||
| CaffeineCacheMetrics.monitor(meterRegistry, cache, "ohlc_hot_window_cache"); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public Optional<OhlcHotWindow> get(String symbol, String interval) { | ||||||||||||||||||||||||||||||
| return Optional.ofNullable(cache.getIfPresent(key(symbol, interval))); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public void replace( | ||||||||||||||||||||||||||||||
| String symbol, | ||||||||||||||||||||||||||||||
| String interval, | ||||||||||||||||||||||||||||||
| List<OhlcCandleSnapshot> finalizedCandles, | ||||||||||||||||||||||||||||||
| Optional<KlineEvent> liveCandle, | ||||||||||||||||||||||||||||||
| Instant synchronizedAt | ||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||
| KlineEvent openLiveCandle = liveCandle.filter(event -> !event.closed()).orElse(null); | ||||||||||||||||||||||||||||||
| cache.put( | ||||||||||||||||||||||||||||||
| key(symbol, interval), | ||||||||||||||||||||||||||||||
| new OhlcHotWindow(normalize(finalizedCandles), openLiveCandle, synchronizedAt, 0) | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public long eventVersion(String symbol, String interval) { | ||||||||||||||||||||||||||||||
| OhlcHotWindow current = cache.getIfPresent(key(symbol, interval)); | ||||||||||||||||||||||||||||||
| return current == null ? 0 : current.eventVersion(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public boolean replaceIfVersion( | ||||||||||||||||||||||||||||||
| String symbol, | ||||||||||||||||||||||||||||||
| String interval, | ||||||||||||||||||||||||||||||
| List<OhlcCandleSnapshot> finalizedCandles, | ||||||||||||||||||||||||||||||
| Optional<KlineEvent> liveCandle, | ||||||||||||||||||||||||||||||
| Instant synchronizedAt, | ||||||||||||||||||||||||||||||
| long expectedVersion | ||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||
| String key = key(symbol, interval); | ||||||||||||||||||||||||||||||
| KlineEvent openLiveCandle = liveCandle.filter(event -> !event.closed()).orElse(null); | ||||||||||||||||||||||||||||||
| AtomicBoolean replaced = new AtomicBoolean(); | ||||||||||||||||||||||||||||||
| cache.asMap().compute(key, (ignored, current) -> { | ||||||||||||||||||||||||||||||
| long currentVersion = current == null ? 0 : current.eventVersion(); | ||||||||||||||||||||||||||||||
| if (currentVersion != expectedVersion) { | ||||||||||||||||||||||||||||||
| return current; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| replaced.set(true); | ||||||||||||||||||||||||||||||
| return new OhlcHotWindow( | ||||||||||||||||||||||||||||||
| normalize(finalizedCandles), openLiveCandle, synchronizedAt, currentVersion); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| return replaced.get(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public void applyEvent(KlineEvent event) { | ||||||||||||||||||||||||||||||
| String key = key(event.symbol(), event.interval()); | ||||||||||||||||||||||||||||||
| cache.asMap().compute(key, (ignored, current) -> { | ||||||||||||||||||||||||||||||
| OhlcHotWindow base = current != null | ||||||||||||||||||||||||||||||
| ? current | ||||||||||||||||||||||||||||||
| : new OhlcHotWindow(List.of(), null, Instant.EPOCH, 0); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!event.closed()) { | ||||||||||||||||||||||||||||||
| return new OhlcHotWindow( | ||||||||||||||||||||||||||||||
| base.finalizedCandles(), event, base.synchronizedAt(), base.eventVersion() + 1); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+92
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 비마감 이벤트를 Pub/Sub 경로는 전달 순서를 보장하지 않습니다. 재연결이나 다중 채널 상황에서 과거 버킷의 live 이벤트가 나중에 도착할 수 있습니다. 현재 코드는 그 이벤트로 최신 live 캔들을 덮습니다. 결과는 차트에서 현재 봉이 과거 값으로 되돌아가는 현상입니다. 이미 마감 처리 경로(L100-102)에서는 🛡️ 제안 diff if (!event.closed()) {
+ boolean older = base.liveCandleOptional()
+ .filter(current -> current.startTime() > event.startTime())
+ .isPresent();
+ if (older) {
+ return base;
+ }
return new OhlcHotWindow(
base.finalizedCandles(), event, base.synchronizedAt(), base.eventVersion() + 1);
}같은 버킷 내 재전송은 최신 값으로 덮는 편이 맞습니다. 그래서 비교 조건은 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| List<OhlcCandleSnapshot> updated = new ArrayList<>(base.finalizedCandles()); | ||||||||||||||||||||||||||||||
| updated.removeIf(candle -> candle.epochSeconds() == event.startTime()); | ||||||||||||||||||||||||||||||
| updated.add(toSnapshot(event)); | ||||||||||||||||||||||||||||||
| KlineEvent live = base.liveCandleOptional() | ||||||||||||||||||||||||||||||
| .filter(candidate -> candidate.startTime() != event.startTime()) | ||||||||||||||||||||||||||||||
| .orElse(null); | ||||||||||||||||||||||||||||||
| return new OhlcHotWindow( | ||||||||||||||||||||||||||||||
| normalize(updated), live, base.synchronizedAt(), base.eventVersion() + 1); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private List<OhlcCandleSnapshot> normalize(List<OhlcCandleSnapshot> candles) { | ||||||||||||||||||||||||||||||
| Map<Long, OhlcCandleSnapshot> byTimestamp = new LinkedHashMap<>(); | ||||||||||||||||||||||||||||||
| candles.stream() | ||||||||||||||||||||||||||||||
| .sorted(Comparator.comparingLong(OhlcCandleSnapshot::epochSeconds)) | ||||||||||||||||||||||||||||||
| .forEach(candle -> byTimestamp.put(candle.epochSeconds(), candle)); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| List<OhlcCandleSnapshot> normalized = new ArrayList<>(byTimestamp.values()); | ||||||||||||||||||||||||||||||
| int fromIndex = Math.max(0, normalized.size() - OhlcWindowPolicy.MAX_SIZE); | ||||||||||||||||||||||||||||||
| return List.copyOf(normalized.subList(fromIndex, normalized.size())); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private OhlcCandleSnapshot toSnapshot(KlineEvent event) { | ||||||||||||||||||||||||||||||
| LocalDateTime bucketTime = LocalDateTime.ofEpochSecond( | ||||||||||||||||||||||||||||||
| event.startTime(), 0, ZoneOffset.UTC); | ||||||||||||||||||||||||||||||
| return new OhlcCandleSnapshot( | ||||||||||||||||||||||||||||||
| bucketTime, | ||||||||||||||||||||||||||||||
| event.startTime(), | ||||||||||||||||||||||||||||||
| event.open(), | ||||||||||||||||||||||||||||||
| event.high(), | ||||||||||||||||||||||||||||||
| event.low(), | ||||||||||||||||||||||||||||||
| event.close(), | ||||||||||||||||||||||||||||||
| event.volume() | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private String key(String symbol, String interval) { | ||||||||||||||||||||||||||||||
| return symbol.toLowerCase() + ":" + interval; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+133
to
+135
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🐛 제안 diff private String key(String symbol, String interval) {
- return symbol.toLowerCase() + ":" + interval;
+ return symbol.toLowerCase(Locale.ROOT) + ":" + interval;
}
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
This file was deleted.
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
버전 불일치 시 "포기"하는 정책이 핫 윈도우를 비어 있는 상태로 고정할 수 있습니다.
applyEvent는 live 캔들 이벤트마다eventVersion을 올리고, 폴링·요청 경로는 Redis I/O 이후에 버전 일치를 요구합니다. 활성 심볼에서는 그 사이 이벤트가 거의 항상 도착하므로 교체가 계속 거부되고,applyEvent가 만든 빈 확정 리스트가 그대로 남아 요청이 DB backfill로 떨어집니다.backend/coinflow-api-app/src/main/java/com/coinflow/chart/cache/hot/OhlcHotWindowStore.java#L62-L83: 확정 캔들 스냅샷은 항상 반영하고, 버전 충돌은 live 캔들 유지(또는 CAS 재시도)로 한정하세요.backend/coinflow-api-app/src/main/java/com/coinflow/chart/service/OhlcChartService.java#L266-L286:replaceIfVersion이false를 반환하면, 캐시 값이 아니라 방금 읽은finalized/live로 구성한 윈도우를 반환하세요.📍 Affects 2 files
backend/coinflow-api-app/src/main/java/com/coinflow/chart/cache/hot/OhlcHotWindowStore.java#L62-L83(this comment)backend/coinflow-api-app/src/main/java/com/coinflow/chart/service/OhlcChartService.java#L266-L286🤖 Prompt for AI Agents