BigQuery Storage Read API와 DATE 타입 불일치 해결하기

2025. 7. 2. 07:19·⚡ Performance & Optimization/🗄️ Database Tuning
반응형

BigQuery Storage Read API와 DATE 타입 불일치 해결하기

들어가며

최근 프로젝트에서 BigQuery의 성능 개선을 위해 Storage Read API를 도입하는 과정에서 흥미로운 문제를 만났습니다. GoogleSQL의 DATE 타입과 Apache Avro의 DATE 타입 간의 불일치로 인해 예상치 못한 데이터 변환 이슈가 발생했는데요, 이를 해결하는 과정을 공유해보려 합니다.

왜 Storage Read API인가?

BigQuery에서 데이터를 읽는 방법은 크게 두 가지가 있습니다:

구분 Legacy API Storage Read API
통신 방식 REST 기반 gRPC 기반
데이터 형식 JSON Apache Avro/Arrow
처리 방식 순차적 페이지네이션 병렬 스트림
성능 기준 약 20-30% 개선
메모리 기준 상당한 절약

Storage Read API는 특히 대용량 데이터 처리에서 뛰어난 성능을 보여주는데, 바이너리 직렬화와 병렬 스트림 처리가 핵심입니다.

Apache Avro를 선택한 이유

BigQuery Storage Read API는 Apache Avro와 Apache Arrow 두 가지 직렬화 포맷을 지원합니다. 저희가 Avro를 선택한 이유는:

  • 스키마 기반: 데이터와 스키마가 함께 저장되어 타입 안전성 보장
  • 언어 호환성: Node.js 환경에서 안정적인 라이브러리 지원
  • 네트워크 효율성: 컴팩트한 바이너리 형식으로 전송량 최소화

문제 발견: 날짜가 숫자로?

Storage Read API를 적용하고 테스트를 돌려보니 예상치 못한 결과가 나왔습니다.

// Legacy API 결과
console.log('Legacy API date:', result.event_date.value); // "2025-06-29"

// Storage Read API 결과  
console.log('Storage Read API date:', result.event_date); // 20268

날짜가 문자열이 아닌 숫자로 나오는 것이었습니다! 😱

근본 원인 분석

문제의 원인은 두 API 간의 DATE 타입 표현 방식 차이였습니다:

  • Legacy API: BigQueryDate 객체 {value: "YYYY-MM-DD"}
  • Storage Read API: Avro DATE 타입으로 1970-01-01부터의 일수 (epoch days)

즉, 20268이라는 숫자는 1970년 1월 1일부터 20,268일 후인 2025년 6월 29일을 의미하는 것이었습니다.

해결 과정

1단계: DATE 필드 자동 감지

먼저 AVRO 스키마에서 DATE 타입 필드를 자동으로 찾는 함수를 만들었습니다:

/**
 * AVRO 스키마에서 DATE 타입 필드인지 확인
 */
function isAvroDateField(fieldType: unknown): boolean {
  // Union type 처리 (["null", { "type": "int", "logicalType": "date" }])
  if (Array.isArray(fieldType)) {
    return fieldType.some((type) => isAvroDateField(type));
  }

  // Record type 처리 ({ type: "int", logicalType: "date" })
  if (typeof fieldType === 'object' && fieldType !== null) {
    const field = fieldType as { type?: string; logicalType?: string };
    return field.type === 'int' && field.logicalType === 'date';
  }

  return false;
}

/**
 * AVRO 스키마에서 모든 DATE 필드 찾기
 */
function findDateFieldsInAvroSchema(schema: any): Set<string> {
  const dateFields = new Set<string>();

  function traverse(schemaNode: any): void {
    if (schemaNode?.type === 'record' && schemaNode?.fields) {
      schemaNode.fields.forEach((field: any) => {
        if (isAvroDateField(field.type)) {
          dateFields.add(field.name);
        }
        traverse(field.type);
      });
    } else if (Array.isArray(schemaNode)) {
      schemaNode.forEach(item => traverse(item));
    }
  }

  traverse(schema);
  return dateFields;
}

2단계: Epoch Days 변환 유틸리티

다음으로 epoch days를 BigQuery DATE 형식으로 변환하는 함수를 구현했습니다:

const UNIX_EPOCH = new Date('1970-01-01T00:00:00.000Z');
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;

/**
 * Epoch days를 YYYY-MM-DD 문자열로 변환
 */
function convertEpochDaysToDateString(epochDays: number): string {
  const date = new Date(
    UNIX_EPOCH.getTime() + epochDays * MILLISECONDS_PER_DAY
  );
  return date.toISOString().split('T')[0];
}

/**
 * Epoch days를 BigQueryDate 객체로 변환
 */
function convertEpochDaysToBigQueryDate(epochDays: number) {
  const dateString = convertEpochDaysToDateString(epochDays);
  return { value: dateString };
}

/**
 * 데이터에서 DATE 필드들을 BigQueryDate 형태로 변환
 */
function convertDateFieldsInData<T>(data: T, dateFields: Set<string>): T {
  if (Array.isArray(data)) {
    return data.map(item => convertDateFieldsInData(item, dateFields)) as T;
  } 

  if (data && typeof data === 'object' && data !== null) {
    const newObj: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(data)) {
      if (dateFields.has(key) && typeof value === 'number') {
        newObj[key] = convertEpochDaysToBigQueryDate(value);
      } else {
        newObj[key] = convertDateFieldsInData(value, dateFields);
      }
    }

    return newObj as T;
  }

  return data;
}

3단계: 필드명 매핑 문제 해결

추가로 발견한 문제가 하나 더 있었습니다. 30d, 1d 같은 숫자로 시작하는 필드명이 _30d, _1d로 변환되는 것이었습니다.

/**
 * 숫자로 시작하는 필드명에 _ prefix 추가
 */
function fixAvroSchemaFieldNames(schema: any): any {
  if (schema?.type === 'record' && schema?.fields) {
    const newFields = schema.fields.map((field: any) => {
      const newField = { ...field };
      if (/^\d/.test(newField.name)) {
        newField.name = `_${newField.name}`;
      }
      newField.type = fixAvroSchemaFieldNames(newField.type);
      return newField;
    });
    return { ...schema, fields: newFields };
  }

  if (Array.isArray(schema)) {
    return schema.map(item => fixAvroSchemaFieldNames(item));
  }

  return schema;
}

/**
 * 변환된 필드명을 원래대로 복원
 */
function restoreOriginalFieldNames<T>(data: T): T {
  if (Array.isArray(data)) {
    return data.map(item => restoreOriginalFieldNames(item)) as T;
  }

  if (data && typeof data === 'object' && data !== null) {
    const newObj: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(data)) {
      let newKey = key;
      if (key.startsWith('_') && /^\d/.test(key.substring(1))) {
        newKey = key.substring(1);
      }
      newObj[newKey] = restoreOriginalFieldNames(value);
    }

    return newObj as T;
  }

  return data;
}

4단계: 통합 구현

모든 변환 로직을 통합한 Storage Read API 구현:

async function readTableWithStorageApi(params: {
  tableName: string;
  selectedFields?: string[];
  rowRestriction?: string;
}): Promise<unknown[]> {
  const { tableName, selectedFields, rowRestriction } = params;

  try {
    // 1. Read Session 생성
    const [session] = await bigqueryReadClient.createReadSession({
      parent: `projects/${projectId}`,
      readSession: {
        table: `projects/${projectId}/datasets/dataset/tables/${tableName}`,
        dataFormat: 'AVRO',
        readOptions: {
          ...(selectedFields && { selectedFields }),
          ...(rowRestriction && { rowRestriction }),
        },
      },
      maxStreamCount: 1,
    });

    if (!session.streams?.length) {
      return [];
    }

    // 2. Avro 스키마 파싱 및 DATE 필드 감지
    let schema = JSON.parse(session.avroSchema.schema);
    const dateFields = findDateFieldsInAvroSchema(schema);

    // 필드명 변환 적용
    schema = fixAvroSchemaFieldNames(schema);
    const avroType = avro.Type.forSchema(schema);

    // 3. 스트리밍 데이터 읽기
    const results: unknown[] = [];

    for (const stream of session.streams) {
      const readStream = bigqueryReadClient.readRows({
        readStream: stream.name,
        offset: 0,
      });

      await new Promise<void>((resolve, reject) => {
        readStream
          .on('error', reject)
          .on('data', (response: any) => {
            if (response.avroRows?.serializedBinaryRows) {
              try {
                let pos;
                do {
                  const decodedData = avroType.decode(
                    Buffer.from(response.avroRows.serializedBinaryRows),
                    pos
                  );
                  if (decodedData.value) {
                    results.push(decodedData.value);
                  }
                  pos = decodedData.offset;
                } while (pos > 0);
              } catch (error) {
                console.error('Decode error:', error);
              }
            }
          })
          .on('end', resolve);
      });
    }

    // 4. DATE 필드 변환 및 필드명 복원
    const convertedResults = convertDateFieldsInData(results, dateFields);
    const restoredResults = restoreOriginalFieldNames(convertedResults);

    return restoredResults;
  } catch (error) {
    console.error('Storage Read API error:', error);
    throw error;
  }
}

5단계: 안전한 Fallback 패턴

마지막으로 Storage Read API 실패 시 Legacy API로 자동 전환하는 안전장치를 구현했습니다:

async function queryBigQueryData(params: QueryParams): Promise<unknown[]> {
  // Storage Read API 우선 시도
  try {
    console.log('[STORAGE-READ] Attempting to use Storage Read API');
    return await readTableWithStorageApi(params);
  } catch (error) {
    console.error('[STORAGE-READ] Failed:', error);
    console.warn('[FALLBACK] Using legacy BigQuery API');

    // Legacy API로 자동 전환
    return await queryWithLegacyApi(params);
  }
}

async function queryWithLegacyApi(params: QueryParams): Promise<unknown[]> {
  const query = `SELECT * FROM \`${params.tableName}\` WHERE ${params.rowRestriction}`;
  const [rows] = await bigquery.query(query);
  return rows;
}

테스트 및 검증

구현한 솔루션을 검증하기 위해 단위 테스트를 작성했습니다:

describe('BigQuery Storage Read API Date 변환', () => {
  test('Epoch days를 날짜 문자열로 변환', () => {
    const epochDays = 20268; // 2025-06-29
    const result = convertEpochDaysToDateString(epochDays);
    expect(result).toBe('2025-06-29');
  });

  test('BigQueryDate 객체로 변환', () => {
    const epochDays = 20268;
    const result = convertEpochDaysToBigQueryDate(epochDays);
    expect(result).toEqual({ value: '2025-06-29' });
  });

  test('AVRO DATE 필드 식별', () => {
    const dateFieldType = { type: 'int', logicalType: 'date' };
    const stringFieldType = 'string';

    expect(isAvroDateField(dateFieldType)).toBe(true);
    expect(isAvroDateField(stringFieldType)).toBe(false);
  });

  test('필드명 복원', () => {
    const avroData = [{ _30d: 100, _1d: 50, name: 'test' }];
    const result = restoreOriginalFieldNames(avroData);
    expect(result[0]).toEqual({ '30d': 100, '1d': 50, name: 'test' });
  });
});

성능 개선 결과

최종적으로 다음과 같은 성능 개선을 달성했습니다:

항목 Legacy API Storage Read API 개선율
응답 시간 5.3초 4.1초 23% ↑
메모리 사용량 192MB 절약됨 상당한 개선
데이터 일치성 ✅ ✅ 완벽 일치

핵심 해결 포인트

이번 문제 해결 과정에서 얻은 핵심 인사이트들:

1. 자동 DATE 필드 감지

  • AVRO 스키마를 파싱하여 logicalType: "date" 필드 자동 식별
  • Union 타입과 Record 타입 모두 지원하는 재귀적 탐색

2. 정확한 Epoch Days 변환

  • UTC 기준 1970-01-01부터의 일수 계산
  • BigQueryDate 객체 형식으로 변환하여 기존 코드와 호환성 보장

3. 필드명 매핑 처리

  • 숫자로 시작하는 필드명의 _ 접두사 자동 처리
  • 원본 필드명으로 복원하여 기존 코드 영향 최소화

4. 안정적인 Fallback

  • Storage Read API 실패 시 자동으로 Legacy API 사용
  • 서비스 중단 없이 점진적 마이그레이션 가능

개발자를 위한 권장사항

1. 데이터 타입 사전 검증

새로운 API 도입 시 다음 사항을 미리 확인하세요:

  • DATE, TIMESTAMP 등 복잡한 타입의 실제 반환 형태
  • 숫자로 시작하는 필드명의 변환 여부
  • NULL 값 처리 방식

2. 점진적 도입 전략

// 환경 변수로 API 방식 제어
const useStorageReadApi = process.env.USE_STORAGE_READ_API === 'true';

async function queryData(params: QueryParams) {
  if (useStorageReadApi) {
    try {
      return await readTableWithStorageApi(params);
    } catch (error) {
      console.warn('Storage Read API 실패, Legacy API로 전환');
      return await queryWithLegacyApi(params);
    }
  }

  return await queryWithLegacyApi(params);
}

3. 충분한 테스트와 모니터링

// 성능 모니터링
const startTime = Date.now();
const result = await queryData(params);
const duration = Date.now() - startTime;

console.log(`쿼리 실행 시간: ${duration}ms`);
console.log(`반환된 로우 수: ${result.length}`);

마무리

BigQuery Storage Read API 도입 과정에서 만난 DATE 타입 불일치 문제는 처음에는 당황스러웠지만, 차근차근 분석하고 해결해나가는 과정에서 많은 것을 배울 수 있었습니다.

특히 서로 다른 시스템 간의 데이터 타입 호환성이 얼마나 중요한지, 그리고 점진적 마이그레이션과 안전장치가 얼마나 필요한지 깨달았습니다.

이런 경험이 비슷한 문제를 겪고 있는 다른 개발자들에게 도움이 되기를 바랍니다. 혹시 질문이나 더 나은 해결 방법이 있다면 언제든 댓글로 공유해주세요! 🚀


참고 자료

  • BigQuery Storage Read API 공식 문서
  • Apache Avro 스펙
  • BigQuery 데이터 타입
반응형

'⚡ Performance & Optimization > 🗄️ Database Tuning' 카테고리의 다른 글

TypeORM Gzip을 활용한 데이터 압축 및 최적화  (0) 2025.04.17
'⚡ Performance & Optimization/🗄️ Database Tuning' 카테고리의 다른 글
  • TypeORM Gzip을 활용한 데이터 압축 및 최적화
KilPenguin
KilPenguin
penguin-dev 님의 블로그 입니다.
    반응형
  • KilPenguin
    Penguin Dev
    KilPenguin
  • 전체
    오늘
    어제
    • 분류 전체보기 (41)
      • 🏗️ Architecture & Design (2)
        • 📐 Clean Architecture (2)
        • 🔄 Design Patterns (0)
      • ⚡ Performance & Optimizatio.. (4)
        • 🗄️ Database Tuning (2)
        • 🚀 Caching Strategy (1)
        • 🖥️ Server Optimization (1)
      • 💻 Backend Development (9)
        • 🔒 Concurrency Control (5)
        • 🌱 Spring Framework (3)
        • 📨 Event-Driven Architecture (0)
        • ☕ Java Fundamentals (1)
      • 🔧 Dev Tools & Environment (4)
        • 🔄 Version Control (2)
        • 📝 Documentation Tools (1)
        • 🎨 Blog Setup (1)
      • 📈 Career & Growth (21)
        • 🎓 Learning Journey (15)
        • 🎤 Conference & Community (6)
      • 🎯 Personal (1)
        • 👋 Introduction (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    항해플러스백엔드
    판교퇴근길밋업
    항해플러스
    항해솔직후기
    인프런
    개발바닥밋업
    항해99
    항해플러스후기
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
KilPenguin
BigQuery Storage Read API와 DATE 타입 불일치 해결하기
상단으로

티스토리툴바