If it stinks, change it.
~블록체인 플랫폼 실시간 ohlcv 차트 개발기 시리즈~
[1] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 1. 문제편
[2] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 2. 해결편
[3] 블록체인 플랫폼 실시간 ohlcv 차트 적재 서버 후속편: Refactoring (1)
[4]블록체인 플랫폼 실시간 ohlcv 차트 적재 서버 후속편:Refactoring (2)
[5] 블록체인 플랫폼에서 실시간 ohlcv 차트 적재 서버 후속편: Restructuring
#3. 차트 재적재 로직 분리
🔹 AS-IS (1): ChartManager에 집중된 책임 2 + α
앞서 ChartManager가 너무 많은 일들을 맡고 있다고 언급했다. 그 중 또 하나의 짐을 덜어주어야 하는데,
이번에 그 주인 공은 바로 '차트 재적재 로직'이다. 과거의 누락된 차트 데이터를 다시 채워 넣는 로직이 현재는 하나의 거대한 메소드로 존재하고 있다. 왜 거대하냐면... 이 메소드는 DB에서 과거 거래 기록을 조회하고, 차트를 계산하고, 다시 DB에 저장하는 모든 기능을 담당하고 있다. (다시 변명하자면,,, 시간이 촉박해서 '정상적인 실행'에 주안점을 두고 다 때려 박았다... 나도 안다.. 이러면 안되는거...)
아래 코드는 리팩토링 전 코드이고, 문제점이 있는 부분만 발췌했다. 부끄럽지만 설명을 위해 공개한다...
그리고 과거의 코드를 현재 어떻게 리팩토링했는지도 주석으로 남겼는데 자세한 내용을 이제 설명하려고 한다.
여기서는 문제점을 중심으로 봐주면 되겠다.
// AS-IS: The problematic parts of the monolithic method (generalized)
func (cm *ChartManager) RestackHistoricalCharts(chartType ChartType, startAtTimestamp int64) error {
// ... 초기 설정 및 캐시 준비 ...
// 1. 👉 [문제점] 캐시 초기화 로직이 중복
// ✅ [해결] 이 로직은 HistoricalRestacker가 자신의 책임으로 가져가 처리
latestChart, _ := cm.db.GetLatestChart(startOfInterval, chartType)
// ... for 루프를 돌며 latestChart.Ohlcv를 로컬 캐시에 복사 ...
Batch: // 2. 👉 [문제점] 제어 불가능한 goto 루프
// ✅ [해결] for-select 구문과 context를 사용하여 외부에서 제어 가능한 루프로 변경됨
if endOfInterval < time.Now().Unix() {
// ... 캔들 초기화 로직 ...
chartForInterval, _ := cm.db.GetChartForInterval(startOfInterval, endOfInterval)
// 3. 👉 [문제점] 거대하고 중복되는 머지(merge) 로직
// ✅ [해결] 실시간 처리 로직과 동일한 'mergeOhlcv' 헬퍼 함수를 재사용하도록 개선
for token, incomingOhlcv := range chartForInterval.Ohlcv {
existingOhlcv := historicalCache[token]
if existingOhlcv == nil {
historicalCache[token] = incomingOhlcv
} else {
// ... 수동으로 필드를 비교하고 업데이트하는 긴 코드 블록 ...
}
}
// 4. 👉 [문제점] 루프 안에서 매번 DB에 쓰기 작업을 수행하여 비효율적
// ✅ [해결] HistoricalRestacker에서는 배치 단위로 모아 처리하거나,
// 더 효율적인 bulk write를 사용하도록 구조를 변경
err := cm.db.StoreChart(chartType, &Chart{ /* ... */ })
// ... 다음 인터벌로 시간 이동 ...
goto Batch // 다시 처음으로!
}
// 5. 👉 [문제점] 메인 캐시를 직접 덮어쓰는 매우 위험한 작업
// ✅ [해결] HistoricalRestacker는 독립적인 컴포넌트이므로 메인 캐시에 직접 접근하지 않음
// 재적재가 끝나면 ChartManager가 안전한 시점에 메인 캐시를 업데이트하도록 책임이 명확해짐
cm.overwriteMainCache(chartType, historicalCache)
return nil
}
이 코드의 주요 문제점은 다음과 같다고 볼 수 있다.
- 단일 책임 원칙(SRP) 위배: ChartManager가 차트 관리 뿐만 아니라 '과거 데이터 복구'라는 전혀 다른 책임을 떠안고 있다.
- 테스트의 어려움: 이 로직을 테스트하려면 실제 DB와 ChartManager의 모든 의존성을 함께 준비해야한다. 재시도 로직만 따로 떼어 테스트하는 것은 거의 불가능하다.
- 예측 불가능한 제어 흐름: goto문은 코드의 흐름을 따라가기 어렵게 만들어 '스파게티 코드'를 만든다. 그것보다 더 심각한 문제는 고루틴을 외부에서 제어하거나 안전하게 종료시킬 방법이 없다는 것이다.
- 비효율적인 I/O: 루프를 돌 때마다 매번 DB에 쓰기 작업을 수행한다. 이는 I/O에 엄청난 부하를 주는 매우 비효율적인 방식이다.
- 동시성 위험: 모든 작업이 끝난 후 재적재용 임시 캐시로 메인 캐시를 그대로 덮어쓰는데, 이 과정에서 어떠한 동시성 제어도 없어 Data Race에 무방비로 노출된다.
🔹 TO-BE (1): 관심사 분리
우선 ChartManager에서 이 로직을 분리시켰다. historical_restacker.go 라는 별도의 파일과 HistoricalRestacker 라는 인터페이스로 완전히 분리했다.
// TO-BE in chartManager.go
type ChartManager struct {
// ...
historicalRestacker HistoricalRestacker // <- The new expert component
// ...
}
func (w *ChartManager) TriggerHistoricalRestack(timestamp int64) {
w.historicalTrigger.Do(func() { // Only allow this to run once
for chartType, state := range w.chartStates {
// 1. "재적재 중" 플래그를 켜서 실시간 데이터 처리를 잠시 멈추도록 '감독'
state.isRestacking.Store(true)
// 2. 실제 작업은 비동기적으로 전문가에게 '지시'
go func(ct ChartType, st *ChartState) {
defer st.isRestacking.Store(false) // 작업이 끝나면 플래그 해제
if err := w.historicalRestacker.RestackHistoricalCharts(ct, timestamp, st); err != nil {
// ...
}
}(chartType, state)
}
})
}
이 구조의 핵심은 의존성 분리다. 이제 ChartManager는 더 이상 재적재 로직의 구체적인 구현을 알 필요가 없이, 그저 HistoricalRestacker 인터페이스 타입의 객체를 가지고 TriggerHistoricalRestack 메서드를 실행시키기만 하면 된다.
이는 의존성 역전 원칙(Dependency Inversion Principle, DIP)을 따른 것인데, 고수준 모듈(ChartManager)이 저수준 모듈(재적재 로직)에 의존하지 않고, 둘다 추상화에 의존하게 만든 것이다.
이 메소드는 sync.Once 를 사용해 전체 어플리케이션 생명주기 동안 단 한번만 실행되도록 보장한다. 이는 무거운 재적재 작업의 중복 실행을 방지하고, 동시성 환경에서 안전하게 초기화를 수행하기 위한 성능 최적화 및 동시성 제어 메커니즘이다.
func (w *ChartManager) UpdateChartModelWithCheck(
l types.Log, eventData any, eventTime int64, chartType ChartType, tradingType TradingType,
) error {
state, _ := w.getChartState(chartType)
// "재적재 중" 이거나, 들어온 이벤트가 재적재가 끝난 시점보다 과거의 것이면 Skip
if state.isRestacking.Load() || eventTime < state.historicalTimestamp.Load() {
return nil
}
return w.updateChartModelInternal(...)
}
참고로 실시간 이벤트를 처리하는 UpdateChartModelWithCheck에서는 isRestacking 플래그를 확인하여 재적재가 진행 중일 때는 업데이트를 건너 뛰도록 감독한다. 어차피 현재 시간까지 재적재 프로세스가 데이터를 처리할 거기 때문에 굳이 두 군데에서 처리할 필요가 없기 때문이다. (현재 서버 구조 상, 만약 과거 데이터도 재적재를 하고 싶고, 현재 데이터도 실시간으로 팔로업하고 싶다면 각각 서버를 2대 띄워야한다. 코드는 똑같고 config에서 start block을 변경해주면 되는 식이다.)
책임이 명확히 분리되면서 코드가 깔끔해지고 유지보수성과 안정성이 향상 되었다! 내가 계속! 코드를 짜면서도 수정하고 싶었던 부분이라 그 어느 개선 작업보다도 가장 속이 시원했던 작업이어서 그 기분을 짤로 전달해본다 ㅎㅅㅎ
🔹 TO-BE (2): 안전한 동시성 관리
책임을 분리하면서 가장 위험했던 동시성 문제도 구조적으로 해결할 수 있었다. HistoricalRestacker는 더 이상 ChartManager의 메인 캐시에 직접 접근하지 않는다!
위에 코드 예시에서 개선된 TriggerHistoricalRestack과 UpdateChartModelWithCheck 메소드 코드를 살펴보면,
HistoricalRestacker가 RestackHistoricalCharts 메소드에 ChartState 포인터를 이용해 작업 결과를 안전하게 넘긴다.
이 RestackHistoricalCharts 메소드 코드를 보자.
// TO-BE in historical_restacker.go
func (h *historicalRestackerImpl) RestackHistoricalCharts(chartType ChartType, eventTimestamp int64, state *ChartState) error {
start := (eventTimestamp / chartType.GetInterval()) * chartType.GetInterval()
historicalChartCache := &sync.Map{} // 재적재 전용 임시 캐시
// 1. 임시 캐시를 초기화
// ...
// 2. 배치 단위로 과거 데이터를 처리
// ...
// 3. 모든 작업이 성공적으로 끝나면, 마무리 함수를 호출
// 이때 처리한 마지막 시간(start)과 최종 결과물(historicalChartCache)을 전달
h.finalizeHistoricalRestack(state, historicalChartCache, start)
return nil
}
// 전달받은 ChartState에 결과를 안전하게 반영
func (h *historicalRestackerImpl) finalizeHistoricalRestack(state *ChartState, cache *sync.Map, lastStart int64) {
h.log.Trace("Finalizing historical restack...")
// ChartManager가 알려준 통로(state)를 통해, 원자적으로 결과를 업데이트
state.historicalTimestamp.Store(lastStart)
state.cache.Store(cache) // 가장 위험했던 메인 캐시 덮어쓰기가 원자적 연산으로 변경
state.cronJob.Start()
}
모든 작업을 마친 후 finalizeHistoricalRestack에서 원자적 연산으로 안전하게 ChartManager의 메인 캐시를 새로운 데이터로 교체한다! 이로써 데이터 경합이 발생할 수 있는 코드도 개선 되었다 :)
🔹 TO-BE (3): for-select 와 Quadratic Backoff 도입
관심사를 분리한 후, 내부 구현도 개선했다. (이사 후 새로운 짐정리 처럼~)
goroutine으로 무한정 돌며 외부에서 제어할 수 없었던 goto 루프를 for-select 구조로 바꾸고,
단순하게 10초를 기다리던 재시도 로직을 좀 더 세련된 제곱 백오프 방식으로 교체했다.
// TO-BE in historical_restacker.go
func (h *historicalRestackerImpl) processHistoricalBatches(cache *sync.Map, start int64, chartType ChartType) error {
now := time.Now().Unix()
currentTimestamp := (now / chartType.GetInterval()) * chartType.GetInterval()
// goto 대신 제어 가능한 for 루프
for start < currentTimestamp {
// 루프의 시작점에서 외부 종료 신호를 가장 먼저 확인
select {
case <-h.ctx.Done():
return h.ctx.Err()
default:
}
end := start + chartType.GetRange()
// 재시도 로직을 포함한 헬퍼 함수 호출
if err := h.processBatchWithRetry(cache, start, end, chartType); err != nil {
return err
}
start += chartType.GetInterval()
// ...
}
return nil
}
// 재시도 로직입니다. 제곱 백오프(Quadratic Backoff)를 사용
func (h *historicalRestackerImpl) processBatchWithRetry(cache *sync.Map, /*...*/) error {
for retry := 0; retry < maxRetries; retry++ {
if retry > 0 {
// 1s, 4s, 9s... 로 대기 시간이 늘어납니다.
backoff := time.Duration(retry*retry) * time.Second
time.Sleep(backoff)
}
if err := h.processSingleBatch(cache, /*...*/); err == nil {
return nil
}
// ...
}
return lastErr
}
이로서 context를 통해 ChartManager가 HistoricalRestacker 의 생명주기를 완벽하게 제어할 수 있게 되었다.
이 부분은 다음 리팩토링인 #4에서 더 자세히 설명한다!
🔹 TO-BE (4): 함수 분리와 배치 처리
// TO-BE in historical_restacker.go
// 단일 배치 처리
func (h *historicalRestackerImpl) processSingleBatch(cache *sync.Map, start, end int64, chartType ChartType) error {
// 1. 캔들 데이터 리셋
h.resetCandleData(cache)
// 2. DB에서 이벤트 기반 차트 데이터 가져오기
chart, err := h.mongoDB.WememeDB.GetChartFromOrganizer(h.ctx, start, end)
// ...
// 3. 기존 mergeOhlcv 헬퍼 함수를 재사용하여 데이터 병합
h.mergeChartData(cache, chart)
// 4. 배치 단위로 모아서 DB에 저장
return h.mongoDB.WememeDB.StoreNewChart(h.ctx, chartType, newChart)
}
// 캔들 리셋 로직
func (h *historicalRestackerImpl) resetCandleData(cache *sync.Map) { /* ... */ }
// 데이터 병합 롲기
func (h *historicalRestackerImpl) mergeChartData(cache *sync.Map, chart *Chart) {
chart.Ohlcv.Range(func(key, value any) bool {
// ...
// 실시간 처리 로직과 동일한 global 'mergeOhlcv' 헬퍼 함수를 재사용합니다.
cache.Store(token, mergeOhlcv(beforeOhlcv, ohlcv))
// ...
})
}
단일 책임 원칙은 구조체나 패키지 뿐만 아니라, 함수 수준에서도 중요하다. processSingleBatch, resetCandelData, mergeChartData 처럼 각 함수가 명확한 하나의 임무만 갖도록 분리하니, 코드를 이해하고 수정하기가 훨씬 쉬워졌다!
무엇보다 processingSingleBatch는 배치 단위로 DB I/O를 수행하므로, 루프마다 DB를 호출하던 과거에 비해 훨씬 효율적이게 되었다!
#4. 우아한 종료(Graceful Shutdown) 시스템 구축
초기 버전의 서버는 종료 시그널에 대한 아무런 대비가 되어 있지 않았다...
main 함수는 그저 필요한 고루틴들을 실행시키고, 영원히 블로킹 하는 select{} 구문으로 끝났다.
main 쪽도 개선이 필요함을 알았지만 그때 나는 차트 쪽을 정상화하는데만 신경을 쓰고 있었기 때문에 이 부분은 수정하지 못했다.
이 작업은 단순히 차트 관련 모듈에 국한된 변경도 아니었다. 서버를 구성하는 모든 핵심 컴포넌트들의 생명주기를 통합적으로 관리하는, 서버 전체 아키텍처 개선 작업이었다!
그래서 이건 리팩토링.. 이라기 보다는 오히려 2편의 "수정" 파트에 들어가는게 맞을수도..?
🔹 AS-IS: Suddenly Die
// AS-IS: A server where components run wild
// (리팩토링 전의 상태를 보여주기 위한 가상의 예시입니다.)
func main() {
// ... 의존성 초기화 ...
scanner := NewScanner(...)
writer := NewWriter(...)
chartManager := NewChartManager(...)
// 컴포넌트의 생명주기를 고려하지 않고 각자 알아서 실행시킴
go scanner.Start()
go writer.Start()
go chartManager.startAll()
log.Println("Server started. Press Ctrl+C to exit.")
// 프로세스가 강제 종료될 때까지 영원히 대기
// 각 컴포넌트에게 종료하라고 알려줄 방법이 없음!!
select {}
}
이 구조의 치명적인 문제는 Ctrl+C를 눌렀을 때 발생한다.
운영체제는 프로세스에 SIGINT 시그널을 보내 강제로 종료시키고, 각 컴포넌트들은 자신이 하던 작업을 마무리 할 기회도 얻지 못한 채 최후를 맞이하기 때문이다.
예를 들어 ChartManager의 경우, 메모리 캐시에만 존재하던 최신 ohlcv 데이터는 DB에 저장될 기회도 없이 그대로 유실된다.
또한 열려있던 DB 커넥션들은 비정상적으로 끊겨 리소스를 낭비할 수도 있다.
🔹 TO-BE: Context를 이용한 유기적인 종료 신호 전파
이 문제를 해결하기 위해, 서버 전체의 생명주기를 책임지는 중앙 관제 시스템을 도입했다.
1. 모든 컴포넌트는 Service에 의해 생성되고 관리된다.
// TO-BE in a generalized root.go
// Service는 서버의 모든 핵심 컴포넌트를 멤버로 소유합니다.
type Service struct {
scanner *scanner.Scanner
writer *writer.Writer
chartManager *chartManager.ChartManager // ChartManager도 이제 Service의 일부
// ... 기타 컴포넌트와 DB 커넥션
}
func NewService(cfg *config.Config) {
// ...
s := &Service{ /* ... */ }
// ========================================
// Component Initialization
// ========================================
// 모든 컴포넌트는 Service가 중앙에서 생성하고 관리합니다.
s.scanner = scanner.NewScanner(cfg, s.client)
s.writer = writer.NewWriter(s.client, s.mongoDB, s.mySql, /*...*/)
s.chartManager = chartManager.NewChartManager(s.mongoDB)
// ========================================
// Component Startup
// ========================================
s.scanner.Start()
s.writer.Start()
s.chartManager.Start() // 각 컴포넌트는 자신의 Start 메서드를 가진다
// ========================================
// Graceful Shutdown Setup
// ========================================
// 종료 처리기 설정 및 서버 대기를 시작
s.setupGracefulShutdown()
log.Info("Server started successfully. Press Ctrl+C to stop.")
s.waitForShutdown()
}
2. 모든 컴포넌트는 Context를 통해 생명주기를 공유한다.
이 아키텍처의 핵심은, 각 컴포넌트가 생성될 때, 자신의 생명주기를 제어할 context.Context를 내부적으로 생성한다는 점이다.
그리고 외부에서는 각 컴포넌트의 Stop() 메소드를 호출하여 종료를 명령할 수 있다,.
// TO-BE: 각 컴포넌트는 이제 Stop() 메서드를 가집니다.
// 예시: chartManager.go
func NewChartManager(db DatabaseClient) *ChartManager {
ctx, cancel := context.WithCancel(context.Background())
// ...
cm.ctx = ctx
cm.cancel = cancel
// ...
return cm
}
func (cm *ChartManager) Stop() {
cm.cancel() // 자신의 context를 취소하여 하위 고루틴에 전파
}
이 구조의 핵심은 각 컴포넌트의 Start 메서드가 ctx.Done() 채널을 주시하고 있다는 점이다.
main 함수에서 cancel()이 호출되면, 모든 컴포넌트의 select 문에 있는 case <- ctx.Done(): 이 실행되고,
각자 마무리 작업을 수행한 후 return하여 고루틴을 종료할 수 있도록 한다!
func (c *Component) run() {
for {
select {
case <-c.ctx.Done():
return
// ...
}
}
+) 여기서 한가지 아키텍처적인 선택에 궁금한 점이 생길 수 있다.
왜 Service가 생성한 단 하나의 Context를 모든 컴포넌트에게 주입하지 않고, 각 컴포넌트가 자신의 Context를 내부적으로 생성하도록 했을까?
Deep Dive: 왜 단일 Context가 아닌, '컴포넌트별 독립 Context'를 선택했는가?
두 방식의 차이를 통해 살펴보자.
대안 1: 단일 공유 Context 아키텍처
가장 먼저 떠올릴 수 있는 일반적인 구조는 Service가 main 함수처럼 context.WithCancel(context.Background())로 ctx를 만들고, 이 ctx를 모든 컴포넌트의 생성자에 주입하는 방식이다.
// 가상 시나리오: 모든 컴포넌트가 하나의 Context를 공유하는 경우
func NewService(cfg *config.Config) {
ctx, cancel := context.WithCancel(context.Background()) // Service가 단일 Context 생성
s := &Service{...}
s.scanner = scanner.NewScanner(cfg, s.client, ctx) // 생성자에 ctx 주입
s.writer = writer.NewWriter(..., ctx) // 생성자에 ctx 주입
s.chartManager = chartManager.NewChartManager(..., ctx) // 생성자에 ctx 주입
// 종료 시에는 cancel() 한번만 호출하면 모든 컴포넌트에 신호가 전파됨
go func() {
<-sigChan
cancel()
}()
// ...
}
장점 | 단점 |
|
|
대안 2: 컴포넌트별 독립 Context 아키텍처
root.go의 실제 코드는 각 컴포넌트가 자신의 생명주기를 스스로 책임지는 방식을 채택했다. 각 컴포넌트는 생성될 때 자신의 Context와 cancel 함수를 내부적으로 생성하고, 외부에는 Stop()이라는 제어 메서드만 노출한다.
// 실제 아키텍처: 각 컴포넌트는 자신의 Context를 소유한다.
// 예시: chartManager.go
func NewChartManager(db DatabaseClient) *ChartManager {
ctx, cancel := context.WithCancel(context.Background()) // ChartManager 자신의 Context
cm := &ChartManager{
// ...
ctx: ctx,
cancel: cancel,
}
return cm
}
func (cm *ChartManager) Stop() {
cm.cancel() // 자신과 자신의 하위 고루틴만 제어
}
Service는 이 컴포넌트들의 Stop() 메소드를 호출하는 관리자 역할을 한다.
// shutdown 메서드는 '지휘자'로서 종료 순서를 제어합니다.
func (s *Service) shutdown() {
// 1. Scanner를 먼저 멈춰서 더 이상 데이터가 들어오지 않도록 합니다.
s.scanner.Stop()
// 2. 다른 서비스들을 멈춥니다.
s.chartManager.Stop()
// 3. 채널에 남은 데이터를 처리할 시간을 준 뒤, 마지막으로 Writer를 멈춥니다.
close(s.logC)
time.Sleep(500 * time.Millisecond)
s.writer.Stop()
// ...
}
장점 | 단점 |
|
|
결론:
우리 서버는 명확한 데이터 파이프라인의 구조를 가지고 있다. 이 파이프라인이 중간에 끊기지 않고, 채널에 남은 데이터를 모두 처리한 뒤 안전하게 종료하는 것이 매우 중요했다.
따라서, 나는 약간의 복잡성을 감수하더라도 정교한 종료 순서를 제어할 수 있는 대안2, 컴포넌트별 독립 Context 아키텍철르 선택한 것이다!
3. Service가 종료 신호를 받아 모든 컴포넌트에 전파한다.
Service는 운영체제의 종료 신호를 감지하고, 자신이 관리하는 모든 컴포넌트에게 순서대로 종료를 명령한다.
// TO-BE in a generalized root.go
// setupGracefulShutdown은 종료 신호를 감지하는 고루틴을 실행합니다.
func (s *Service) setupGracefulShutdown() {
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan // 신호가 올 때까지 대기
log.Info("Received shutdown signal. Initiating graceful shutdown...")
// 모든 컴포넌트를 종료시키는 shutdown 메서드를 호출합니다.
s.shutdown()
}()
}
// shutdown 메서드는 컴포넌트들을 정해진 순서에 따라 종료시킵니다.
func (s *Service) shutdown() {
log.Info("Shutting down components...")
// 1. 외부로부터 데이터 유입을 가장 먼저 차단합니다. (Scanner)
s.scanner.Stop()
// 2. 다른 보조 서비스들을 중지합니다.
s.chartManager.Stop()
// s.avgRecovery.Stop()
// 3. 채널에 남아있는 데이터를 모두 처리할 때까지 잠시 기다린 후 Writer를 중지합니다.
close(someChannel)
time.Sleep(500 * time.Millisecond)
s.writer.Stop()
// 4. 모든 컴포넌트가 멈춘 후, DB 커넥션 등 리소스를 정리합니다.
s.cleanup()
log.Info("Shutdown complete")
os.Exit(0)
}
이 개선 작업은 서비스의 안정성과 데이터 무결성을 보장하는 중요한 리팩토링이었다.
이제 우리 서버는 언제 어떤 이유로 종료되더라도 데이터 유실이나 리소스 누수 없이 모든 작업을 깔끔하게 마무리할 수 있게 되었다.
이는 24/7 운영되어야 하는 프로덕션 서비스가 갖춰야 할 필수적인 신뢰성을 확보했다는 것을 의미한다.
The end? The And?: 차기작의 떡밥을 던지며
길고도 험난했던 리팩토링 여정이 끝났다. 책임은 명확해졌고, 코드는 여러모로 개과천선 했으며, 서버는 이제 우아하게 종료가 가능하다.
하지만... 여기서 과연 끝일까?!???!!? 이 세상에 완벽한 코드는 없다!
지금도 여전히 보완해야할 사항이 보인다. (젠장..ㅠ)
이 리팩토링 끝내고 글을 쓰는 약 3주간 또 기술 부채가 늘어났다... 하....
바로 스케일 아웃(Scale-out) 문제다.
지금까지의 모든 리팩토링은 이 서버가 단일 인스턴스로 배포되었을 때를 가정하고 있다. sync.Map과 atomic.Value로 무장한 우리의 차트 캐시는, 결국 해당 서버의 로컬 메모리에만 존재한다. 만약 트래픽이 폭증하여 서버를 여러 대로 늘려야 하는 상황이 온다면 어떻게 될까?
각 서버는 자신만의 로컬 캐시를 갖게 되고, 로드 밸런서를 통해 어떤 서버로 요청이 분산되느냐에 따라 사용자에게 완전히 다른 차트가 보이는,,, 끔찍한 캐시 불일치 문제가 발생할 것이다!!!
이 문제를 해결하기 위해서는 로컬 캐시의 한계를 벗어나, 모든 서버가 동일한 데이터를 공유할 수 있는 분산 캐시 시스템, 예를 들어 Redis나 Memcached 같은 기술을 도입해야한다.
이 새로운 기술 부채를 어떻게 해결하게 될지... 다음편에서 이어질수도 아닐수도?!
아니면 다른 프로젝트에서 선보일수도?!
'Project' 카테고리의 다른 글
[Go] 캔들 차트 적재 서버 후속편: Refactoring (1) (1) | 2025.08.04 |
---|---|
[Go] encoding/csv 패키지를 이용해 csv 다운로드 API 구현하기 (0) | 2025.06.12 |
[Go] 블록체인 플랫폼에서 실시간 ohlcv 차트(캔들 차트) 설계 방법 (부제: Restructuring) (0) | 2025.06.05 |
[Go] util 함수 개발기 - klaytn 패키지의 unpack 함수에서 abi 의존성 제거하기 (0) | 2024.07.23 |