업비트 자동매매 개발일지 #5 – 실시간 체결 데이터 DB 모델링하기

WebSocket으로 실시간 체결 데이터를 받는 것만으로는 자동매매 시스템이 완성되지 않는다. 프로그램을 종료해도 과거 데이터를 다시 조회할 수 있어야 하고, 같은 조건으로 전략을 반복 검증하려면 수집한 데이터를 일정한 구조로 저장해야 한다. 데이터가 쌓이는 속도뿐 아니라 나중에 꺼내 쓰는 속도까지 고려해야 했다.

이번 글에서는 업비트 실시간 체결 데이터를 PostgreSQL에 저장하기 위해 어떤 테이블을 만들었고, 왜 날짜별 파티션과 인덱스를 적용했는지 정리한다. 데이터베이스 모델링은 저장할 항목과 항목 사이의 관계를 미리 설계하는 작업이다. 화면에는 잘 드러나지 않지만 이후 백테스트와 모의투자의 기반이 되는 중요한 단계다.

논리 ERD로 본 데이터 구조

논리 ERD는 실제 저장 형식보다 데이터가 어떤 의미를 가지고 서로 어떻게 연결되는지 보여주는 그림이다. 종목 하나에는 시간에 따라 여러 건의 실시간 체결, 분봉, 장기 캔들 데이터가 연결된다.

업비트 자동매매 데이터베이스 논리 ERD
종목을 기준으로 실시간 체결, 분봉, 장기 캔들 데이터가 연결되는 논리 모델

그림의 연결선은 종목 코드를 기준으로 한 논리적인 관계다. 현재 실제 데이터베이스에는 이 관계를 강제로 묶는 외래키를 두지 않았지만, 모든 시세 데이터는 같은 종목 코드를 사용해 서로 연결할 수 있다.

수집 목적에 따라 테이블 나누기

데이터의 성격이 다르면 저장 방식과 조회 방법도 달라진다. 그래서 하나의 큰 테이블에 모든 내용을 넣지 않고 종목 정보, 캔들, 실시간 체결 데이터를 분리했다.

  • 종목 정보: 거래 종목 코드, 한글명, 영문명, 거래 상태와 수집 대상 여부를 관리한다.
  • 분봉 데이터: 1분부터 240분까지의 시가·고가·저가·종가와 거래량을 저장한다.
  • 장기 캔들 데이터: 일봉·주봉·월봉처럼 기간이 긴 데이터를 따로 관리한다.
  • 실시간 체결 데이터: WebSocket에서 받은 개별 체결을 빠짐없이 저장한다.

캔들은 일정 시간 동안의 체결을 하나로 요약한 데이터다. 반면 실시간 체결 데이터는 주문이 실제로 체결될 때마다 발생하므로 훨씬 빠르게 늘어난다. 두 데이터를 같은 방식으로 관리하면 체결 데이터가 커질수록 캔들 조회까지 영향을 받을 수 있어 목적에 따라 분리했다.

캔들 데이터는 어떻게 수집했나

실시간 체결과 캔들은 저장 구조뿐 아니라 가져오는 방법도 다르다. 개별 체결은 WebSocket으로 계속 받아 체결 테이블에 저장한다. 현재 진행 중인 분봉은 이 체결을 시간 단위로 묶어 프로그램에서 직접 만든다.

분봉은 1분, 3분, 5분, 10분, 15분, 30분, 60분, 240분 단위로 집계한다. 첫 체결 가격을 시가, 가장 높은 가격을 고가, 가장 낮은 가격을 저가, 마지막 체결 가격을 종가로 기록하고 체결 수량과 거래대금을 계속 더한다. 시간이 끝난 캔들은 분봉 테이블에 저장한다.

프로그램을 시작하기 전의 과거 분봉이나 수집하지 못한 구간은 업비트 분 캔들 조회 API로 보충한다. 업비트는 한 번의 요청에서 최대 200개 캔들을 반환하므로, 필요한 시작 날짜에 도달할 때까지 조회 종료 시각을 과거로 옮겨가며 반복해서 받아온다.

일봉·주봉·월봉은 실시간 체결로 직접 만들지 않고 업비트 REST API에서 받아 장기 캔들 테이블에 저장한다. 일봉은 하루, 주봉은 일주일, 월봉은 한 달의 가격 흐름을 나타낸다. 분봉보다 긴 시장 추세를 확인하고 종목의 거래 규모를 분류할 때 사용한다.

  • 일 캔들 조회: 하루 단위의 시가·고가·저가·종가, 거래량과 거래대금을 받는다.
  • 주 캔들 조회: 일주일 단위의 가격과 거래 정보를 받는다.
  • 월 캔들 조회: 한 달 단위의 가격과 거래 정보를 받는다.

업비트 캔들 API의 공식 호출 제한은 초당 10회다. 프로그램에서는 한계까지 호출하지 않고 약 0.12초 간격으로 요청해 초당 약 8회 수준으로 여유를 뒀다. 각 페이지를 저장한 뒤 다음 구간을 요청하기 때문에 중간에 작업이 멈춰도 이미 받은 데이터는 남는다.

아직 끝나지 않은 현재 캔들은 가격과 거래량이 계속 변한다. 같은 종목·시간 단위·시작 시각의 캔들이 다시 들어오면 새 행을 하나 더 만드는 대신 기존 행의 시가·고가·저가·종가와 거래량을 갱신한다. 이렇게 해야 API를 다시 호출해도 중복 없이 최신 값이 유지된다.

테이블과 컬럼별 역할

종목 테이블

종목 테이블은 거래 종목의 이름과 상태, 수집 여부를 관리하는 기준 정보다.

  • 종목 코드: 업비트가 제공하는 KRW-BTC 형식의 고유 값이다.
  • 심볼: 업비트가 별도로 주는 값이 아니라 KRW-BTC의 뒷부분을 잘라 프로그램에서 만든 값이다. KRW 표시를 반복하지 않고 BTC처럼 짧게 사용하기 위한 값이며, 스테이블 종목 목록과 비교할 때도 이 심볼을 사용한다.
  • 한글 종목명: 업비트 종목 목록에서 받은 한글 이름이다.
  • 영문 종목명: 업비트 종목 목록에서 받은 영문 이름이다.
  • 유의 종목 상태: 업비트 종목 목록에서 받은 투자 유의 경고 정보를 저장한다.
  • 종목 등급: 최근 7일의 평균 가격과 거래대금을 바탕으로 프로그램에서 계산한 내부 분류다. 전략이 메이저 종목, 스테이블 종목, 1원 미만 저가 종목을 제외하거나 유동성 규모별로 대상을 나눌 때 사용한다. 실제 시가총액을 직접 저장한 값이라기보다 자동매매에 필요한 거래 특성 분류에 가깝다.
  • 스테이블 종목 여부: 업비트 제공값이 아니라 프로그램에 등록한 스테이블 종목 목록과 심볼을 비교해 자동 설정한다. 특정 자산 가격을 따라가도록 설계된 종목은 일반 코인보다 변동 방식이 달라 현재 전략과 맞지 않기 때문에 수집 및 매매 대상에서 제외하기 위해 만들었다.
  • 활성 여부: 업비트 제공값이 아니라 프로그램에서 해당 종목을 사용할지 관리하는 내부 설정이다. 상장 폐지나 운영 중단처럼 더 이상 사용할 필요가 없는 종목을 데이터베이스에서 삭제하지 않고 수집기와 전략 대상에서 제외할 수 있다.
  • 매매 차단 여부: 신규 상장이나 수동 차단처럼 프로그램 내부 정책에 따라 자동매매 대상에서 제외했는지 표시한다. 신규 상장 종목은 급격한 가격 변동을 피하기 위해 처음 발견한 뒤 72시간 동안 자동 차단하며, 반복 오류나 전략에 맞지 않는 종목도 필요하면 별도로 제외한다. 차단된 종목은 신규 진입과 계좌 상태를 맞추는 대상에서는 제외하지만, 분석과 검증에 필요한 시세 데이터 수집은 계속한다.
  • 과거 데이터 수집 여부: 과거 캔들과 체결 데이터 보충 작업이 끝났는지 확인하기 위한 운영 관리값이다. 데이터가 충분하지 않은 종목을 백테스트에 섞거나 수집 완료 작업을 다시 실행하는 일을 막기 위해 추가했다. 현재 전략 진입 조건에 직접 사용하는 값이라기보다 데이터 준비 상태를 확인하는 역할이다.
  • 차단 사유: 신규 상장, 운영자 제외, 반복 오류처럼 차단한 이유를 구분한다. 신규 상장 사유만 72시간 후 자동 해제하고 다른 사유는 운영자가 확인할 때까지 유지하기 위해 필요하다.
  • 차단 메모: 같은 차단 상태라도 구체적인 원인이 다를 수 있어 운영자가 나중에 판단할 수 있도록 추가 설명을 남긴다.
  • 차단 시각: 언제 차단했는지 기록해 자동 해제 시점과 운영 이력을 확인한다.
  • 상장 시각: 업비트가 직접 주는 공식 상장 시각이 아니라 프로그램이 신규 종목을 처음 발견한 시각이다. 신규 상장 직후 72시간 동안 전략 진입을 막는 기준 시각으로 사용한다.
  • 생성 시각: 종목 정보가 처음 저장된 시간이다.
  • 수정 시각: 종목 정보가 마지막으로 변경된 시간이다.

실시간 체결 테이블

실시간 체결 테이블은 WebSocket으로 받은 개별 거래를 저장하는 가장 세밀한 원본 데이터다.

  • 종목 코드: 체결이 발생한 거래 종목을 연결한다.
  • 체결 고유번호: 업비트가 각 체결에 부여한 번호로 중복 여부를 확인한다.
  • 거래소 체결 시각: 업비트에서 실제 거래가 성립한 시간이다.
  • 체결 가격: 거래가 성립한 가격이다.
  • 체결 수량: 해당 가격에서 거래된 코인 수량이다.
  • 매수·매도 구분: 어느 주문 방향에서 발생한 체결인지 나타낸다.
  • 프로그램 수집 시각: 내 프로그램이 메시지를 받은 시간으로 수집 지연을 확인한다.

분봉 테이블

분봉 테이블은 일정한 분 단위로 체결을 묶어 짧은 구간의 가격 흐름을 요약한다.

  • 종목 코드: 캔들이 속한 거래 종목이다.
  • 분 단위: 1분·3분·5분처럼 한 캔들의 시간 길이다.
  • 캔들 시작 시각: 해당 분봉 구간이 시작된 시간이다.
  • 시가: 구간에서 처음 체결된 가격이다.
  • 고가: 구간에서 가장 높았던 가격이다.
  • 저가: 구간에서 가장 낮았던 가격이다.
  • 종가: 구간에서 마지막으로 체결된 가격이다.
  • 거래량: 구간 동안 거래된 코인 수량의 합이다.
  • 거래대금: 구간 동안 발생한 거래 금액의 합이다.
  • 생성 시각: 캔들 행이 처음 저장된 시간이다.
  • 수정 시각: 캔들 행이 마지막으로 갱신된 시간이다.

장기 캔들 테이블

장기 캔들 테이블은 일봉·주봉·월봉처럼 긴 기간의 가격 흐름을 저장한다.

  • 종목 코드: 캔들이 속한 거래 종목이다.
  • 일·주·월 구분: 한 캔들이 나타내는 장기 시간 단위다.
  • 캔들 시작 시각: 해당 일·주·월 구간이 시작된 시간이다.
  • 시가: 장기 구간에서 처음 체결된 가격이다.
  • 고가: 장기 구간에서 가장 높았던 가격이다.
  • 저가: 장기 구간에서 가장 낮았던 가격이다.
  • 종가: 장기 구간에서 마지막으로 체결된 가격이다.
  • 거래량: 장기 구간 동안 거래된 코인 수량의 합이다.
  • 거래대금: 장기 구간 동안 발생한 거래 금액의 합이다.
  • 생성 시각: 장기 캔들 행이 처음 저장된 시간이다.
  • 수정 시각: 장기 캔들 행이 마지막으로 갱신된 시간이다.

체결 테이블에 저장한 항목

체결 테이블에는 종목 코드, 체결 고유번호, 업비트 체결 시각, 체결 가격, 체결 수량, 매수·매도 구분, 실제 수집 시각을 저장한다. 데이터베이스에서는 각각 market, sid, trade_dt, price, volume, side, collected_at이라는 이름을 사용했다.

가격과 수량은 소수점 오차를 피하기 위해 정확한 자릿수를 저장할 수 있는 숫자 형식을 선택했다. 일반적인 실수 형식은 계산 과정에서 아주 작은 오차가 생길 수 있는데, 거래 데이터를 장기간 합산하면 이 차이가 커질 수 있기 때문이다. 매수·매도 구분은 업비트가 전달하는 ASK와 BID 값만 들어오도록 제한했다.

업비트 체결 시각과 실제 수집 시각도 따로 기록했다. 체결 시각은 거래소에서 거래가 발생한 시간이고, 수집 시각은 내 프로그램이 메시지를 받은 시간이다. 두 값을 비교하면 네트워크나 프로그램 처리 과정에서 데이터가 얼마나 늦게 도착했는지 확인할 수 있다.

중복 체결을 막는 기준

WebSocket이 끊겼다가 다시 연결되거나 누락 구간을 보정하면 같은 체결이 다시 들어올 수 있다. 이를 막기 위해 종목 코드, 체결 고유번호, 체결 시각의 조합을 한 건의 체결을 구별하는 기준으로 정했다.

이미 같은 조합이 저장되어 있다면 새 행을 추가하지 않고 넘어간다. 수집기는 이를 오류로 중단하지 않고 중복 건수만 통계에 기록한다. 이 방식 덕분에 실시간 수집과 누락 복구가 겹치더라도 동일한 체결이 여러 번 저장되는 것을 막을 수 있다.

날짜별 파티션을 적용한 이유

전 종목 체결 데이터는 하루에도 많은 양이 쌓인다. 하나의 테이블이 계속 커지면 특정 날짜를 조회하거나 오래된 데이터를 관리하는 비용도 함께 증가한다. 이를 줄이기 위해 체결 시각을 기준으로 하루 단위 파티션을 만들었다. 파티션은 하나의 큰 테이블을 날짜별 작은 서랍처럼 나누어 보관하는 기능이다.

수집기는 새로운 날짜의 데이터가 들어오면 해당 날짜의 파티션이 있는지 확인하고, 없다면 자동으로 만든다. 사용자는 체결 테이블 하나를 조회하지만 PostgreSQL은 조건에 맞는 날짜 파티션만 찾아간다. 백테스트처럼 기간이 정해진 조회에서 불필요한 데이터를 읽지 않는 것이 장점이다.

조회 속도를 위한 두 가지 인덱스

인덱스는 책의 찾아보기처럼 원하는 데이터를 빠르게 찾도록 도와주는 구조다. 종목별 최근 체결을 조회할 때는 종목 코드와 체결 시각을 묶은 B-Tree 인덱스를 사용했다. B-Tree는 값의 순서를 정리해 두는 일반적인 인덱스로, 특정 종목의 최신 데이터를 찾는 데 적합하다.

여러 종목에서 특정 시간 범위를 한꺼번에 조회할 때는 체결 시각을 기준으로 BRIN 인덱스도 사용했다. BRIN은 가까운 시간에 저장된 데이터가 물리적으로도 가까이 있다는 특징을 이용해 대략적인 저장 위치를 찾는다. 크기가 작고 시간순으로 계속 추가되는 체결 데이터에 잘 맞는다.

한 건씩 저장하지 않은 이유

체결이 들어올 때마다 데이터베이스에 한 건씩 저장하면 연결과 기록 작업이 지나치게 자주 발생한다. 현재 수집기는 메시지를 메모리에 잠시 모았다가 1초가 지나거나 500건이 쌓이면 한 번에 저장한다. 짧은 시간 동안 여러 체결이 몰려도 데이터베이스 작업 횟수를 줄일 수 있다.

빠르게 저장하는 것만큼 다시 꺼내 쓰기 좋은 구조를 만드는 것도 중요했다. 날짜별 파티션, 중복 방지 기준, 조회 목적에 맞는 인덱스를 함께 적용하면서 실시간 수집과 백테스트가 같은 데이터를 안정적으로 사용할 수 있는 기반을 만들었다.

다음 단계

다음 글에서는 앞서 발급한 업비트 API 키로 인증 요청을 만들고 보유 자산을 조회하는 과정을 정리할 예정이다. 실시간 시세 데이터와 내 계좌 정보를 연결하기 전에 인증이 정상적으로 동작하는지부터 확인해 보려고 한다.

개발일지 이어보기
이전 글: 업비트 자동매매 개발일지 #4 – API 키 발급과 안전한 관리 방법

이 글은 개인적인 개발 과정과 경험을 기록한 글이며, 특정 투자 또는 수익을 권유하거나 보장하지 않습니다.