한눈에 보기
- 문제: 매일 수집하는 주식 OHLCV를 쌓을 24/7 저장소가 필요했는데, 개발 데스크톱은 항상 켜져 있지 않다.
- 선택: 이미 24시간 돌고 있던 시놀로지 NAS에 MinIO를 올려 S3 호환 오브젝트 스토리지로 사용했다.
- 결과: 모든 데이터의 정본(source of truth)을 NAS의 Parquet로 두고, 나머지 컴포넌트는 전부 “조회자”가 됐다.
- 핵심 교훈: NAS의 대량 LIST 한계와 오브젝트 스토리지의 원자적 PUT — 이 두 가지 물리적 특성이 이후 아키텍처 결정을 계속 좌우했다.
저장소가 먼저다
이 프로젝트는 KRX와 NASDAQ 전 종목의 일봉을 매일 수집해서 기술적 지표를 계산하고, 스크리닝과 백테스팅에 쓰는 개인 데이터 플랫폼이다. 클라우드가 아니라 집 안의 장비로 돌린다.
파이프라인을 설계하기 전에 저장소부터 정해야 했다. 조건은 두 가지였다.
첫째, 수집기는 매일 장 마감 후에 돌아야 하는데 개발용 데스크톱은 항상 켜져 있지 않다. 데이터가 앉을 자리만큼은 24/7이어야 했다. 집에는 이미 24시간 돌고 있는 시놀로지 NAS가 있었다.
둘째, 저장 포맷과 접근 프로토콜이 특정 도구에 종속되면 안 됐다. 이 시점엔 조회 엔진을 뭘로 할지도 확정 전이었다(결국 DuckDB로 시작해 Trino까지 가게 되는데, 그 얘기는 DuckDB는 저장소가 아니라 조회 엔진이다에서 다룬다). 데이터 엔지니어링 생태계에서 이 조건을 만족하는 사실상의 표준은 “S3 API로 접근하는 Parquet”다. DuckDB의 httpfs, Trino의 Hive/Iceberg 커넥터, dbt, pyarrow 전부가 S3 프로토콜을 말한다.
그래서 결론은 자연스러웠다. NAS 위에 S3 호환 오브젝트 스토리지인 MinIO를 컨테이너로 올리고, 모든 데이터는 Parquet로 저장한다.
설치에서 만난 두 가지 함정
설치 과정은 단순할 줄 알았는데 두 번 걸렸다.
Docker Hub의 MinIO 이미지는 버려져 있었다. 습관적으로 minio/minio:latest를 받으려다 확인해 보니 Docker Hub 쪽 이미지는 2023년 이후 업데이트가 끊긴 상태였다. 보안 패치가 누락된 이미지를 24/7 스토리지에 쓸 수는 없다. MinIO Inc.가 공식 배포를 계속하는 곳은 quay.io/minio/minio였고, 이쪽을 선택했다. 커뮤니티 포크(pgsty/minio)도 있었지만 스토리지처럼 오래 살아남아야 하는 컴포넌트에는 공식 배포 채널이 우선이라고 판단했다. “latest 태그가 있다”와 “유지보수되고 있다”는 다른 문제라는 걸 다시 확인했다.
시놀로지의 Docker는 root 권한이 필요하다. SSH로 들어가 docker 명령을 치면 permission denied while trying to connect to the Docker daemon socket이 난다. 관리자 그룹에 docker 그룹을 영구 추가하는 방법도 있지만, NAS에서 docker를 만질 일이 자주 없어서 sudo로 운영하기로 했다. 볼륨은 호스트 경로 마운트 대신 Docker 관리 볼륨을 썼다 — 시놀로지의 경로 권한 문제를 우회하는 가장 단순한 방법이었다.
저장 구조: Medallion, 그리고 경로가 곧 파티션
버킷 안은 medallion 아키텍처를 따라 세 구역으로 나눴다.
stock-data/stockdata/
├── bronze/ ← 수집 원본 OHLCV
│ └── exchange={EX}/symbol={SYM}/ohlcv.parquet
├── silver/ ← 지표 적용 (날짜 파티션, 스크리닝용)
│ └── exchange={EX}/date={DATE}/data.parquet
└── silver_by_symbol/ ← 지표 적용 (심볼 파티션, 백테스팅용)
└── exchange={EX}/symbol={SYM}/indicators.parquet
Hive 스타일 경로(key=value)를 쓰면 DuckDB든 Trino든 경로 자체를 파티션 컬럼으로 인식한다. 별도 메타데이터 시스템 없이 디렉토리 구조가 곧 스키마의 일부가 되는 셈인데, 소규모 플랫폼에서는 이 단순함이 강력했다.
silver가 두 벌인 이유는 조회 패턴이 두 개이기 때문이다. “특정 날짜의 전 종목”(스크리닝)과 “특정 종목의 전체 기간”(백테스팅)은 파티션 축이 정반대라서, 어느 한쪽 레이아웃으로는 다른 쪽 조회가 모든 파일을 열게 된다. 같은 데이터를 두 번 저장하는 비용을 내고 두 조회 모두 O(1)로 만들었다. 저장 용량 2배는 개인 NAS 규모에서 감당 가능한 비용이었다.
NAS의 물리적 한계는 설계 제약이 됐다
운영하면서 NAS라는 물리적 실체가 설계에 개입하는 순간이 반복해서 왔다.
대량 LIST에 약하다. 심볼 수천 개 규모(KRX 약 4,100 + NASDAQ 약 3,900)의 **/*.parquet glob 조회는 NAS의 대량 LIST 처리 한계로 반복적으로 타임아웃이 났다. 단일 파일 읽기는 안정적으로 성공하는데 목록 나열이 문제였다. dbt 검증 작업을 할 때는 NAS 반복 요청이 계속 실패해서 파일을 로컬에 1회 캐시해 두고 비교해야 했을 정도다.
동시 연결 수에도 한계가 있다. 지표 백필에서 계산 루프 안에 저장 호출을 넣었더니 동시 요청이 몰리면서 Connection refused가 났다. 결국 계산 단계(Phase 1)와 저장 단계(Phase 2)를 시간적으로 분리하고 워커 수를 따로 조절하는 2-Phase 구조로 바꿨다.
이 한계들은 나중에 큰 결정들의 배경이 된다. glob 스캔 자체를 버리고 Trino 레이크하우스로 가게 된 것(사라지는 산출물 - dbt와 Trino Iceberg 레이크하우스), 그리고 파일 수 자체를 줄이는 싸움(쿼리 한 번에 104초 - 작은 파일 8만 개와의 싸움)이다.
반대로, 오브젝트 스토리지라서 얻은 것
한참 뒤의 일이지만 기록해 둘 가치가 있다. bronze를 종목당 파일 하나로 합치는 결정을 할 때 “파일 하나에 몰아넣으면 쓰기 실패 시 전체가 손상되는 것 아닌가”라는 우려가 있었다. 그런데 이 우려는 파일시스템의 부분 쓰기(partial write)를 가정한 것이고, MinIO 같은 오브젝트 스토리지의 PUT은 원자적이다 — 성공하면 전체가 교체되고 실패하면 이전 객체가 그대로 남는다. 연 단위로 파일을 쪼개서 방어하려던 위험을 스토리지가 이미 막고 있었던 것이다.
저장 레이아웃의 트레이드오프를 따질 때는 그 스토리지의 실제 실패 모델부터 확인해야 한다는 것, 이게 이 스토리지를 2년 가까이 쓰면서 얻은 가장 값진 교훈이다.
트레이드오프 정리
| 얻은 것 | 감수한 것 |
|---|---|
| 24/7 저장소를 추가 비용 없이 확보 | 모든 조회가 LAN 대역폭과 NAS 성능에 종속 |
| S3 API로 도구 교체 자유 (DuckDB→Trino 전환이 실제로 일어남) | 대량 LIST·동시 연결에 약한 NAS 특성이 설계 제약으로 상존 |
| 경로 기반 파티션의 단순함 | 파티션 설계 실수가 곧 성능 사고로 직결 |
클라우드 S3였다면 LIST 한계나 동시 연결 문제는 없었을 것이다. 대신 매달 비용이 나가고, 백테스팅처럼 같은 데이터를 반복해서 훑는 워크로드에서는 egress 비용이 무시하기 어렵다. 집에 이미 있는 NAS로 시작한 것은 지금도 옳은 선택이었다고 생각한다 — 다만 그 물리적 한계를 설계 제약으로 명시적으로 취급하게 되기까지 몇 번의 실측이 필요했다.