3. REQUIRED Set by the First Load

테이블을 처음 만든 실행이 정한 NOT NULL이 몇 주 뒤에 터진 이야기

  • Haram Lee
  • 2026-09-16
  • studies / Topics / Data Loading

아무것도 바꾸지 않았는데 왜 갑자기 실패할까?

  • BigQuery 테이블의 REQUIRED 모드는 누가 정할까?
  • 왜 몇 주 동안 멀쩡하다가 하루아침에 터질까?
  • 이미 REQUIRED인 컬럼은 어떻게 풀까?

3.1 The Symptom

MongoDB 컬렉션을 Spark로 읽어 BigQuery에 덮어쓰는 적재 작업이 있었다. 몇 시간 전 실행은 성공했는데 다음 실행이 재시도까지 같은 자리에서 실패했다. 드라이버 로그의 예외는 이랬다.

text
BigQueryConnectorException: Failed to write to BigQuery
Caused by: BigQueryException: Required field profile cannot be null

MongoDB 읽기, 변환, GCS에 중간 파일 쓰기까지는 끝났고, 마지막 BigQuery load job에서 거부됐다. 코드도 설정도 바뀌지 않았다. 설정에는 profile이 nullable struct로 분명히 적혀 있었다.

3.2 The Load Job Follows the Existing Table

대상 테이블의 스키마를 보니 컬럼 열여섯 개 중 RECORD 두 개만 REQUIRED였고, 그 안의 하위 필드는 전부 NULLABLE이었다.

성공한 실행과 실패한 실행의 load job 설정을 로그에서 비교해 보니 스키마가 완전히 같았다. 둘 다 profile이 mode=REQUIRED였다. 설정은 nullable인데 load job은 REQUIRED라는 것은, 커넥터가 기존 테이블의 모드를 그대로 유지한다는 뜻이다. 테이블이 이미 있으면 load job은 그 테이블의 스키마를 요구 스키마로 쓴다.

바뀐 것은 데이터였다. 두 실행 사이에 profile 하위 문서가 아예 없는 문서가 처음 들어왔다. 그럼 REQUIRED는 언제, 누가 만들었을까.

3.3 Where REQUIRED Came From

테이블이 생성된 시각에 돈 첫 load job을 로그에서 찾았다. 그 job이 이미 profile을 REQUIRED로 보냈다. 지금의 적재 작업이 아니라, 그 전에 같은 테이블을 만들던 예전 덤프 작업이었다.

예전 덤프 작업에는 정리 단계가 하나 있었다. 표본에서 값이 전부 null이라 Spark가 NullType으로 추론한 중첩 필드를 지우는 단계다. 하위 필드를 하나라도 지우면 그 struct를 남은 필드로 다시 조립한다. 문제는 여기서 생긴다.

flowchart TD
  sample["표본 추론<br/>profile.x 가 전부 null → NullType"] --> drop["NullType 하위 필드 제거"]
  drop --> rebuild["struct(남은 필드...) 로 재조립"]
  rebuild --> nn["Spark struct() 결과는<br/>nullable = false"]
  nn --> req["spark-bigquery 커넥터가<br/>REQUIRED 컬럼으로 테이블 생성"]
  • Spark의 struct() 함수가 만든 컬럼은 nullable이 false다. 이어서 cast를 해도 바뀌지 않는다.
  • spark-bigquery 커넥터는 nullable이 false인 컬럼을 REQUIRED로 만든다.
  • 하위 필드의 nullable은 cast 대상 스키마에서 오므로 안쪽은 NULLABLE로 남는다.

테이블이 딱 그 모양이었다. 바깥 RECORD만 REQUIRED이고 안쪽은 NULLABLE이다. 같은 데이터를 다른 환경에서 적재한 테이블과 중첩 스키마를 비교하니 그 흔적도 보였다. 한쪽에만 있는 하위 필드가 있었는데, 표본에서 전부 null이라 잘려 나간 필드였다. 그 필드가 잘리면서 struct가 재조립되고 REQUIRED가 됐다.

3.4 Why It Took Weeks to Break

재조립된 struct는 문서에 하위 문서가 없어도 null이 되지 않는다. 필드가 전부 null인 struct가 될 뿐이다. 그래서 예전 덤프 작업이 매번 재조립을 하는 동안에는 REQUIRED 테이블에 아무 문제가 없었다.

새 적재 작업은 스키마를 명시적으로 받았다. 타입이 고정되니 NullType이 생기지 않고, 정리 단계는 아무것도 재조립하지 않는다. 그러면 profile은 원래대로 nullable이고, 문서에 없으면 진짜 NULL이 실린다. 실패하려면 두 조건이 같이 필요했다.

  1. 기존 테이블이 REQUIRED다.
  2. 하위 문서가 통째로 빠진 문서가 들어온다.

두 번째 조건이 몇 주 뒤에야 처음 생겼다. 테스트 데이터가 많은 환경일수록 표본에 빈 필드가 흔하고 하위 문서를 빼먹은 문서도 흔해서 조건이 맞기 쉽다.

같은 함정이 다른 테이블에도 있는지 찾아봤다. INFORMATION_SCHEMA.COLUMNS에서 NOT NULL인 STRUCT 컬럼을 뽑으면 된다.

sql
SELECT table_schema, table_name, column_name
FROM `my_project.region-us.INFORMATION_SCHEMA.COLUMNS`
WHERE is_nullable = 'NO' AND data_type LIKE 'STRUCT%'
ORDER BY 1, 2;

한 건이 더 나왔다. 이 테이블은 스키마 없이 추론으로 돈 실행 단 한 번이 테이블을 다시 만들면서 REQUIRED가 됐다. 앞뒤 실행은 스키마가 있었는데 그 한 번만 빠졌고, 그 한 번이 CREATE_IF_NEEDED로 스키마를 굳혔다. 아직 해당 struct가 빠진 문서가 없어서 터지지 않았을 뿐이다.

3.5 Relaxing REQUIRED

먼저 DDL을 시도했다.

sql
ALTER TABLE my_dataset.users ALTER COLUMN profile DROP NOT NULL;

거부됐다. 메시지는 Cannot DROP NOT NULL constraint from column profile which does not have a NOT NULL constraint였다. RECORD 컬럼의 REQUIRED 모드는 이 DDL이 다루는 제약이 아니다.

BigQuery 문서의 방법대로 스키마 JSON을 내려받아 모드를 바꾸고 다시 올렸다. REQUIRED에서 NULLABLE로 가는 완화는 허용되는 변경이고, 데이터에는 영향이 없다.

bash
bq show --schema --format=prettyjson my_project:my_dataset.users > schema.json
sed -i '' 's/"mode": "REQUIRED"/"mode": "NULLABLE"/g' schema.json
bq update my_project:my_dataset.users schema.json

bq show로 다시 읽어 REQUIRED가 0건인지 확인하고 실패한 실행을 다시 돌렸다. 새 load job은 profile을 NULLABLE로 보냈고 정상 종료했다.

재발을 막으려면 순서가 중요하다. struct를 재조립할 때 원본이 null이면 null을 유지하도록 고치면 새 테이블은 REQUIRED로 생기지 않는다. 하지만 이미 REQUIRED인 테이블이 남아 있는 상태에서 이것부터 고치면, 진짜 NULL이 실리기 시작해 그 테이블이 바로 터진다. 그래서 REQUIRED를 먼저 완화하고 코드는 그다음에 고친다.

3.6 Two More Schema Traps

같은 시기에 스키마 때문에 걸린 것이 두 가지 더 있다.

Parquet으로는 네이티브 JSON 컬럼이 만들어지지 않았다. 로그를 압축률 좋은 Parquet으로 바꿔 보자고 했다가, 스키마에 JSON 타입을 지정해도 Parquet 로드로는 네이티브 JSON 컬럼이 만들어지지 않는 것을 확인했다. 같은 데이터를 gzip JSONL로 두고 스키마를 지정하면 JSON 컬럼으로 들어갔다. Parquet을 쓴다면 지정하는 스키마의 타입과 모드가 파일의 물리 타입과 정확히 맞아야 한다는 것도 이때 알았다. 덧붙여 로그처럼 레코드마다 필드가 빠질 수 있는 데이터는 파티션 컬럼 정도만 REQUIRED로 두고 나머지는 NULLABLE로 두는 편이 안전하다. 레코드 하나가 필드를 빠뜨리면 load job 전체가 실패하기 때문이다.

파티션 데코레이터는 컬럼 파티셔닝과 쓸 때 조건이 붙는다. 매번 테이블 전체를 다시 쓰던 적재를 table$YYYYMMDD 데코레이터로 날짜 파티션 한 칸만 덮어쓰도록 바꾸다가, 컬럼 파티셔닝(time_partitioning.field)과 데코레이터를 같이 쓰면 문제가 된다는 리뷰를 받았다. 문서로 확인한 정확한 규칙은 이렇다. 수집 시간 파티셔닝 테이블은 데코레이터로 어느 파티션에든 쓸 수 있다. 시간 컬럼 파티셔닝 테이블도 데코레이터를 쓸 수 있지만, 데코레이터의 파티션과 쓰는 행의 컬럼 값이 일치해야 하고 아니면 오류가 난다. 어느 파티셔닝을 쓰는지 먼저 정하고, 컬럼 파티셔닝이라면 데코레이터가 행의 값과 어긋나지 않게 해야 한다.

세 가지 모두 같은 교훈으로 묶인다. BigQuery 테이블의 스키마와 파티셔닝은 처음 만들 때 정해지고, 이후 load job은 그것을 따른다. 테이블을 누가, 어떤 설정으로 처음 만들었는지가 몇 주 뒤의 실패를 정한다.

Discussion