From 64d362f5845e28425873806d1fe5746a05e5b560 Mon Sep 17 00:00:00 2001 From: Orange flavored banana <106858113+moonwhistle@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:20:56 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20l?= =?UTF-8?q?int=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Chart/TradingChart.tsx | 7 +++- frontend/src/components/LiveTicker.tsx | 26 +++++++------- frontend/src/context/WebSocketContext.tsx | 35 +++++++------------ frontend/src/context/WebSocketContextCore.ts | 13 +++++++ frontend/src/context/useWebSocketContext.ts | 10 ++++++ frontend/src/hooks/useCoinflowWebSocket.ts | 8 +++-- 6 files changed, 59 insertions(+), 40 deletions(-) create mode 100644 frontend/src/context/WebSocketContextCore.ts create mode 100644 frontend/src/context/useWebSocketContext.ts diff --git a/frontend/src/components/Chart/TradingChart.tsx b/frontend/src/components/Chart/TradingChart.tsx index 587cce3d..0b607386 100644 --- a/frontend/src/components/Chart/TradingChart.tsx +++ b/frontend/src/components/Chart/TradingChart.tsx @@ -35,6 +35,7 @@ export const TradingChart = () => { const isFetchingRef = useRef(false); const hasMoreRef = useRef(true); const rawDataRef = useRef<{ candles: ChartCandle[], volumes: VolumeBar[] }>({ candles: [], volumes: [] }); + const ensureFullViewRef = useRef<() => void>(() => {}); // State const [activeTimeframe, setActiveTimeframe] = useState('M1'); @@ -93,7 +94,7 @@ export const TradingChart = () => { // If initial load doesn't fill viewport, load more if (!to) { - setTimeout(() => ensureFullView(), CHART_CONSTANTS.INITIAL_FILL_DELAY_MS); + setTimeout(() => ensureFullViewRef.current(), CHART_CONSTANTS.INITIAL_FILL_DELAY_MS); } } catch (err) { @@ -120,6 +121,10 @@ export const TradingChart = () => { } }, [loadChartData]); + useEffect(() => { + ensureFullViewRef.current = ensureFullView; + }, [ensureFullView]); + // --- Real-time Data Handling --- const handleWebSocketMessage = useCallback((msg: WsMessage) => { if (!mainSeriesRef.current || !volumeSeriesRef.current) return; diff --git a/frontend/src/components/LiveTicker.tsx b/frontend/src/components/LiveTicker.tsx index 6d1c51eb..17dddfe0 100644 --- a/frontend/src/components/LiveTicker.tsx +++ b/frontend/src/components/LiveTicker.tsx @@ -42,6 +42,18 @@ export const LiveTicker = () => { const handleMessage = useCallback((msg: WsMessage) => { // 1. Kline (Candle) Handling - M1 is used for highest freq ticker updates if (isKlineEvent(msg) && msg.interval === 'M1') { + const currentPrice = msg.close; + const previousPrice = prevPriceRef.current; + + if (previousPrice !== null) { + if (currentPrice > previousPrice) { + setPriceColor('up'); + } else if (currentPrice < previousPrice) { + setPriceColor('down'); + } + } + + prevPriceRef.current = currentPrice; setLastMessage(msg); } // 2. Ticker Event (Price/Volume only) - Reserved for direct ticker streams @@ -59,20 +71,6 @@ export const LiveTicker = () => { } }, [isConnected, subscribe]); - useEffect(() => { - if (lastMessage) { - const currentPrice = lastMessage.close; - if (prevPriceRef.current !== null) { - if (currentPrice > prevPriceRef.current) { - setPriceColor('up'); - } else if (currentPrice < prevPriceRef.current) { - setPriceColor('down'); - } - } - prevPriceRef.current = currentPrice; - } - }, [lastMessage]); - // Derived Display Values const currentPrice = lastMessage?.close ?? null; const currentVolume = lastMessage?.volume ?? null; diff --git a/frontend/src/context/WebSocketContext.tsx b/frontend/src/context/WebSocketContext.tsx index 30af787a..49e6706d 100644 --- a/frontend/src/context/WebSocketContext.tsx +++ b/frontend/src/context/WebSocketContext.tsx @@ -1,16 +1,6 @@ -import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import type { WsRequest } from '../types/websocket'; - -type MessageListener = (event: MessageEvent) => void; - -interface WebSocketContextType { - isConnected: boolean; - sendMessage: (message: WsRequest) => void; - addMessageListener: (listener: MessageListener) => void; - removeMessageListener: (listener: MessageListener) => void; -} - -const WebSocketContext = createContext(null); +import { WebSocketContext, type MessageListener } from './WebSocketContextCore'; interface WebSocketProviderProps { url: string; @@ -23,6 +13,8 @@ export const WebSocketProvider: React.FC = ({ url, child const reconnectTimeout = useRef | null>(null); const messageQueue = useRef([]); const listenersRef = useRef>(new Set()); + const shouldReconnectRef = useRef(false); + const connectRef = useRef<() => void>(() => {}); const addMessageListener = useCallback((listener: MessageListener) => { listenersRef.current.add(listener); @@ -54,9 +46,11 @@ export const WebSocketProvider: React.FC = ({ url, child console.log('[WS Provider] Disconnected'); setIsConnected(false); ws.current = null; + if (!shouldReconnectRef.current) return; + reconnectTimeout.current = setTimeout(() => { console.log('[WS Provider] Attempting reconnect...'); - connect(); + connectRef.current(); }, 3000); }; @@ -73,8 +67,14 @@ export const WebSocketProvider: React.FC = ({ url, child }, [url]); useEffect(() => { + connectRef.current = connect; + }, [connect]); + + useEffect(() => { + shouldReconnectRef.current = true; connect(); return () => { + shouldReconnectRef.current = false; if (ws.current) { ws.current.close(); } @@ -100,12 +100,3 @@ export const WebSocketProvider: React.FC = ({ url, child ); }; - -export const useWebSocketContext = () => { - const context = useContext(WebSocketContext); - if (!context) { - throw new Error('useWebSocketContext must be used within a WebSocketProvider'); - } - return context; -}; - diff --git a/frontend/src/context/WebSocketContextCore.ts b/frontend/src/context/WebSocketContextCore.ts new file mode 100644 index 00000000..093cba82 --- /dev/null +++ b/frontend/src/context/WebSocketContextCore.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; +import type { WsRequest } from '../types/websocket'; + +export type MessageListener = (event: MessageEvent) => void; + +export interface WebSocketContextType { + isConnected: boolean; + sendMessage: (message: WsRequest) => void; + addMessageListener: (listener: MessageListener) => void; + removeMessageListener: (listener: MessageListener) => void; +} + +export const WebSocketContext = createContext(null); diff --git a/frontend/src/context/useWebSocketContext.ts b/frontend/src/context/useWebSocketContext.ts new file mode 100644 index 00000000..f74c5056 --- /dev/null +++ b/frontend/src/context/useWebSocketContext.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { WebSocketContext } from './WebSocketContextCore'; + +export const useWebSocketContext = () => { + const context = useContext(WebSocketContext); + if (!context) { + throw new Error('useWebSocketContext must be used within a WebSocketProvider'); + } + return context; +}; diff --git a/frontend/src/hooks/useCoinflowWebSocket.ts b/frontend/src/hooks/useCoinflowWebSocket.ts index cb447fdc..030f6d86 100644 --- a/frontend/src/hooks/useCoinflowWebSocket.ts +++ b/frontend/src/hooks/useCoinflowWebSocket.ts @@ -1,5 +1,5 @@ import { useEffect, useCallback, useRef } from 'react'; -import { useWebSocketContext } from '../context/WebSocketContext'; +import { useWebSocketContext } from '../context/useWebSocketContext'; import { WsCommandType, type WsMessage } from '../types/websocket'; export const useCoinflowWebSocket = ( @@ -10,7 +10,10 @@ export const useCoinflowWebSocket = ( // useRef ensures the listener always calls the LATEST onMessage callback // without needing to re-register the listener when onMessage changes const onMessageRef = useRef(onMessage); - onMessageRef.current = onMessage; + + useEffect(() => { + onMessageRef.current = onMessage; + }, [onMessage]); useEffect(() => { const listener = (event: MessageEvent) => { @@ -54,4 +57,3 @@ export const useCoinflowWebSocket = ( unsubscribe }; }; - From 8661ee75d50eed63acc677c7396c8c13288febdd Mon Sep 17 00:00:00 2001 From: Orange flavored banana <106858113+moonwhistle@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:22:45 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aggregation/service/TickProcessServiceTest.java | 12 ++++-------- .../coinflow/handler/TickRawMessageHandlerTest.java | 10 ++++++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/coinflow-consumer-app/src/test/java/com/coinflow/aggregation/service/TickProcessServiceTest.java b/backend/coinflow-consumer-app/src/test/java/com/coinflow/aggregation/service/TickProcessServiceTest.java index 88f68cf2..688193ed 100644 --- a/backend/coinflow-consumer-app/src/test/java/com/coinflow/aggregation/service/TickProcessServiceTest.java +++ b/backend/coinflow-consumer-app/src/test/java/com/coinflow/aggregation/service/TickProcessServiceTest.java @@ -14,17 +14,14 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.connection.stream.RecordId; -import org.springframework.data.redis.core.RedisTemplate; import java.math.BigDecimal; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.Objects; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -50,8 +47,8 @@ class TickProcessServiceTest { private TickerBroadcaster tickerBroadcaster; @Mock private DbPersistService dbPersistService; - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private RedisTemplate redisTemplate; + @Mock + private BatchAckWorker batchAckWorker; @Mock private MetricRecorder metricRecorder; @Mock @@ -113,9 +110,8 @@ void processLateTickTest() { anyLong(), any(String[].class)), // 4. DB 비동기 저장 서비스 호출 확인 () -> verify(dbPersistService, times(1)).persistClosedCandleAsync(eq(symbol), any()), - // 5. 비동기 파이프라인 종료 후 Redis ACK 확인 - () -> verify(Objects.requireNonNull(redisTemplate.opsForStream()), timeout(1000)) - .acknowledge("mystream", "mygroup", recordId) + // 5. 비동기 파이프라인 종료 후 Batch ACK Worker 위임 확인 + () -> verify(batchAckWorker, timeout(1000)).addAck(recordId) ); } } diff --git a/backend/coinflow-consumer-app/src/test/java/com/coinflow/handler/TickRawMessageHandlerTest.java b/backend/coinflow-consumer-app/src/test/java/com/coinflow/handler/TickRawMessageHandlerTest.java index b44cd5fd..ab93d447 100644 --- a/backend/coinflow-consumer-app/src/test/java/com/coinflow/handler/TickRawMessageHandlerTest.java +++ b/backend/coinflow-consumer-app/src/test/java/com/coinflow/handler/TickRawMessageHandlerTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import com.coinflow.aggregation.service.TickProcessService; import com.coinflow.tick.serialization.TickRawBinaryCodec; @@ -61,8 +62,12 @@ void shouldHandleBinaryMessageCorrectly() { @Test @DisplayName("유효하지 않은 데이터가 포함된 바이너리는 처리에 실패해야 한다") void shouldFailWhenDataIsInvalid() { - // given: 가격이 0 이하인 유효하지 않은 데이터 바이너리 준비 - byte[] rawData = TickRawBinaryCodec.encode("test", BigDecimal.ZERO, BigDecimal.ONE, 123L); + // given: 프로토콜 버전과 심볼만 존재하고 가격/수량/시간 필드가 누락된 바이너리 준비 + byte[] rawData = new byte[] { + TickRawBinaryCodec.PROTOCOL_VERSION, + 4, + 't', 'e', 's', 't' + }; Map payload = Map.of(RAW_PAYLOAD_FIELD, rawData); // when: 핸들러 실행 @@ -70,6 +75,7 @@ void shouldFailWhenDataIsInvalid() { // then: 실패 결과 반환 확인 assertThat(result).isFalse(); + verifyNoInteractions(tickProcessService); } @Test From f327867a9377163e404ac74e2e9554bb7dd8d3dc Mon Sep 17 00:00:00 2001 From: Orange flavored banana <106858113+moonwhistle@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:25:47 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/context/WebSocketContext.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/context/WebSocketContext.tsx b/frontend/src/context/WebSocketContext.tsx index 49e6706d..15359394 100644 --- a/frontend/src/context/WebSocketContext.tsx +++ b/frontend/src/context/WebSocketContext.tsx @@ -25,7 +25,12 @@ export const WebSocketProvider: React.FC = ({ url, child }, []); const connect = useCallback(() => { - if (ws.current?.readyState === WebSocket.OPEN) return; + if ( + ws.current?.readyState === WebSocket.OPEN || + ws.current?.readyState === WebSocket.CONNECTING + ) { + return; + } console.log('[WS Provider] Connecting to', url); const socket = new WebSocket(url); @@ -43,6 +48,8 @@ export const WebSocketProvider: React.FC = ({ url, child }; socket.onclose = () => { + if (ws.current !== socket) return; + console.log('[WS Provider] Disconnected'); setIsConnected(false); ws.current = null; From 9efc14ab8d9c8a0395dc8ff7acb604a60cb2c82c Mon Sep 17 00:00:00 2001 From: Orange flavored banana <106858113+moonwhistle@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:29:29 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=ED=98=B8=ED=99=98=EC=84=B1=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f735131..5f327ed2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2144,13 +2144,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { @@ -2209,9 +2212,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001802", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz", + "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==", "dev": true, "funding": [ { From d0fbc78f38c14197fb79f4e01b72df4d13d871ad Mon Sep 17 00:00:00 2001 From: Orange flavored banana <106858113+moonwhistle@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:46:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=EB=AC=B8=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++--- backend/coinflow-consumer-app/README.md | 57 +++++++++---------- docs/troubleshooting/volumescailing.md | 6 +- ...5\355\225\264\353\263\264\354\236\2202.md" | 8 +-- ...\354\225\274 \355\225\240\352\271\214?.md" | 12 ++-- .../decisions_table.md" | 2 +- frontend/README.md | 13 +++++ 7 files changed, 62 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index eaf72169..b2c50469 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ During this project, I wanted to build everything starting from raw tick data. ## 🛠️ Tech Stack ### Backend -* **Core**: Java 17, Spring Boot 3.2 +* **Core**: Java 17, Spring Boot 3.4.1 * **Messaging**: Redis (Stream, Pub/Sub) * **Database**: PostgreSQL (JPA/Hibernate) * **Build**: Gradle (Multi-module Architecture) @@ -59,7 +59,7 @@ To achieve both Extreme Real-time Responsiveness and Strong Consistency without - **Collector**: Pushes raw tick data to the Message Queue (Redis Stream) as fast as possible. - **Consumer (Single Aggregator)**: Consumes raw ticks and builds perfect OHLC candle in-memory. -- **View**: The WebSocket Gateway simply broadcasts the current tick and current ohlc candle(250ms delay) directly to the Dashboard. +- **View**: The WebSocket Gateway broadcasts ticker updates immediately and throttled OHLC candle snapshots directly to the Dashboard. - **Replay (Spring Batch)**: Periodically(5min) syncs with Binance API to fix data gaps and guarantee 100% accuracy. > This unified approach ensures strict data consistency between the server and the client without complex synchronization logic. @@ -74,11 +74,11 @@ To achieve both Extreme Real-time Responsiveness and Strong Consistency without ## 🧑‍💻 Getting Started ### Backend -1. Build JARs: `./gradlew build -x test` +1. Build JARs: `cd backend && ./gradlew build -x test` 2. Run with Docker Compose: `docker compose -f infra/docker/docker-compose-prod.yml up -d` ### Frontend -1. Install dependencies: `npm install` +1. Install dependencies: `cd frontend && npm install` 2. Run development server: `npm run dev` ## 🛠️ Technical Decisions & Troubleshooting @@ -121,7 +121,7 @@ To achieve 10,000 TPS on a low-spec T2.micro(512MB), eliminate the **JSON Overhe - **Problem**: Processing 10,000+ ticks/sec using JSON/Jackson causes massive object allocation (25MB/s), leading to GC pauses and latency spikes in a memory-constrained environment. -- **Solution**: eplaced JSON with a custom binary protocol and eliminated object creation by processing raw bytes directly. This reduced GC overhead and stabilized P99 latency under high load. +- **Solution**: Replaced JSON with a custom binary protocol and eliminated object creation by processing raw bytes directly. This reduced GC overhead and stabilized P99 latency under high load. - **Result**: Reduced memory allocation by 95%+, ensuring stable 10,000 TPS within a 512MB RAM limit. @@ -133,8 +133,8 @@ To eliminate Redis I/O bottlenecks, I introduced Batch ACK with idempotency stra - **Problem**: Per-message XACK at 10,000 TPS caused severe I/O bottlenecks. - **Solution**: - - Batch ACK: Processed 500 records or flushed every 50ms to reduce Redis calls. - - L1 (Caffeine Cache): Applied an in-memory LRU filter for RecordId-based deduplication. + - Batch ACK: `BatchAckWorker` processes 500 records or flushes every 50ms to reduce Redis calls. + - Idempotency: Applied a Caffeine-based RecordId filter to prevent duplicate aggregation during Redis Stream re-delivery. - **Result**: Reduced Redis I/O by over 95% while preserving full data integrity. diff --git a/backend/coinflow-consumer-app/README.md b/backend/coinflow-consumer-app/README.md index ef962134..7b297711 100644 --- a/backend/coinflow-consumer-app/README.md +++ b/backend/coinflow-consumer-app/README.md @@ -1,45 +1,44 @@ -# # CoinFlow Consumer 로직 분석 +# CoinFlow Consumer 로직 분석 ## 1. 로직 흐름 (Tick 데이터 수신부터 집계까지) -이 시스템은 스케줄링된 Flush 메커니즘을 가진 **스트림 기반 인메모리 집계(Stream-based In-Memory Aggregation)** 패턴을 따릅니다. +이 시스템은 Redis Stream 기반으로 바이너리 Tick 데이터를 소비하고, 서버 메모리에서 1m/5m/30m Kline을 집계한 뒤 Redis Pub/Sub, Redis Live Cache, 비동기 DB 저장, Batch ACK를 조율하는 구조입니다. ### A. 수신 단계 (Ingestion) 1. **Source**: Redis Stream (`tick:raw`) 2. **Consumer**: `TickRawEventConsumer` (`StreamListener` 구현체) -3. **Handler**: `TickRawMessageHandler`가 raw Map 데이터를 `TickRawEvent` 객체(심볼, 가격, 수량, 시간)로 변환 -4. **Service**: `TickProcessService`가 구체적인 집계 서비스(`Ohlc1mAggregationService`)로 처리를 위임 +3. **Handler**: `TickRawMessageHandler`가 `Map`에서 바이너리 payload를 꺼내 `TickRawBinaryCodec`으로 필드를 직접 추출 +4. **Service**: `TickProcessService`가 `KlineAggregatorService`에 집계를 위임하고 후속 전파/저장/ACK 흐름을 조율 ### B. 집계 단계 (Aggregation - In-Memory) -- **Store**: `Ohlc1mAggregationStore`가 `ConcurrentHashMap`를 관리 -- **Accumulator**: `OhlcAccumulator`가 메모리 상에서 OHLC 데이터를 갱신 +- **Store**: `KlineAggregatorService`가 `symbol:interval` 키 기준으로 `KlineState`를 관리 +- **Accumulator**: `KlineState`와 `MutableKlineSnapshot`이 메모리 상에서 OHLC 데이터를 갱신 - **로직**: - `Open`: 최초 수신 가격 - `High/Low`: 최대/최소 가격 비교 갱신 - `Close`: 가장 늦은 `eventTime`을 가진 가격으로 결정 - `Volume`: 누적 합계 - - **동시성**: `ConcurrentHashMap.compute()`를 사용하여 키(심볼+버킷) 단위로 락을 걸고 안전하게 처리 + - **동시성**: `ConcurrentHashMap` 기반 상태 저장소를 사용하고, 최근 마감 버킷은 `recentlyClosed` 버퍼에서 Late Tick 보정을 처리 -### C. 지속성 단계 (Persistence - Flush) -- **Scheduler**: `Ohlc1mFlushScheduler`가 **1000ms(1초)** 마다 실행 -- **Close Check**: `BucketCloseChecker`를 통해 메모리 상의 모든 키를 순회하며 버킷 시간 윈도우가 지났는지 확인 -- **Flush Action**: - 1. `Ohlc1mFlushService.flush()` 호출 - 2. `Ohlc1mService.applyAndSave()`를 통해 DB 저장 - 3. `Ohlc1mFlushedEvent` 발행 (후속 5m/30m 롤업 처리를 위함) - 4. 메모리에서 해당 키 제거 +### C. 지속성 및 ACK 단계 (Persistence & ACK) +- **Live Snapshot**: 진행 중인 Kline은 Redis Live Cache에 저장되고 Redis Pub/Sub으로 브로드캐스트됩니다. +- **Closed / Late Snapshot**: 마감 또는 Late Tick 보정 스냅샷은 `DbPersistService.persistClosedCandleAsync()`를 통해 비동기로 DB에 저장됩니다. +- **Batch ACK**: + 1. 저장 파이프라인이 완료되면 `BatchAckWorker.addAck(recordId)` 호출 + 2. `BatchAckWorker`가 500건 또는 50ms 기준으로 Redis Stream XACK를 묶어서 처리 + 3. RecordId 기반 Caffeine 캐시로 재전달 메시지의 중복 집계를 방지 ## 2. 패키지 및 의존성 분석 모듈 구조는 관심사를 잘 분리하고 있으나, Core 모듈과 다소 강한 결합이 있습니다. - **`com.coinflow.consumer`**: Redis 연결 관련 코드를 깔끔하게 분리 -- **`com.coinflow.aggregation`**: 핵심 비즈니스 로직 포함 (Accumulator, Store, Scheduler) +- **`com.coinflow.aggregation`**: Consumer 파이프라인 조율, Redis Pub/Sub 전파, 비동기 저장, Batch ACK 로직 포함 - **Dependencies**: - **`:coinflow-core`**: - - 도메인 엔티티(`Symbol`, `Ohlc`)와 서비스(`SymbolService`, `Ohlc1mService`)를 사용 + - 도메인 엔티티(`Symbol`, `Ohlc`)와 서비스(`SymbolService`, `KlineAggregatorService`, `Ohlc1mService`, `Ohlc5mService`, `Ohlc30mService`)를 사용 - **관찰**: Consumer 애플리케이션이 Core 도메인 서비스를 직접 의존합니다. 모듈러 모놀리스(Modular Monolith) 구조에서는 일반적이나, 트랜잭션/엔티티 구조에 직접 묶여 있습니다. - - **`:coinflow-common`**: DTO 및 Event 정의 사용 + - **`:coinflow-common`**: Event, Metric, Tick binary codec, validation 공통 코드 사용 ## 3. Core 모듈 통합 분석 (Persistence & Upsert) @@ -49,24 +48,24 @@ - **지연 이벤트 처리 (Late Event Handling)**: - Flush 이후에 도착한(지각한) 틱 데이터가 와도, DB에 있는 기존 봉(Candle)을 조회하여 값을 갱신(High/Low/Close/Volume)하므로 데이터 정합성이 깨지지 않습니다. - **동시성 고려사항**: - - JPA의 낙관적 락(`@Version`)이나 별도의 비관적 락이 보이지 않습니다. - - 만약 동일 심볼+버킷에 대해 여러 Consumer가 동시에 Flush를 시도할 경우, **Lost Update**나 **Unique Constraint Violation**이 발생할 수 있는 구조입니다. (현재는 Redis Stream Group으로 인해 단일 파이프라인 처리가 보장된다면 안전함) + - `AbstractOhlc`에 JPA 낙관적 락(`@Version`)이 적용되어 있습니다. + - 동일 심볼+버킷은 유니크 인덱스와 낙관적 락 기반으로 중복 저장 및 Lost Update 위험을 방어합니다. ## 4. 개선 제안 (Suggestions for Improvement) -### A. 스케줄러 루프의 확장성 -- **현재**: 스케줄러가 매초 **모든 활성 키**를 순회함 -- **위험**: 관리하는 심볼 수가 증가(예: 10,000개 이상)하면, 이 전체 순회 방식("Stop-the-world" 스타일)은 성능 저하를 일으킬 수 있음 -- **개선**: **DelayQueue**나 시간 윈도우 기반 파티셔닝 맵을 사용하여, 실제로 마감 시간이 된 버킷만 체크하도록 변경 추천 +### A. 인메모리 상태 확장성 +- **현재**: `KlineAggregatorService`가 지원 interval별 `KlineState`와 최근 마감 버퍼를 메모리에 유지함 +- **위험**: 심볼 수가 크게 증가하면 상태 맵과 최근 마감 버퍼의 메모리 사용량이 증가할 수 있음 +- **개선**: 심볼 확장 시 상태 보관 정책, 버퍼 TTL, interval별 eviction 정책을 별도로 검토 필요 ### B. Hot Symbol에 대한 경합 (Contention) -- **현재**: `store.compute()` 메서드는 해당 키에 대해 락을 걺 +- **현재**: 단일 hot symbol에 Tick이 집중되면 해당 symbol/interval의 `KlineState` 갱신이 집중됨 - **위험**: 틱 빈도가 매우 높은(예: 1000+ TPS) 심볼의 경우, 락 경합이 병목지점이 될 수 있음 - **개선**: - Volume 집계 등에 **Double Buffering** 또는 **LongAdder** 도입 - - 또는 내부적으로 Accumulator를 샤딩(Sharding)하여 경합 분산 + - 또는 내부적으로 Kline 상태를 샤딩(Sharding)하여 경합 분산 ### C. 메모리 안전성 -- **현재**: Flush 성공에 의존하여 메모리를 비움 -- **위험**: DB 장애 등의 이유로 Flush가 계속 실패하면, 메모리 맵이 무한정 커져 OOM(Out Of Memory) 발생 가능 -- **개선**: **Backpressure** 메커니즘 도입 또는 `max-size` 제한(오래된 데이터 디스크 스필 또는 소비 일시 정지) 구현 필요 +- **현재**: DB 저장 실패 시 재시도 후 Batch Reconciliation으로 복구하는 방향을 가짐 +- **위험**: 장시간 DB 장애가 발생하면 비동기 저장 큐와 Redis Stream PEL이 증가할 수 있음 +- **개선**: **Backpressure** 메커니즘 도입 또는 저장 큐/PEL 모니터링 기반 소비 일시 정지 정책 검토 필요 diff --git a/docs/troubleshooting/volumescailing.md b/docs/troubleshooting/volumescailing.md index c32d5634..9243922c 100644 --- a/docs/troubleshooting/volumescailing.md +++ b/docs/troubleshooting/volumescailing.md @@ -74,9 +74,9 @@ CoinFlow에서 가장 많이 수신되고, 가장 빈번하게 계산되는 데 * 알고리즘 문제를 풀 때 부동소수점 오차를 피하기 위해 특정 배수를 곱해 정수로 치환하여 계산하는 것과 동일한 원리입니다. * **구현 방법 (VolumeScaler 도입):** - * `coinflow-core` 모듈 내의 [`VolumeScaler`](../../../coinflow-core/src/main/java/com/coinflow/domain/ohlc/policy/VolumeScaler.java) 정책 클래스를 구현하여 이 과정을 캡슐화했습니다. + * `coinflow-core` 모듈 내의 [`VolumeScaler`](../../backend/coinflow-core/src/main/java/com/coinflow/domain/ohlc/policy/VolumeScaler.java) 정책 클래스를 구현하여 이 과정을 캡슐화했습니다. * **Delimiter:** 암호화폐 거래의 대다수는 소수점 8자리까지의 정밀도를 가집니다. 그래서 delimiter를 `10^8 (100,000,000)`로 정해줬습니다. - * **값이 들어올 때:** 외부 (업비트 등)에서 들어온 거래량 `0.12345678`에 `10^8`을 곱해 순수 정수인 `12345678L(long)`로 변환(`VolumeScaler.toLong`)하여 메모리(`OhlcAccumulator`)에 누적합니다. + * **값이 들어올 때:** 외부 거래소에서 들어온 거래량 `0.12345678`에 `10^8`을 곱해 순수 정수인 `12345678L(long)`로 변환(`VolumeScaler.toLong`)하여 메모리(`KlineState`, `MutableKlineSnapshot`)에 누적합니다. * **연산:** CPU가 가장 잘하고 빠른 기본 자료형인 `long`의 단순 `+` 연산만 수행하므로 GC 부하가 거의 없습니다.(`volume = Math.addExact(volume, vol);`) * **값을 내보낼 때:** 1분 봉이 마감되어 DB에 저장하거나 클라이언트(프론트엔드)로 응답을 내려줄 때만 다시 `10^8`로 나누어 `BigDecimal`로 복원합니다. @@ -108,7 +108,7 @@ CoinFlow에서 가장 많이 수신되고, 가장 빈번하게 계산되는 데 * **단순 `+` 연산의 위험성:** Java에서 두 `long` 값의 합이 범위를 넘어가면 시스템은 예외를 던지지 않고 오버플로우로 인해 음수로 바뀌어버립니다. 이는 금융/트레이딩 시스템에서 치명적인 문제라고 생각했습니다. * **`Math.addExact()` 적용:** ```java - // OhlcAccumulator.java 일부 + // KlineState.java / MutableKlineSnapshot.java 일부 public synchronized void apply(BigDecimal price, long vol, Instant eventTime) { // ... 가격, 시간 갱신 로직 생략 ... diff --git "a/docs/troubleshooting/\354\204\244\352\263\204\353\245\274 \353\263\200\352\262\275\355\225\264\353\263\264\354\236\2202.md" "b/docs/troubleshooting/\354\204\244\352\263\204\353\245\274 \353\263\200\352\262\275\355\225\264\353\263\264\354\236\2202.md" index a6863118..14f86cf7 100644 --- "a/docs/troubleshooting/\354\204\244\352\263\204\353\245\274 \353\263\200\352\262\275\355\225\264\353\263\264\354\236\2202.md" +++ "b/docs/troubleshooting/\354\204\244\352\263\204\353\245\274 \353\263\200\352\262\275\355\225\264\353\263\264\354\236\2202.md" @@ -48,9 +48,9 @@ Dual-Path 아키텍처는 실시간성 뿐만 아니라, Replay와 Snapshot이 결국 Dual-Path를 과감히 포기하고, Single Aggregator로 모든 흐름을 통합했습니다. 프론트엔드가 들고 있던 복잡한 캔들 집계 로직을 제거하고자 했습니다. 이 방법의 핵심은 **서버 메모리(In-Memory) 상에 단 하나 존재하는 중앙 집계소(Single Aggregator)**입니다. -백엔드 앱 하나가 거래소의 모든 틱 데이터를 수신하고, `OhlcAccumulator`라는 자신의 메모리 공간 위에서 `Math.addExact()`와 같은 초고속 연산을 통해 초당 수천 번 실시간 캔들을 집계합니다.(여기까지는 이전과 동일합니다.) +백엔드 Consumer가 거래소의 모든 틱 데이터를 수신하고, `KlineAggregatorService`와 `KlineState`의 메모리 상태 위에서 `Math.addExact()`와 같은 기본형 연산을 통해 초당 수천 번 실시간 캔들을 집계합니다. -그리고 250ms라는 극히 짧은 주기마다, 서버 메모리에서 실시간 캔들 데이터를 그대로 Redis에 덮어쓰고 웹소켓으로 브로드캐스트합니다. 프론트엔드는 이 데이터를 받는 순간, 서버가 이미 완벽하게 계산해둔 캔들스냅샷을 화면에 찍어주기만 하면 끝납니다. +그리고 실시간 캔들 스냅샷은 Redis 저장소에 갱신되고, Redis Pub/Sub을 거쳐 WebSocket Gateway가 브라우저로 전달합니다. 진행 중인 캔들은 과도한 렌더링과 네트워크 전송을 막기 위해 짧은 간격으로 throttling 됩니다. 프론트엔드는 이 데이터를 받는 순간, 서버가 이미 계산해둔 캔들 스냅샷을 화면에 반영하면 끝납니다. ## 완벽해진 데이터 흐름 새로운 구조는 데이터의 흐름이 놀랍도록 직관적이며 완벽한 정합성을 보장합니다. @@ -59,11 +59,11 @@ Dual-Path 아키텍처는 실시간성 뿐만 아니라, Replay와 Snapshot이 1. **Collector**: 외부 틱 데이터를 발생 즉시 Message Queue(Redis Stream)로 밀어 넣습니다. 2. **Consumer (Aggregator)**: Stream에서 틱을 수신하여 내부 메모리 위에서 실시간 OHLC 캔들을 지속적으로 집계합니다. (또한, 실시간 현재가 데이터를 별도로 바로바로 publish 합니다.) -3. **Database & Cache**: 백엔드는 250ms(0.25초)라는 극히 짧은 주기마다 메모리의 캔들 스냅샷을 Redis에 `OpsForValue().set()`으로 덮어쓰기(Cache) 함과 동시에, 웹소켓 Pub/Sub으로 브로드캐스트 합니다. (캔들이 완전히 마감되는 00초에는 DB에 영구 저장합니다.) +3. **Database & Cache**: Consumer는 진행 중인 캔들 스냅샷을 Redis에 저장하고 Redis Pub/Sub으로 브로드캐스트합니다. 캔들이 마감되거나 Late Tick으로 보정되면 비동기 DB 저장 파이프라인을 통해 PostgreSQL에 영구 저장합니다. 4. **View (Stateless)**: 웹소켓을 통해 넘어온 "서버가 이미 완벽하게 계산해둔 캔들스냅샷"을 프론트엔드는 그저 화면에 찍어주기만 하면 끝납니다. 더하기/빼기는 단 하나도 하지 않습니다. ## 결론 이제 코인플로우의 차트는 네트워크가 끊겼다 복구되더라도 아무런 로직이 필요 없습니다. -그저 서버가 250ms마다 무조건 올바른 캔들을 던져주기 때문에, 재연결되는 즉시 다음 스냅샷으로 덮어써지며 서버의 상태와 100% 일치하도록 **자동으로 동기화** 됩니다. +재연결되는 즉시 서버가 계산한 다음 스냅샷으로 덮어써지며 서버의 상태와 동일한 방향으로 **자동으로 동기화** 됩니다. 개발 복잡성(프론트 로직)은 절반으로 줄이고, 정합성은 100%로 끌어올린 아주 만족스러운 설계 변경이었습니다. 코드의 단순함은 설계에서 나온다는 것을 다시금 느끼며 설계의 중요성을 깨닫게 되었습니다. diff --git "a/docs/troubleshooting/\354\213\244\354\213\234\352\260\204\354\204\261\354\235\204 \354\234\204\355\225\264 \354\272\224\353\223\244\354\235\204 \354\226\264\353\226\273\352\262\214 \354\240\200\354\236\245\355\225\264\354\225\274 \355\225\240\352\271\214?.md" "b/docs/troubleshooting/\354\213\244\354\213\234\352\260\204\354\204\261\354\235\204 \354\234\204\355\225\264 \354\272\224\353\223\244\354\235\204 \354\226\264\353\226\273\352\262\214 \354\240\200\354\236\245\355\225\264\354\225\274 \355\225\240\352\271\214?.md" index 15819b2e..983f01a6 100644 --- "a/docs/troubleshooting/\354\213\244\354\213\234\352\260\204\354\204\261\354\235\204 \354\234\204\355\225\264 \354\272\224\353\223\244\354\235\204 \354\226\264\353\226\273\352\262\214 \354\240\200\354\236\245\355\225\264\354\225\274 \355\225\240\352\271\214?.md" +++ "b/docs/troubleshooting/\354\213\244\354\213\234\352\260\204\354\204\261\354\235\204 \354\234\204\355\225\264 \354\272\224\353\223\244\354\235\204 \354\226\264\353\226\273\352\262\214 \354\240\200\354\236\245\355\225\264\354\225\274 \355\225\240\352\271\214?.md" @@ -55,9 +55,9 @@ 1. Consumer Thread가 MessageQueue에서 데이터를 꺼내와서 1m/5m/30m 캔들을 집계합니다. 2. 집계된 캔들을 DB 에 저장할 때 **DB 전용 스레드에 비동기로 저장을 요청**합니다. 3. Consumer Thread 는 DB 전용 스레드의 응답을 기다리지 않고 **즉시 다음 작업을 수행(다음 틱 읽기)**합니다. -4. DB 전용 스레드는 데이터 저장을 수행하며, **모든 인터벌(1m/5m/30m)의 저장이 성공적으로 완료된 시점에 Redis에 확인 응답(XACK)을 전송**합니다. +4. DB 전용 스레드는 데이터 저장을 수행하며, 저장 파이프라인이 완료된 뒤 `BatchAckWorker`에 ACK 대상 `RecordId`를 넘깁니다. -이렇듯, 비동기 방식으로 변경함으로써 Consumer Thread의 차단을 막아 **실시간성을 확보**함과 동시에, 실제 ACK는 저장이 끝난 뒤에 날림으로써 **데이터 유실(At-least-once 보장)까지 잡은 완벽한 구조**를 완성했습니다. +이렇듯, 비동기 방식으로 변경함으로써 Consumer Thread의 차단을 막아 **실시간성을 확보**함과 동시에, 실제 XACK는 저장 완료 이후 Batch ACK로 처리하여 **데이터 유실 방지(At-least-once 기반 처리)**와 Redis I/O 절감을 함께 가져가는 구조를 완성했습니다. 비동기 방식의 전체적인 흐름은 여기까지이고, 이제부터는 스레드 풀을 어떻게 설정했는지, 비동기 저장이 실패했을 때 어떻게 처리했는지에 대해 설명드리겠습니다. @@ -97,8 +97,8 @@ public Executor dbPersistExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(properties.corePoolSize()); // 기본 유지 스레드 수 (3) - executor.setMaxPoolSize(properties.maxPoolSize()); // 최대 스레드 수 (5) + executor.setCorePoolSize(properties.corePoolSize()); // 기본 유지 스레드 수 (현재 설정: 10) + executor.setMaxPoolSize(properties.maxPoolSize()); // 최대 스레드 수 (현재 설정: 10) executor.setQueueCapacity(properties.queueCapacity()); // 대기 큐 크기 (500) executor.setKeepAliveSeconds(properties.keepAliveSeconds()); // 유휴 스레드 대기 시간 executor.setAllowCoreThreadTimeOut(true); // Core 스레드도 유휴 시 반납 (자원 효율화) @@ -229,7 +229,7 @@ Thread.sleep(50) 즉 50ms 의 delay 를 통해 실제 DB 환경의 지연(최악 -그리고 결과적으로 5개의 스레드를 비동기 스레드로 할당해줬습니다! +이후 현재 설정에서는 DB 저장 파이프라인의 처리량을 더 확보하기 위해 `core-pool-size`와 `max-pool-size`를 각각 10으로 조정했습니다. @@ -390,4 +390,4 @@ CONSTRAINT uk_ohlc_1m_symbol_bucket UNIQUE (symbol_id, bucket_time) -1. 이제 같은 row 에 한해서, insert \ No newline at end of file +1. 이제 같은 row 에 한해서, insert diff --git "a/docs/\352\263\240\353\240\244\354\202\254\355\225\255/decisions_table.md" "b/docs/\352\263\240\353\240\244\354\202\254\355\225\255/decisions_table.md" index 8f986b30..1a0a8e85 100644 --- "a/docs/\352\263\240\353\240\244\354\202\254\355\225\255/decisions_table.md" +++ "b/docs/\352\263\240\353\240\244\354\202\254\355\225\255/decisions_table.md" @@ -1,6 +1,6 @@ # ID 생성 전략 및 시간 모델 설계 결정 -본 문서는 주식 데이터 파이프라인 MVP 구축 과정에서 선택한 **ID 생성 전략**과 **시간 모델**에 대한 설계 의도와 근거를 기록한다. +본 문서는 암호화폐 실시간 차트 데이터 파이프라인 MVP 구축 과정에서 선택한 **ID 생성 전략**과 **시간 모델**에 대한 설계 의도와 근거를 기록한다. 단기적인 구현 단순성과 장기적인 확장 가능성 사이의 트레이드오프를 명확히 하기 위함이다. --- diff --git a/frontend/README.md b/frontend/README.md index d2e77611..94bb84a7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,18 @@ # React + TypeScript + Vite +CoinFlow frontend is a React + TypeScript + Vite client for the real-time Bitcoin chart. + +## CoinFlow Scripts + +```bash +npm install +npm run dev +npm run lint +npm run build +``` + +Local Vite proxy routes `/api` to `http://localhost:8080` and `/ws` to `ws://localhost:8081`. + This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Currently, two official plugins are available: From aec9dd742705dbcf4164c330a96503010e020fa8 Mon Sep 17 00:00:00 2001 From: Orange flavored banana <106858113+moonwhistle@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:25:29 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20CI=EC=99=80=20CD=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend-cd.yml | 67 ++++++++++++++++++++++++++ .github/workflows/backend-ci-cd.yml | 74 ----------------------------- .github/workflows/ci.yml | 69 +++++++++++++++++++++++++++ README.md | 2 +- 4 files changed, 137 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/backend-cd.yml delete mode 100644 .github/workflows/backend-ci-cd.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/backend-cd.yml b/.github/workflows/backend-cd.yml new file mode 100644 index 00000000..60e69ab7 --- /dev/null +++ b/.github/workflows/backend-cd.yml @@ -0,0 +1,67 @@ +name: Backend CD + +on: + workflow_run: + workflows: [ "CI" ] + branches: [ "master" ] + types: [ "completed" ] + +jobs: + build-and-deploy: + name: Build Images And Deploy + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + cache: gradle + + - name: Build backend JARs + run: | + cd backend + chmod +x ./gradlew + ./gradlew build + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker images + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + cd infra/docker + docker compose -f docker-compose-prod.yml build + docker compose -f docker-compose-prod.yml push + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + envs: DOCKERHUB_USERNAME + script: | + cd ~/CoinFlow + git pull origin master + + export DOCKERHUB_USERNAME=$DOCKERHUB_USERNAME + + cd infra/docker + docker compose -f docker-compose-prod.yml pull + docker compose -f docker-compose-prod.yml up -d + docker compose -f docker-compose-prod.yml restart nginx + docker image prune -f diff --git a/.github/workflows/backend-ci-cd.yml b/.github/workflows/backend-ci-cd.yml deleted file mode 100644 index 82002bcc..00000000 --- a/.github/workflows/backend-ci-cd.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Backend CI/CD Pipeline - -on: - push: - branches: [ "master" ] - paths: - - 'backend/**' - - 'infra/docker/**' - - '.github/workflows/**' - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'corretto' - cache: gradle - - - name: Build with Gradle - run: | - cd backend - chmod +x ./gradlew - # 테스트를 포함해 빌드 (실패 시 배포 중단) // 에러 발생 시 -x test 로 우회 가능 - ./gradlew build -x test - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and Push Docker Images - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - run: | - cd infra/docker - docker compose -f docker-compose-prod.yml build - docker compose -f docker-compose-prod.yml push - - - name: Deploy to EC2 - uses: appleboy/ssh-action@v1.0.3 - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME - script: | - # 1. 최신 코드 풀 가져오기 - cd ~/CoinFlow - git pull origin master - - # 2. .env 와 같이 환경변수로 Dockerhub 계정명 넘겨주기 - export DOCKERHUB_USERNAME=$DOCKERHUB_USERNAME - - # 3. 최신 이미지 바탕으로 컨테이너 다시 올리기 - # (변경사항이 있는 컨테이너만 영리하게 재시작됨) - cd infra/docker - docker compose -f docker-compose-prod.yml pull - docker compose -f docker-compose-prod.yml up -d - - # 4. Nginx 재시작 (업스트림 컨테이너들의 변경된 내부 IP를 새로 인식시키기 위함) - docker compose -f docker-compose-prod.yml restart nginx - - # 5. 이전 배포에서 남은 미사용 Docker 이미지 정리 (디스크 풀 방지) - docker image prune -f diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5609d9af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + branches: [ "master" ] + paths: + - 'backend/**' + - 'frontend/**' + - 'infra/docker/**' + - '.github/workflows/**' + push: + branches: [ "master" ] + paths: + - 'backend/**' + - 'frontend/**' + - 'infra/docker/**' + - '.github/workflows/**' + +jobs: + backend-test: + name: Backend Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + cache: gradle + + - name: Run backend tests + run: | + cd backend + chmod +x ./gradlew + ./gradlew test --continue + + frontend-check: + name: Frontend Lint And Build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: | + cd frontend + npm ci + + - name: Run frontend lint + run: | + cd frontend + npm run lint + + - name: Build frontend + run: | + cd frontend + npm run build diff --git a/README.md b/README.md index b2c50469..f094d84e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ During this project, I wanted to build everything starting from raw tick data. ![Architecture](/image/architect2.png) -(In the current deployment, ElastiCache and RDS run as Docker containers on the same EC2 to minimize cost...) +(In the current deployment, Redis and PostgreSQL run as Docker containers on the same EC2 to minimize cost...) > **Why Single Server?** Multi-instance deployments with ALB are unnecessary at this scale(including cost problems). A single EC2 with Nginx achieves the same routing and SSL at zero cost — while remaining ready to scale out when needed. From 7294b7bcdc183fed5e73b32569b2ee58c690db77 Mon Sep 17 00:00:00 2001 From: Orange flavored banana <106858113+moonwhistle@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:55:59 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=A4=EB=B0=8B=20=EC=A0=95=ED=95=A9=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend-cd.yml | 7 +++++-- .github/workflows/ci.yml | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-cd.yml b/.github/workflows/backend-cd.yml index 60e69ab7..2716ae81 100644 --- a/.github/workflows/backend-cd.yml +++ b/.github/workflows/backend-cd.yml @@ -17,6 +17,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -49,14 +50,16 @@ jobs: uses: appleboy/ssh-action@v1.0.3 env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }} with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME + envs: DOCKERHUB_USERNAME,DEPLOY_SHA script: | cd ~/CoinFlow - git pull origin master + git fetch origin master + git checkout "$DEPLOY_SHA" export DOCKERHUB_USERNAME=$DOCKERHUB_USERNAME diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5609d9af..22774714 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -45,6 +47,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v4