실시간·Hot·Cold 3계층 데이터 아키텍처를 설계한 이유

시간 순서로 쌓이는 이벤트 데이터도 시간이 지나면 접근 빈도와 갱신 방식이 달라집니다. 수집 직후에는 새 데이터가 계속 들어오고 상태가 갱신되지만, 최근 며칠 또는 몇 주의 데이터는 화면 조회와 통계에서 반복해서 읽힙니다.

수개월이 지난 데이터는 거의 조회되지 않지만 감사, 장기 통계, 파일 내보내기를 위해 보관해야 합니다. 초기에는 모든 조회 데이터를 PostgreSQL에 계속 쌓았습니다. 데이터가 많지 않을 때는 가장 단순하고 합리적인 선택이었습니다. 오래된 범위를 실제로 옮기는 과정은 PostgreSQL 데이터를 Parquet로 이관하는 파이프라인에서 별도로 다룹니다.

하지만 테이블과 인덱스가 함께 커지자 최근 데이터만 조회하는 API도 오래된 이력의 영향을 받기 시작했습니다. 대량 삭제는 WAL과 vacuum 부하를 만들었습니다. 백업과 복구 시간도 늘어났습니다.

그렇다고 오래된 데이터를 버릴 수는 없었습니다. 결국 데이터를 Realtime, Hot, Cold 세 계층으로 나눴습니다. 중요한 것은 기술 이름이 아닙니다.

데이터의 수명과 접근 패턴에 따라 책임을 나눴다는 점입니다.

Realtime·Hot·Cold 세 계층의 책임 경계

Realtime 계층은 수집 직후의 원본과 처리 상태를 담당합니다. 원본 문서에는 조회 모델을 만드는 전체 데이터가 있습니다. PostgreSQL 동기화 여부도 함께 기록됩니다.

이 계층은 MongoDB로 구성했습니다. Hot 계층은 최근 범위의 온라인 조회를 담당합니다. 사용자는 기간, 장비, 제품, 결함 종류 등 다양한 조건으로 필터링하고 정렬합니다.

관계형 인덱스와 집계가 필요한 영역이어서 PostgreSQL을 사용했습니다. Cold 계층은 변경이 끝난 오래된 데이터를 저렴하게 보관하고 필요할 때 읽는 역할입니다. 큰 기간 스캔과 일부 컬럼 조회가 중요했습니다.

그래서 Parquet로 저장하고 DuckDB로 읽었습니다.

수집 요청
   │
   ▼
MongoDB (Realtime / Source of Truth)
   │ Redis Streams
   ▼
PostgreSQL (Hot / 최근 조회와 통계)
   │ Migration Job
   ▼
Parquet + DuckDB (Cold / 장기 보관과 분석)

세 계층이 같은 데이터를 무작정 복제하는 것은 아닙니다. Realtime은 원본과 처리 상태, Hot은 조회에 필요한 정규화된 모델과 인덱스, Cold는 닫힌 기간의 컬럼 파일을 가집니다. 각 계층은 다음 계층을 다시 만들 수 있는 정보와 완료 증거를 남깁니다.

Hot과 Cold의 경계는 단순한 보존 일수가 아니었다

처음에는 now - retentionDays를 경계로 사용하면 된다고 생각했습니다. 그러나 현재 시각을 기준으로 매 요청마다 경계가 움직이면 같은 시각의 데이터가 Hot과 Cold 양쪽에 있거나 어느 쪽에도 없는 순간이 생깁니다. Migration Job의 실제 완료 범위와 조회 라우터의 계산 경계가 다르면 더 위험합니다.

그래서 Cold 계층의 manifest와 coverage를 경계의 근거로 사용했습니다. manifest는 어떤 dataset의 어느 반개구간 [start, end)이 어떤 Parquet 파일에 들어 있고 검증이 끝났는지를 기록합니다.

data class ColdManifest(
    val dataset: String,
    val startAt: Instant,
    val endAt: Instant,
    val path: String,
    val rowCount: Long,
    val status: MigrationStatus,
)

fun covers(time: Instant): Boolean =
    status == MigrationStatus.SUCCEEDED &&
        time >= startAt && time < endAt

반개구간을 사용한 이유는 인접한 두 범위를 합칠 때 경계 중복을 피하기 쉽기 때문입니다. 첫 파일이 [8월 1일, 8월 11일), 다음 파일이 [8월 11일, 8월 21일)이면 8월 11일 0시는 두 번째 파일에만 들어갑니다. Hot 보존 정책과 Cold coverage가 항상 같다고 가정하지 않았습니다.

이관은 실패하거나 지연될 수 있습니다. 따라서 Hot 삭제는 일정이 지났다는 이유만으로 실행하지 않고, 대응하는 manifest가 성공했고 행 수와 범위 검증이 끝났을 때만 허용했습니다.

계층별 window를 만드는 조회 라우터

사용자는 데이터 위치를 알 필요가 없어야 했습니다. 조회 요청이 Hot 범위에만 있으면 PostgreSQL을 사용하고, Cold 범위에만 있으면 DuckDB로 Parquet를 읽습니다. 두 계층을 걸치면 요청 기간을 겹치지 않는 window로 분할합니다.

data class TimeWindow(val start: Instant, val end: Instant)

fun route(request: TimeWindow, coldEnd: Instant): List<TierWindow> = buildList {
    if (request.start < coldEnd) {
        add(TierWindow.COLD(request.start, minOf(request.end, coldEnd)))
    }
    if (request.end > coldEnd) {
        add(TierWindow.HOT(maxOf(request.start, coldEnd), request.end))
    }
}

여기서 coldEnd는 단순히 설정 파일의 보존 일수가 아니라 연속적으로 검증된 coverage의 끝입니다. 중간 구간의 manifest가 빠졌다면 그 뒤에 성공 파일이 있어도 연속 coverage로 보지 않습니다. 그렇지 않으면 라우터가 누락 구간까지 Cold에 있다고 판단할 수 있습니다.

각 repository는 결과를 공통 read model로 변환합니다. 필드 이름과 nullable 규칙이 다르면 계층을 합친 뒤 사용자에게 서로 다른 데이터처럼 보입니다. 정렬 키도 occurredAt 하나만 사용하지 않고 동률을 깨는 안정적인 식별자를 함께 둡니다.

-- Hot
SELECT event_id, occurred_at, source_key, device_key, has_issue
FROM event_record
WHERE occurred_at >= :start_at AND occurred_at < :end_at
ORDER BY occurred_at DESC, event_id DESC;

-- Cold
SELECT event_id, occurred_at, source_key, device_key, has_issue
FROM read_parquet(:paths)
WHERE occurred_at >= :start_at AND occurred_at < :end_at
ORDER BY occurred_at DESC, event_id DESC;

페이지네이션은 계층 분리보다 더 어려운 문제였다

두 결과를 모두 메모리에 읽어 정렬한 뒤 페이지를 자르면 구현은 쉽지만 데이터가 많을 때 사용할 수 없습니다. 반대로 Hot과 Cold에 각각 LIMIT 100을 적용하고 단순히 이어 붙이면 전체 정렬 기준상 상위 100개를 보장하지 못합니다. 시간 역순 조회에서는 일반적으로 Hot 데이터가 Cold보다 최신이므로 Hot window를 먼저 소진하고 이후 Cold로 넘어갈 수 있습니다.

하지만 요청 범위와 정렬 방식에 따라 이 가정이 깨질 수 있습니다. 임의 컬럼 정렬이나 score 정렬을 지원한다면 두 계층에서 후보를 읽어 merge하는 cursor 전략이 필요합니다.

조회 요구적합한 방식주의점
시간 역순, 경계가 명확함Hot을 먼저 읽고 Cold로 이동cursor에 계층과 안정 정렬 키 포함
전체 CSV 내보내기각 window를 같은 writer에 순차 기록경계 중복과 컬럼 순서 검증
전역 score 정렬계층별 후보를 읽어 merge과다 후보 조회와 cursor 복잡성
장기 집계Cold pre-aggregation + Hot 집계 결합버킷 경계와 중복 제거

온라인 목록 API와 대량 export가 같은 repository를 억지로 공유하지 않았습니다. 온라인 API는 제한된 결과와 짧은 지연이 중요하고, export는 전체 범위를 일정한 메모리로 순회하는 것이 중요합니다. 공통 계약은 필터와 컬럼 의미에 두고 실행 방식은 분리했습니다.

Cold 이관에 함께 보존한 데이터와 메타데이터

Parquet 파일이 스토리지에 존재한다고 해서 이관이 끝난 것은 아닙니다. 임시 파일 생성에 성공했지만 업로드가 실패할 수 있고, 업로드는 성공했지만 manifest 쓰기 전에 프로세스가 종료될 수도 있습니다. Migration Job은 대략 다음 순서를 따릅니다.

  1. 이관할 dataset과 시간 범위를 계획합니다.
  2. 같은 범위의 중복 실행을 막기 위해 advisory lock을 획득합니다.
  3. DuckDB가 PostgreSQL source partition을 읽어 Parquet 임시 파일을 만듭니다.
  4. 행 수, 최소·최대 시각, 스키마를 검증합니다.
  5. 파일을 영구 경로로 이동하거나 업로드합니다.
  6. manifest와 연속 coverage를 갱신합니다.
  7. 검증된 범위에 대해서만 Hot 정리를 허용합니다.
COPY (
    SELECT *
    FROM postgres_scan('connection', 'public', 'event_record_p20260801')
    WHERE occurred_at >= TIMESTAMP '2026-08-01'
      AND occurred_at <  TIMESTAMP '2026-08-11'
) TO '/cold/event_record/2026-08-01.parquet'
  (FORMAT PARQUET, COMPRESSION ZSTD);

애플리케이션이 모든 행을 객체로 만들지 않고 DuckDB 엔진이 PostgreSQL에서 Parquet로 스트리밍하도록 했습니다. 이 방식은 JVM 힙 사용량을 줄이지만 임시 디스크 공간과 DuckDB connection lifecycle을 별도로 관리해야 합니다.

장애 시 누락을 막는 우선순위

Hot/Cold 통합 조회에서 Cold 파일 하나가 읽히지 않을 때 Hot 결과만 반환하면 부분 성공처럼 보일 수 있습니다. 사용자는 전체 기간의 결과라고 믿기 때문에 사용자에게 알리지 않은 누락이 가장 위험합니다. 온라인 API는 요청 범위가 누락된 coverage를 포함하면 명확한 오류 또는 데이터 가용성 상태를 반환하도록 했습니다.

일부 기능은 Hot 결과만 허용할 수 있지만 그 경우 응답에 실제 제공 범위를 함께 표시해야 합니다. Export Job은 어느 window에서 실패했는지 기록하고 전체 파일을 성공으로 만들지 않았습니다. 다음 상태를 운영 지표로 두었습니다.

  • dataset별 연속 Cold coverage의 끝.
  • 실패·진행 중 manifest 수와 가장 오래된 범위.
  • Hot 파티션과 Cold manifest 사이의 행 수 차이.
  • DuckDB query 대기 시간과 동시 실행 수.
  • Cold 파일 누락, 손상, 스키마 불일치 횟수.
  • Hot/Cold 경계를 지나는 조회와 export의 실패율.

세 계층이 항상 정답은 아니다

데이터량이 작고 보존 기간이 짧다면 PostgreSQL 한 곳이 훨씬 낫습니다. 파티션과 적절한 인덱스만으로 충분한데 Cold 계층을 추가하면 manifest, migration, 통합 조회, 파일 스키마 호환성이라는 운영 비용만 늘어납니다. Cold 계층을 도입할 시점은 저장 비용만으로 정하지 않았습니다.

최근 조회의 인덱스 크기, 대량 삭제 비용, 백업 시간, 장기 export 요구, 운영팀이 추가 pipeline을 감당할 수 있는지를 함께 봐야 합니다. Parquet 파일은 UPDATE와 작은 point lookup에 적합하지 않으므로 변경이 끝난 기간만 보내는 것도 중요합니다.

세 계층을 선택할 수 있는 조건

Realtime, Hot, Cold 구조의 장점은 가장 비싼 저장소에 모든 데이터를 영원히 두지 않아도 된다는 것입니다. 그러나 비용 절감은 자동으로 오지 않습니다. 각 계층의 책임, 시간 경계, manifest 상태, 재시작 순서를 명확히 해야 합니다.

이 구조에서 가장 중요한 계약은 사용자가 데이터 위치를 몰라도 된다는 점이었습니다. 요청 기간은 라우터가 나누고 각 계층은 같은 read model을 반환합니다. 반면 운영자는 데이터가 어느 범위까지 안전하게 이관됐는지 정확히 볼 수 있어야 합니다.

사용자에게는 하나의 데이터셋처럼 보이고 운영자에게는 계층별 상태가 투명하게 보일 때 3계층 아키텍처가 복잡성보다 더 큰 가치를 만듭니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다