드디어 중간고사가 끝났다. 시험 기간 내내 손도 못 대고 있던 Upcoin 모의투자 거래소 프로젝트의 서비스간 통신문제를 해결하였다. 곧 다가올 소프트웨어 축전에서 Upcoin 모의투자 거래소로 부스를 운영할 예정인데, 이를 대비해 진행한 유지 보수 과정에서 가장 걱정하던 문제를 해결한 경험을 기록으로 남기고자 한다.
처음에는 별 문제가 없었다. 각 서비스는 사용자의 요청을 잘 처리하고 있었다. Node.js는 열심히 WebSocket 메시지를 처리하고, PHP는 필요할 때마다 HTTP로 "지금 진행 중인 분봉 어떻게 되고 있어?"라고 Node.js 서버에 물어보면 그만이었다. 하지만 요청 프로토콜이 HTTP이다보니 데이터를 받아오는 데 약간의 지연이 발생했고, 이 부분을 어떻게 개선할지 고민하게 되었다.
곧 다가올 소프트웨어 축전에서 Upcoin 모의투자 거래소에 많은 사용자들이 동시에 접속할 가능성이 있었다.
"Upcoin 모의투자 거래소 시스템이 이 정도 트래픽을 감당할 수 있을까?"
대규모 트래픽 대비 겸 테스트 서버에서 부하 테스트를 진행해보았다. 결과는 예상보다 좋지 않았다. 동시 접속자가 늘어날수록 Node.js 서버의 CPU 사용률이 급격히 올라갔고, 특히 PHP가 요청하는 /current-minute-data HTTP API 호출이 병목 지점으로 확인됐다.
생각해보니 PHP가 Node.js에게 HTTP 요청을 할 때마다 TCP 연결을 맺고, 헤더를 교환하고, JSON 응답을 파싱하는 등의 오버헤드가 발생한다. 그리고 더 큰 문제는 실시간성이었다. HTTP 요청은 '요청 시점'의 데이터만 가져올 수 있어서, 거래가 활발한 시간대에는 차트가 약간 지연되어 표시되는 문제가 있었다.
이틀동안 컴퓨터 앞에 앉아 계속 고민했다. 축전 당일에 거래소에 장애가 발생하여 체험을 못하고 방문자들이 가버리는 건 생각도 하기 싫다. 더 실시간성 있고 효율적인 통신 방법이 필요했다.
고민 끝에 세 가지 방안을 떠올렸다.
"Kafka를 도입해볼까?"
장점: 실시간 데이터 처리에 강함, 데이터 유실 없음
단점: 설치하고 운영하는 것이 너무 복잡함. 학교 서버에 새로운 시스템을 설치하기에 복잡하고 한번도 안해봄, 간단한 상태 공유에 Kafka는 과한 느낌
"gRPC는 어떨까?"
장점: HTTP보다 훨씬 빠름, Protocol Buffers로 직렬화하니 효율적
단점: 결국 '요청-응답' 모델이라 근본적인 문제 해결은 안 됨. 개발 복잡도도 증가
"Redis Pub/Sub 기능을 써볼까?"
장점: 실시간으로 데이터 변경을 알림 받을 수 있음
단점: 구독자가 연결되어 있을 때만 메시지 수신. PHP는 동기적이라서 항상 연결되어 있진 않음
기존에 시세데이터를 MariaDB에 저장하였는데 관계형 데이터베이스라서 느려서 이를 redis로 바꾸는 작업 중 아이디어가 떠올랐다.
시세 데이터는 이미 Redis에 캐시하고 있었는데, 현재 캔들 데이터도 Redis에 캐시하면 되겠다는 생각이 들었다. 그리고 바로 Redis의 기본 기능인 Hash 자료구조와 HSET/HGETALL가 떠올랐다.
그래서 Redis를 사용하면:
너무 단순해서 오히려 고려하지 않았던 방법이었다. 고급 메시징 시스템 없이, 추가 인프라 없이, Redis라는 '공유 게시판'만으로도 충분했다. 이렇게 하면 실시간성도 향상될 것 같았다.
Node.js에서는 HTTP API 코드를 떼어내고, 웹소켓 메시지 처리 로직에 Redis 업데이트 코드를 추가했다:
// 웹소켓 메시지 처리 중...
if (parsedData.type === 'ticker') {
// Redis에 저장할 현재 캔들 데이터 객체 (모든 값을 문자열로)
const dataToStore = {
time: formattedTime,
open: String(candleData.open ?? '0'),
high: String(candleData.high === -Infinity ? (candleData.open ?? '0') : candleData.high),
low: String(candleData.low === Infinity ? (candleData.open ?? '0') : candleData.low),
close: String(closePrice),
volume: String(candleData.volume ?? '0')
};
// Redis에 Hash 형태로 저장!
await redisClient.hSet(redisKey, dataToStore);
}
// HTTP API 호출 대신
$current_candle_redis = $redis->hGetAll("current_minute_candle:KRW-" . $code);
운영서버에 배포한 후, 전반적인 서버 메트릭을 분석해보았다.
'대규모 트래픽 환경에서 최신 1분봉 데이터 공유'라는 문제를 해결하기 위해 Kafka부터 gRPC, Redis Pub/Sub까지 다양한 기술들을 고민하였지만, 결국 가장 단순한 Redis HSET/HGETALL 방식을 선택하였고, 그 결과는 매우 좋았다.
이제 웹소켓 서버인 node.js의 코드가 변경되면 코드 반영을 위해 다운타임이 생기는 문제가 있는데 무중단으로 배포하는 방법에 대해서 탐구해봐야겠다.