이 글을 3월 9일에 쓰기 시작했는데 현생이 바빠서 6월에 완성..ㅎ..
~블록체인 플랫폼 실시간 ohlcv 차트 개발기 시리즈~
[1] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 1. 문제편
[2] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 2. 해결편
[3] 블록체인 플랫폼 실시간 ohlcv 차트 적재 서버 후속편:Refactoring (1)
[4] 블록체인 플랫폼 실시간 ohlcv 차트 적재 서버 후속편: Refactoring (2)
[5] 블록체인 플랫폼에서 실시간 ohlcv 차트 적재 서버 후속편: RestructuringRestructuring
🟧 얼기설기 끼워 맞추기
QA 테스트 기간 전까지 남은 개발 기간은 단 2-3일 밖에 없었다. 그래서 나는 시니어 개발자분의 원래 설계를 그대로 따르되 예외 처리와 추가 개발을 통해 현재 차트 오류를 해결하고자 했다.
🔹 첫 번째 시도: 동시성 제어를 위한 Race Condition 해결
우선적으로 잡아야한다고 생각한 부분은 base chart generator와 event handler 간의 race condition이었다. base chart가 먼저 기본 차트 틀을 만들기 전에 event handler가 데이터를 업데이트하고, 그 이후에 base chart가 또 데이터를 업데이트하니, 차트 데이터가 엉망이 되었던 것이다.
가장 단순하게 생각할 수 있는 해결책은 sync.RWMutex 즉, lock을 통한 제어였다.
base chart가 먼저 차트 생성을 완료하기 까지 event handler는 기다리도록 lock을 추가했다.
type ChartManager struct {
// ... 기존 필드들 ...
mu sync.RWMutex // 차트 데이터 생성 및 업데이트 시 사용할 뮤텍스
}
// Base Chart 생성 시 Lock
func (cm *ChartManager) setBaseChart(chartType ChartType) error {
cm.mu.Lock()
defer cm.mu.Unlock()
...
}
// Event Handler에서 차트 업데이트 시 Lock 확인
func (cm *ChartManager) MakeUpdateChartModel() (*mongo.UpdateOneModel, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
// ... 기존 업데이트 모델 생성 로직 ...
}
하지만 이 방법으로는 정확하게 순서를 원하는대로 제어할 수 없었다.
1. base chart가 먼저 lock을 잡을지 확신할 수 없을 뿐더러,
2. 만약 base chart가 먼저 잡더라도 실시간으로 쏟아지는 블록체인 이벤트들을 처리하는 event handler 의 성능이 저하될 우려가 있었다. lock을 기다리느라 시간이 허비가 되기 때문이다.
실시간성이 생명인 시스템에서 이는 치명적인 결함이었다.
🟧 결국 재설계

애초에 처음 설계로는 영원히 문제를 해결할 수 없다고 판단하여 시간이 촉박하지만 그냥 설계를 아예 갈아엎기로 결정했다. 전체 서버 코드는 아니더라도 차트 파트 만이라도 재설계가 필요했다. 노트에 여러 생각들을 정리하고 다듬으며 설계 방안을 고안했고 다른 팀원들과 의논을 하며 새 설계를 다듬어 나갔다. 주말에도 출근할 각오를 하면서(그치만 최대한 평일에 끝내고 싶었다.) 브랜치를 새로 파서 코드작업을 시작했고 다행히 작업은 주말 전에 끝이 났다.
🔹 새로운 설계: Snap shot 방식
먼저 현재 프로젝트 설계에 얽매이지 않고자 차트의 본질(?)에 대해 생각을 했다. 캔들이 어떻게 생성되는지를 떠올렸더니 답은 의외로 쉽게 나왔다.

캔들이라 함은 그냥 해당 시간이 됐을 때, 시간 간격에 따라 계속 새로 만들어지는 것이다. 가격은 연속적으로 계속 흘러가게 되어 있고, 그 시간 단위를 억지로 나누어서 그림으로 그리는게 차트라는 생각이 들었다.
그러면 똑같이 서버에서도 가격은 알아서 계속 업데이트가 이뤄지고, 해당 시간 간격마다 캡쳐(!)를 해서 그때마다 DB에 쌓으면 되지 않을까? 생각이 들었다.
(그 시간의 상태를 저장하는 것이기에 snap shot라는 이름이 떠올랐는데, 실제로 이러한 설계를 snap shot이라고 하는지는 모르겠다. 다른 서비스에서는 차트를 어떻게 생성하는지 공부를 따로 해보려고 한다.)
이러한 이유로 base chart 로직은 아예 삭제가 되고 새로운 cron job 이 추가되었다.
type ChartManager struct {
cron map[ChartType]*cron.Cron
ohlcv1MCache map[string]*Ohlcv // address -> chart
ohlcv5MCache map[string]*Ohlcv // address -> chart
...
}
func NewChartManager(mongoDB *mongoDB.MongoDB) *ChartManager {
c := &ChartManager{...}
for _, chartType := range []ChartType{Chart1M, Chart5M} {
c.addChartSnapshotCronJob(chartType)
}
return c
}
func (cm *ChartManager) addChartSnapshotCronJob(chartType ChartType) {
cronJobFunc := func() {
// ... startTime 계산 ... //
// 1. 새 캔들을 위한 준비: volume을 0으로 리셋
cm.setNewCandleOhlcvCache(chartType)
// 2. 새 캔들을 만들 시간이 되면 캐시된 차트 데이터를 DB에 스냅샷으로 저장
currentOhlcv := cm.getOhlcvCache(chartType)
currentChart := &Chart{
Start: startTime,
End: startTime + chartType.GetRange(),
Ohlcv: currentOhlcv,
TxHashes: make([]common.Hash, 0),
}
// 3. 새 차트 데이터를 DB에 저장
if err := cm.StoreNewChart(chartType, currentChart); err != nil {
// error handling
}
}
var pattern string
if chartType == Chart1M {
pattern = "* * * * *" // 매 분 :00초에 실행
} else {
pattern = "*/5 * * * *" // 5분마다 :00초에 실행
}
if _, err := w.cron[chartType].AddFunc(pattern, cronJobFunc); err != nil {
// error handling
}
}
// 각 interval 별로 현재 ohlcv 값을 캡쳐해서 가져오기
func (cm *ChartManager) getOhlcvCache(chartType ChartType) map[string]*Ohlcv {
w.chartCacheLock[chartType].RLock()
defer w.chartCacheLock[chartType].RUnlock()
if chartType == Chart1M {
return w.ohlcv1MCache
}
return w.ohlcv5MCache
}
차트 데이터는 map으로 관리가 되는데, 지금 생각해보면 굳이 sync.RWMutex을 사용할 필요가 없도록 sync.Map을 사용하는게 나을 것 같다.
아무튼 서버는 map 타입으로 현재 차트 데이터를 늘 가지고 있다.
그리고 차트 데이터는 buy, sell 이벤트가 일어났을 때만 변경하게 되는데 그 코드는 아래와 같이 구현했다.
func (cm *ChartManager) UpdateChartModel(
l types.Log, eventData any, eventTime int64, chartType ChartType, tradingType TradingType,
) error {
eventTimestamp := (eventTime / chartType.GetInterval()) * chartType.GetInterval()
// handling buy sell event
var price decimal.Decimal
var volume decimal.Decimal
var token string
switch tradingType {
case BUY:
data := eventData.(TokenBoughtEvent)
price = data.PricePoint
volume = data.ToUser
token = hexutil.Encode(data.Token[:])
case SELL:
data := eventData.(TokenSoldEvent)
price = data.PricePoint
volume = data.TokensIn
token = hexutil.Encode(data.Token[:])
default:
return fmt.Errorf(NotSupportedTradingType, tradingType)
}
currentOhlcv := &Ohlcv{
Open: price,
High: price,
Low: price,
Close: price,
Volume: volume,
LastUpdateBlock: int64(l.BlockNumber),
LastUpdatedTxIndex: int64(l.TxIndex),
}
// cache update
cm.upsertOhlcvCacheByToken(chartType, token, currentOhlcv)
//.. db update ...//
return nil
}
func (cm *ChartManager) upsertOhlcvCacheByToken(chartType ChartType, token string, ohlcv *Ohlcv) {
beforeOhlcv := cm.getOhlcvCache(chartType)[token]
cm.chartCacheLock[chartType].Lock()
defer cm.chartCacheLock[chartType].Unlock()
if beforeOhlcv == nil {
// - insert chart cache
if chartType == Chart1M {
cm.ohlcv1MCache[token] = ohlcv
} else {
cm.ohlcv5MCache[token] = ohlcv
}
} else {
// - update chart cache
// volume update
beforeOhlcv.Volume = beforeOhlcv.Volume.Add(ohlcv.Volume)
// high update
if beforeOhlcv.High.Cmp(ohlcv.High) < 0 {
beforeOhlcv.High = ohlcv.High
}
// low update
if beforeOhlcv.Low.Cmp(ohlcv.Low) > 0 {
beforeOhlcv.Low = ohlcv.Low
}
// close update
if beforeOhlcv.LastUpdateBlock < int64(ohlcv.LastUpdateBlock) {
beforeOhlcv.LastUpdateBlock = int64(ohlcv.LastUpdateBlock)
beforeOhlcv.LastUpdatedTxIndex = int64(ohlcv.LastUpdatedTxIndex)
beforeOhlcv.Close = ohlcv.Close
} else if beforeOhlcv.LastUpdateBlock == int64(ohlcv.LastUpdateBlock) {
if beforeOhlcv.LastUpdatedTxIndex < int64(ohlcv.LastUpdatedTxIndex) {
beforeOhlcv.LastUpdatedTxIndex = int64(ohlcv.LastUpdatedTxIndex)
beforeOhlcv.Close = ohlcv.Close
}
}
// cache update
if chartType == Chart1M {
cmw.ohlcv1MCache[token] = beforeOhlcv
} else {
cm.ohlcv5MCache[token] = beforeOhlcv
}
}
}
차트 데이터 생성 및 업데이트 로직은 아래와 같이 동작한다.
1. 이벤트의 타임 스탬프를 기준으로 해당 차트 간격의 start time 계산
2. startTime과 토큰 주소, 차트 타입을 기준으로 캐시 및 DB에서 차트 데이터 조회
3. 차트 데이터가 없다면?
- 이전 시간 간격의 차트 데이터를 조회하여 Close 가격을 가져옴
- 새로운 차트 객체를 생성하고, 이전 Close 가격을 현재 Open 가격으로, 그리고 현재 가격을 high, low, close로, 거래량인 volume은 0으로 초기화
4. 차트 데이터가 이미 있다면?
- 현재 이벤트의 가격과 기존 high, low 가격을 비교하여 업데이트
- close를 최신 가격으로 갱신
- volume은 누적
5. 처리된 트랜잭션 해시, 블록 번호, 트랜잭션 인덱스 등을 기록하여 멱등성 보장 (중복 이벤트 처리 방지)
6. 업데이트된 차트 데이터를 캐시에 반영하고, 주기적으로 DB에 upsert
🔹 추가 기능: 서버 재기동 대비 복구 로직
서버가 예기치 않게 종료가 될 경우, 혹은 네트워크 문제로 인해 tx 이벤트가 정상적으로 처리가 되지 않았을 때 등을 대비하여 재기동 및 재적재 로직이 필요했다.
따라서 아래와 같은 프로세스의 복구 로직을 추가했다.
func (cm *ChartManager) RestackHistoricalCharts(chartType ChartType, eventTimestamp int64) error {
defer func() {
// 현재 재적재 프로세스가 진행 중인지 여부를 나타냄
w.SetIsRestacking(chartType, false)
}()
start := (eventTimestamp / chartType.GetInterval()) * chartType.GetInterval()
end := start + chartType.GetRange()
// 차트 재적재용 캐시
historicalChartCache := make(map[string]*Ohlcv)
// 이전 차트 데이터 가져오기
previousChart, err := cm.mongoDB.GetLatestChart(start, chartType)
if err != nil {
if !errors.Is(err, mongo.ErrNoDocuments) {
return err
}
// 이전 차트가 없을 경우는, 현재 시간의 차트부터 시작해서 쌓는 경우
} else {
for token, ohlcv := range previousChart.Ohlcv {
historicalChartCache[token] = ohlcv
}
}
Batch:
now := time.Now().Unix()
curTimestamp := (now / chartType.GetInterval()) * chartType.GetInterval()
if end < curTimestamp {
// 새 캔들을 위한 OHLCV 초기화
for token := range historicalChartCache {
historicalChartCache[token].Volume = decimal.Zero
historicalChartCache[token].Open = historicalChartCache[token].Close
historicalChartCache[token].High = historicalChartCache[token].Close
historicalChartCache[token].Low = historicalChartCache[token].Close
}
// 해당 시간 구간의 차트 데이터 조회
chart, err := cm.mongoDB.GetChartFromOrganizer(start, end, chartType)
if err != nil {
return err
}
// 캐시 업데이트 및 OHLCV 계산
if len(chart.Ohlcv) != 0 {
for token, ohlcv := range chart.Ohlcv {
cm.updateOhlcvCache(historicalChartCache, token, ohlcv)
}
}
// DB에 업데이트된 차트 저장
chart = &Chart{
Start: start,
End: end,
Ohlcv: historicalChartCache,
TxHashes: chart.TxHashes,
}
if err := cm.mongoDB.StoreNewChart(chartType, chart); err != nil {
// print log
} else {
start = start + chartType.GetInterval()
end = start + chartType.GetRange()
}
time.Sleep(1e6) // 1ms 대기
goto Batch
}
return nil
}
1. config에서 재적재 하고 싶은 block number를 받는다.
1-2. 만약, block number가 0 이라면(default 값) DB에 저장된 가장 최근 차트 데이터의 block number를 읽어온다.
2. 해당 블록 번호를 기준으로, 다시 해당 블록의 로그를 읽어오도록 한다.
[참고] 각 루프 때마다 1ms 만큼 대기하는 이유
: 루프 작업에서 시스템 안정성을 위한 최적화 코드다!
1. CPU 독점 방지 (Go 스케줄러에게 실행 기회 양보)
특히 수백~수천 개의 시간 구간을 연속적으로 처리하는 과거 데이터 재적재 작업의 경우, 1ms 딜레이조차 없다면 해당 작업이 CPU를 100% 점유할 가능성이 있다. 1ms sleep 동안의 텀으로 인해 다른 고루틴들이 실행될 기회가 주어진다.
3. Mongo DB 부하 분산 및 커넥션 풀 보호
연속적인 DB write 작업에서도 1ms 휴식이 필요하다. 너무 빠르게 write 요청을 보내면 DB 커넥션 풀이 고갈되거나, write 성능에 부담을 줄 수 있다. (write 내부 lock 매커니즘의 race condition, I/O 버퍼링 효율성 등)
그리고 재적재 로직은 처음 시작할 때 단 한번만 작동하도록 해야했다. 그래서 sync.Once과 atomic.Bool를 이용한 플래그로 이를 제어했다.
sync.One
특정 함수가 프로그램 실행 중 단 한번만 실행되도록 보장하는 Go의 동기화 프리미티브
atomic.Bool
멀티 고루틴 환경에서 bool 값을 안전하게 읽고 쓸 수 있도록 함. Go 1.19 버전부터 추가된 타입.
기존 sync/atomic 패키지의 복잡한 포인터 연산 없이도 원자적 연산이 가능
type ChartManager struct {
// ... 기존 필드들 ...
HistoricalChartTrigger sync.Once
isRestacking1M atomic.Bool
isRestacking5M atomic.Bool
HistoricalTimestamp map[ChartType]int64
}
func (w *BuyAndSellHandler) HandleTokenBoughtOrSoldEvent(...) {
// 차트 재적재는 딱 한 번만 실행
w.chartManager.HistoricalChartTrigger.Do(func() {
w.chartManager.SetIsRestacking(Chart1M, true)
w.chartManager.SetIsRestacking(Chart5M, true)
go w.chartManager.RestackHistoricalCharts(Chart1M, timestamp)
go w.chartManager.RestackHistoricalCharts(Chart5M, timestamp)
})
// 재적재 중이 아닐 때만 실시간 차트 업데이트
if !w.chartManager.IsRestacking(Chart1M) && timestamp >= w.chartManager.HistoricalTimestamp[Chart1M] {
if err = w.chartManager.UpdateChartModel(l, eventData, timestamp, Chart1M, tradingType); err != nil {
return err
}
}
}
재적재 프로세스 때문에 현재 이벤트를 놓치면 안되므로 재적재 프로세스는 따로 goroutine으로 돌리고, 현재 블록은 계속해서 따라갈 수 있게끔 했다. 재적재 프로세스가 종료되는 시점은 현재 시간보다 앞선 시간을 쌓으려고 했을 때 멈춘다. 일단 서비스 운영을 위해 현재 블록을 쌓긴하지만 재적재 프로세스가 처음부터 현재까지 쭉 한번 더 검토를 하며 다시 차트를 쌓는 셈이다.
🔹 새로운 고민: 이전 봉의 close 가격와 다음 봉의 open 가격의 불일치
차트는 연속적이기 때문에 이전 봉의 종가(close price)가 다음 봉의 시가(start price)로 이어져야 한다. 그러나 스냅샷 방식으로 작동하다 보니, DB에 데이터를 만들고 저장하는 그 약간의 시간 텀에서 현재 가격 데이터가 바뀔수도 있었다(!). 이 현상은 QA환경에서 테스트를 할 때 발견했다.
그러니까 이런 경우이다.
인터벌이 1시간인 캔들을 만든다고 생각해보자. 이제 10시가되어서 9시 봉은 마감을 하고 새로운 10시 봉을 만들었다. 9시봉의 종가를 10시봉의 시가로 해야하는데 10시 봉을 아직 캡쳐하기 전 짧은 시간에 이벤트가 들어와서 가격이 달라진 것이다! 이 상태로 10시봉을 만든다면 9시봉의 종가와는 다른 가격이 10시봉의 시가가 되는 현상이 발생한 것이다.
현재는 cron job으로 매 차트 간격이 시작되는 시점에 스냅샷 형식으로 현재 시간의 차트 데이터를 쌓아주고 있다.
이 cron job에 로직을 추가해서 이전 시가(previous)를 그 전 봉의 종가(second previous)로 무조건 맞춰주는 로직을 추가했다.
새로운 차트 데이터를 쌓기 전이니까 그 사이에 들어온 데이터는 다음 봉의 시가로 적용하면 안되기 때문이다. 시가는 그 이전봉의 종가로 반영하고, 그 사이에 들어온 데이터는 시가 적용 그 이후에 처리를 시켜야 한다고 생각했다.
코드는 아래와 같다.
func (cm *ChartManager) addChartSnapshotCronJob(chartType ChartType) {
cronJobFunc := func() {
// ... 1~3 단계, 위 코드 참고 ... //
// 4. correcting previous chart data
w.correctPreviousChart(chartType, startTime)
}
// ... //
}
func (cm *ChartManager) correctPreviousChart(chartType ChartType, startTime int64) {
previousStart := startTime - chartType.GetInterval()
secondPreviousChart, err := cm.mongoDB.GetLatestChart(previousStart, chartType)
if err != nil {
return
}
previousChart, err := cm.mongoDB.GetChartFromOrganizer(previousStart, startTime, chartType)
if err != nil {
return
}
if len(previousChart.Ohlcv) != 0 {
// update ohlcv
for token, ohlcv := range previousChart.Ohlcv {
if secondPreviousChart.Ohlcv == nil || secondPreviousChart.Ohlcv[token] == nil {
if err := cm.mongoDB.UpdateChart(chartType, previousStart, token, ohlcv, common.Hash{}, false); err != nil {
continue
}
} else {
ohlcv.Open = secondPreviousChart.Ohlcv[token].Close
// true: also update open price
if err := cm.mongoDB.UpdateChart(chartType, previousStart, token, ohlcv, common.Hash{}, true); err != nil {
continue
}
}
}
} else {
// print log
}
}
우선 코렉팅 로직은 다른 snap shot cron job 이후, 바로 실행되도록 했다.
이전 봉 마감 ~ 현재봉 생성 사이에 캐치하지 못한 짧은 시간에 발생한 이벤트들 때문에 가격 불일치가 발생하는 것이고, 이걸 바로잡으려고 하는 로직은 그 다음 봉이 생성될 때 코렉팅되는거다 보니 현재봉 생성 이후에 바로 실행해도 된다고 판단했다.
또한, 현재는 무조건 이전 시가를 그 전 종가로 맞춰주는 로직이다 보니, cron job이 별도의 goroutine에서 실행 되더라도, 현재 event 처리로 인해 데이터가 업데이트 되는건 현재 봉이므로 데이터 정합성 문제가 벌어지지 않을 거라고 생각하여 이렇게 코드를 구현했다.
코드에서 robfig/cron/v3 라이브러리를 사용하고 있는데, cron job에 대해 아래와 같이 설명하고 있다.
> "Funcs are invoked in their own goroutine, asynchronously."
> "Callers may register Funcs to be invoked on a given schedule. Cron will run them in their own goroutines."
좀 실시간성을 고려한다면 시가-종가가 어긋난 그 시간의 근처에서 비동기적으로 코렉팅을 해주는게 좋겠다. 어떻게 데이터 정합성을 해치지 않고 구현할 수 있을지는 더 고민을 해봐야할 것 같다..
🟧 개선된 점
🔹 1. 기존 Race Condition 해결
기존 설계의 근본적인 문제:
Base Chart Generator → 동시에 같은 차트 객체 수정 ← Event Handler
↘ ↙
Chart 데이터 (race condition!)
- Base Chart Generator와 Event Handler가 같은 차트 데이터를 동시에 수정
- Base Chart가 먼저 실행되어야만 Event Handler가 올바르게 동작하는 순서 의존성
- 아무리 뮤텍스를 걸어도 이 구조적 문제는 해결이 되지 않음!
새로운 Snapshot 설계:
Event Handler → 실시간 가격 상태 업데이트 → In-Memory Cache
↓
Cron Job → 정해진 시간마다 스냅샷 → DB 저장
- Base Chart Generator 제거 -> 두 프로세스가 경쟁할 이유가 사라짐
- 역할 완전 분리
- Event Handler: 오직 실시간 가격 상태 업데이트만
- Cron Job: 오직 현재 상태를 DB에 저장만
- 의존성 제거: 순서와 상관없이 각자의 역할만 수행
🔹 2. 메모리 효율 향상
type ChartManager struct {
cron map[ChartType]*cron.Cron
mongoDB *mongoDB.MongoDB
ohlcv1MCache map[string]*Ohlcv // 1분봉용 실시간 가격 캐시
ohlcv5MCache map[string]*Ohlcv // 5분봉용 실시간 가격 캐시
// ... 기타 필드들
}
기존에는 모든 차트 데이터를 Chart 구조체의 map[common.Address]*Ohlcv 형태로 메모리에 보관했다면,
새로운 설계에서는 실시간 가격 상태만 캐시에 보관하고 주기적으로 스냅샷을 DB에 저장하는 방식으로 변경했다.
해당 설계로 변경함으로써 모든 차트 데이터를 메모리에 올릴 필요가 없어졌다. 차트 데이터는 각 차트 타입별로 단 하나의 실시간 가격 상태만 메모리에 유지하고 있고, cron job을 통해 정해진 시간마다 현재 상태를 DB에 적재하고 있기 때문이다.
이로 인해 수백 개의 토큰이 동시에 거래되어도 메모리 사용량이 일정하게 유지되며, 시간이 지나도 메모리가 무한정 증가하지 않게 되었다.
🔹 3. 동시성 제어 개선
기존의 단일 뮤텍스 방식에서 차트 타입별 세분화된 Read/Write Lock 방식으로 개선했다.
type ChartManager struct {
// ...
chartCacheLock map[ChartType]*sync.RWMutex // 차트 타입별 락 분리
// ...
}
func NewChartManager(mongoDB *mongoDB.MongoDB) *ChartManager {
c := &ChartManager{
// ...
chartCacheLock: map[ChartType]*sync.RWMutex{
Chart1M: new(sync.RWMutex), // 1분봉 전용 락
Chart5M: new(sync.RWMutex), // 5분봉 전용 락
},
// ...
}
return c
}
그리고 재적재 goroutine과 기존 차트 업데이트 goroutine의 race condition 방지를 위한 원자적 플래그가 도입 되었다.
type ChartManager struct {
// ...
HistoricalChartTrigger sync.Once // 재적재 작업은 딱 한 번만 실행
isRestacking1M atomic.Bool // 1분봉 재적재 상태
isRestacking5M atomic.Bool // 5분봉 재적재 상태
HistoricalTimestamp map[ChartType]int64 // 재적재 완료 시점
}
func (cm *ChartManager) IsRestacking(chartType ChartType) bool {
if chartType == Chart1M {
return cm.isRestacking1M.Load()
}
return cm.isRestacking5M.Load()
}
func (cm *ChartManager) SetIsRestacking(chartType ChartType, isRestacking bool) {
if chartType == Chart1M {
cm.isRestacking1M.Store(isRestacking)
} else {
cm.isRestacking5M.Store(isRestacking)
}
}
🔹 4. 서버 재기동 시나리오 추가
서버 장애나 네트워크 문제로 인한 데이터 유실을 방지하기 위해 서버 재기동 및 차트 데이터 재적재 코드를 추가했다.
이로써 아래와 같은 장점을 얻으며 안정적이고 확장 가능한 실시간 차트 시스템을 구축하게 되었다.
- 데이터 무결성 보장: 서버 재시작 시에도 누락된 데이터 없이 완전한 시계열 데이터 제공
- 실시간성 유지: 과거 데이터 재적재 중에도 현재 이벤트는 계속 처리
- 멱등성: 동일한 시간 구간을 여러 번 재처리해도 결과가 일관됨
🟧 회고
짧지만 며칠 동안 내 뇌 CPU를 팽팽 썼던 강렬한 나날들이었다. 기한 내에 구현을 마치지 못할까봐 그래서 혹시 프로젝트가 백엔드 때문에 딜레이가 될까봐 정말 똥줄도 탔고, 그럴 때일 수록 침착하게 업무를 수행할 수 있도록 마음을 다스리면서 코드를 구현했다.
그럼에도 몇가지 수정 사항이 이 글을 쓰면서 발견되기는 했지만, 그래도 무사히 기능이 돌아가서, 딜레이 없이 서비스를 오픈할 수 있어서 다행이었다.

이 기쁨을 팀원들, 팀장님과 함께했다. 다들 내 완성만 기다리고 있었던 만큼ㅋㅋㅋㅠㅠ
그 부담감도 상당했는데 다들 수고했다고 해주시니 더더더 뿌듯했다.
이번 작업을 하면서 기술적으로나 정신적으로나 한층 성장했다고 느꼈다. 뭐랄까 기나긴 평행선을 지나 이제 계단식 성장의 오르막 초입에 들어선 느낌?!
처음 다뤄보는 캔들 차트 로직의 복잡함, 실시간 데이터 처리 설계의 어려움, 그리고 동시성 문제 해결의 중요성을 모두 체감한 프로젝트였다. 나를 믿어주고 도와준 팀원들 덕분에 별 탈 없이 여기까지 해낼 수 있었다. 같이 설계를 고민해주고 응원해준 시간들이 없으면 불가능했을 프로젝트라고 생각한다. (불가능하진 않겠지만 엄청 시간이 걸렸을 것이다.)
이번 경험을 통해 나는 정말 많은 것을 배웠다.
1. 과감한 재설계의 중요성, 그리고 초기 설계의 중요성
: 이만큼 설계의 중요성을 느낀 프로젝트는 없었던 것 같다. 최적의 기능을 수행하도록 하기 위해서는 다양한 시나리오를 고려하고 여러 장애 상황을 철저하게 핸들링하는 등 초반 기획이 중요하다. 만약 설계가 잘못되어서 중간에 막혔다면, 아까워하지 말고 과감하게 설계를 엎는 결단이 필요하다. 그게 더 리소스를 아끼는 일이라는 걸 알았다!
2. 막히는 부분이 있다면 동료들과 머리를 맞대어 공유하기
: 처음 설계 뒤집을 때 팀원들이랑 얘기를 하면서 설계를 보완하지 않았으면 뒤에서 또 설계를 엎는 경우가 생겼을 수 있었을 것이다. 또 얘기를 하면서 스스로 설계 결함을 찾거나 더 나은 방법들을 찾게 되었다.
그들도 나를 도와주고 싶어하고, 나도 부끄러워하지 않고 모르는건 모른다고 적극적으로 얘기를 하는게 정말 좋은 결과를 낳는다!
집단 지성은 언제나 개인의 한계를 뛰어 넘으니까!
3. 테스트, 테스트, 테스트!
: 단위 테스트, 통합 테스트는 선택이 아닌 필수다. 코드 및 설계 변경에 따른 예상치 못한 오류를 사전에 방지하고, 시스템의 안정성을 담보하는 확실한 방법이다! 하지만 이번에는 시간이 극도로 촉박해 이 부분을 충분히 챙기지 못한 점이 큰 아쉬움으로 남는다. 다음에 비슷한 난이도의 프로젝트를 진행할 때는 똑같은 불상사가 반복되지 않도록 미리 단위 테스트를 진행해가면서 구현을 해야겠다고 생각했다.
4. 포기하지 않으면 결실을 맺는다
: 벼랑 끝에 몰린 상황에 나는 상당히 많은 압박과 부담을 받았다. 진짜 머리털이 빠지는 느낌을 매분 매초 받았다고 할 수 있다. 내 경력과 실력에 비해 너무 챌린징한 과제라는 생각에 원망이 고개를 들기도 했지만 나는 곧바로 털어버리려고 마인드 컨트롤했다.
그만큼 나는 팀에서 인정받고 있고, 내가 해낼 수 있다고 평가받고 있고, 실제로 나는 그럴 잠재력을 가지고 있다! 고 끊임없이 내가 내 스스로를 믿으려고 했다. 그리고 내가 이 정도로 스트레스 받을 만큼 어려운 과제라면 '이 과제가 끝난 이후의 나는 얼마나 성장해 있을까, 얼마나 많은 걸 배울 수 있을까!' 를 생각하니 그 설렘에 되려 몸이 떨리고, 가슴이 두근거리기 시작했다. 그게 내 원동력이 되어 프로젝트를 무사히 완수할 수 있었고, 실제로 결실을 맺은 경험을 통해 다음 '도전'에도 두려워하기 보다는 성장에 대한 기대감을 가지고 임할 수 있을 것 같다!
+) 프로젝트의 완수(?)와 별개로 코드 적으로는 개선이 필요한 부분이 많다.
1. 촉박하게 개발한 나머지 메모리 효율을 고려해서 좀 더 코드 개선이 필요하다.
2. context 관리가 하나도 되어있지 않다.
: 고루틴을 많이 사용하고 있기 때문에 생명 주기 관리 및 graceful shutdown을 위한 context 패턴 고려가 팔요하다.
3. 매끄럽지 않은 프로세스 구조, 흐름제어를 위한 플래그들이 너무 많은게 마음에 들지 않는다.
4. 테스트 코드를 생성해서 현재 내가 발견하지 못한 오류들을 찾는 과정이 필요하다.
이 부분은 틈틈히 리팩토링 작업을 해나갈 생각이다.
후속편 예고: 만약 내가 처음부터 설계 했다면?
만약 내가 처음부터 이 작업의 담당자였다면 어떻게 설계했을까?
처음 개발해보는 기능이라 우선 설계에 대한 레퍼런스 공부가 필요했을 것 같다.
현재 설계도 에러를 잡기위해 임시 방편(?)으로 여차저차 생각해낸 설계이다 보니, 다른 개발자들은 차트 적재 서버를 어떻게 설계하는지 알아보고 싶다.
이와 관련된 자료들은 따로 정리해서 새로운 글로 올릴 예정이다. 글이 완성되면 이 글에도 다시 태그를 걸어놓겠다.
밑에는 그 당시 내가 검색을 하면서 찾은 자료들이다. 후속편이 언제 올라올지 모르기 때문에(ㅠㅠ) 혹시나 빨리 궁금한 사람이 있다면 도움이 되었으면 좋겠다는 마음으로 첨부를 한다.
1. 시계열 데이터베이스 도입
Storing and Processing Billions of Cryptocurrency Market Data Using InfluxDB
Recently, I’ve been interested in historical market data across different cryptocurrency exchanges for data analysis and automated trading…
medium.com
장점:
- 시간 기반 데이터 최적화: 시계열 데이터의 저장, 빠른 조회, 기간별 집계, 다운샘플링 등의 특화
- 태그와 필드: token_address 등을 태그로 사용하여 다양한 조건으로 필터링 용이
- 저장 효율성 및 압축: 시계열 데이터 특성을 활용한 효율적인 압축
2. kafka 등을 이용한 이벤트 스트리밍 아키텍처 도입
Illuminating Insights: Generating Real-Time Candlestick Charts from Price Feeds for Your Fintech…
Shedding Light on Financial Insights with Visuals
medium.com
장점:
- 결합도 감소: 이벤트 생산자와 소비자를 완전히 분리
- 내구성 및 재처리: Kafka가 이벤트를 안전하게 보관하므로 장애 시에도 이벤트 유실 방지
- 부하 분산: 컨슈머 그룹을 통한 병렬 처리
단점:
- 약간의 지연 시간: 메시지 큐를 거치면서 수십~수백 ms의 지연 추가
- 인프라 복잡성: Kafka 클러스터 운영 비용과 관리 포인트 증가
즉, 밈토큰 차트의 실시간성 요구사항을 고려할 때 이 지연이 acceptable한지 신중한 검토가 필요하다!
'Error Handling' 카테고리의 다른 글
| [AI] Claude Code를 이용해 메모리 사용량을 줄인 소소한 후기 (0) | 2025.06.24 |
|---|---|
| Go 서버 악마 퇴치기 2탄 - goroutine과 channel의 무서움 (0) | 2025.06.10 |
| [Go] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 1. 문제편 (0) | 2025.02.26 |
| Go 서버 악마 퇴치기 1탄 - 포인터와 슬라이스를 쓸 때 주의할 점 (0) | 2024.12.15 |
| [Go] pprof로 메모리 누수 찾아내기 (0) | 2024.09.09 |