4. Arrays of Arrays

repeated는 타입이 아니라 필드의 성질이라 BigQuery가 받지 못하는 모양

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

배열 안의 struct는 되는데 배열 안의 배열은 왜 안 될까?

  • 이 제약은 저장 인코딩에서 올까, 타입 체계에서 올까?
  • Parquet은 같은 인코딩을 쓰는데 왜 배열의 배열을 담을 수 있을까?
  • 적재 경로에서는 어디서 막힐까?

4.1 The Question

MongoDB 컬렉션을 BigQuery로 옮기다 보면 몇몇 필드를 “제외 컬럼"으로 빼고 적재하는 경우가 있다. 처음에는 배열 안에 struct가 있어서 적재가 안 된다고 들었다. 그런데 같은 컬렉션의 다른 필드는 배열 안에 struct가 있고 그 struct 안에 다시 배열이 있는데도 잘 들어갔다.

실제 문서를 확인해 보니 빠진 필드는 GeoJSON Polygon과 경로 데이터였다. Polygon의 좌표는 링의 배열이고, 링은 좌표쌍의 배열이고, 좌표쌍은 다시 숫자의 배열이다. 배열이 세 겹으로 바로 겹친다.

json
{
  "type": "Polygon",
  "coordinates": [
    [[127.01, 37.50], [127.02, 37.50], [127.02, 37.51], [127.01, 37.50]]
  ]
}

막히는 것은 배열 안의 struct가 아니라 배열 안의 배열이었다. BigQuery 문서도 그렇게 적는다. “An array cannot contain another array directly.” 그리고 대안도 준다. “GoogleSQL doesn’t support building arrays of arrays directly. Instead, you must create an array of structs, with each struct containing a field of type ARRAY.”

왜 struct를 하나 끼우면 될까. 답은 BigQuery의 원형인 Dremel의 데이터 모델에 있다.

4.2 Repeated Is a Property of a Named Field

Dremel 논문은 데이터 모델을 Protocol Buffers의 강타입 중첩 레코드로 정의한다. 논문 3장에 나온 추상 문법이 가장 분명하다.

τ = dom | ⟨A₁ : τ[∗|?], . . . , Aₙ : τ[∗|?]⟩

Field i in a record has a name Aᵢ and an optional multiplicity label. Repeated fields (∗) may occur multiple times in a record.

반복 표시 ∗가 붙는 자리는 Aᵢ : τ, 즉 이름이 있는 필드다. 타입 τ 자체에 ∗를 붙이는 생성 규칙은 없다. 그래서 이 모델에서는 “배열인 타입"을 만들 수 없고 “배열인 필드"만 만들 수 있다. 배열의 배열은 반복 지점이 둘인데 그 사이에 필드가 없으니 안쪽 ∗를 붙일 자리가 없다.

protobuf 문법도 같은 모양이다.

text
field = [ "repeated" | "optional" ] type fieldName "=" fieldNumber ... ";"

repeated는 field의 접두사이고 한 번만 올 수 있으며, field에는 이름이 필수다. repeated repeated int64 coords = 1;을 쓸 수 없는 이유가 문법에 그대로 있다.

flowchart LR
  subgraph no["배열의 배열: 안 됨"]
    a1["coordinates *"] --> a2["(이름 없음) *"] --> a3["FLOAT64"]
  end
  subgraph yes["struct를 끼운 경우: 됨"]
    b1["rings *"] --> b2["positions *"] --> b3["FLOAT64"]
  end

이름은 문법 형식만의 문제가 아니다. 중첩 깊이를 표현하는 수단이다. Dremel의 컬럼 저장은 값마다 repetition level을 붙인다. 이 값은 “경로에 있는 repeated 필드 중 어디에서 반복됐는가"를 정수로 적는다. 논문의 예에서 Name.Language.Code의 repetition level이 0부터 2까지인 것은 경로에 repeated 필드가 Name과 Language 둘이기 때문이다. 이름 있는 repeated 필드의 수가 곧 구분할 수 있는 중첩 단계의 수다. Polygon은 세 겹인데 이름은 coordinates 하나뿐이니 세 단계에 번호를 매길 방법이 없다.

BigQuery의 타입 체계는 이 모델을 거의 그대로 노출한다. REPEATED는 필드의 mode이고, 타입은 스칼라 아니면 RECORD다. struct가 안쪽 배열이 붙을 이름을 만들어 주기 때문에 ARRAY<STRUCT<x ARRAY<T>>>는 되고 ARRAY<ARRAY<T>>는 안 된다.

4.3 Not an Encoding Limit

repetition level과 definition level 인코딩 자체가 배열의 배열을 못 담는 것은 아니다. 같은 인코딩을 쓰는 Parquet은 LIST를 항상 세 단으로 적는다. 명세에 실린 예시가 바로 배열의 배열이다.

text
// List<List<Integer>>
optional group array_of_arrays (LIST) {
  repeated group list {
    required group element (LIST) {
      repeated group list {
        required int32 element;
      }
    }
  }
}

list와 element가 Dremel 모델이 요구하는 이름이다. Parquet은 포맷이 이 이름을 대신 지어 주고, BigQuery는 사용자가 struct로 직접 만들게 한다. Avro는 아예 다른 길을 골랐다. array가 타입이고 items에 다시 array 스키마를 넣을 수 있다. 반복이 필드의 라벨이 아니라 타입이면 이름 문제가 생기지 않는다.

그래서 이 제약은 인코딩이 아니라 타입 체계의 선택이다. 커넥터를 바꿔도 사라지지 않는다. 같은 데이터가 GCS에 Parquet으로는 남을 수 있고, BigQuery 스키마로 바꾸는 순간 막힌다.

4.4 Where the Pipeline Breaks

Spark에서 BigQuery로 쓰는 경로에서는 spark-bigquery 커넥터가 Spark 스키마를 BigQuery 스키마로 바꾸는 단계에서 막힌다. SchemaConverters.createBigQueryColumn은 이렇게 생겼다.

java
if (sparkType instanceof ArrayType) {
  ArrayType arrayType = (ArrayType) sparkType;
  fieldMode = Field.Mode.REPEATED;
  sparkType = arrayType.elementType();
}
// ... StructType이면 RECORD, MapType, DecimalType 분기 ...
} else {
  fieldType = toBigQueryType(sparkType, sparkField.metadata());
}

배열을 한 겹 벗겨 mode를 REPEATED로 두고 원소 타입을 본다. 원소가 다시 ArrayType이면 어느 분기에도 걸리지 않고 toBigQueryType으로 떨어져 Data type not expected: array<double> 예외로 끝난다. 배열의 배열을 위한 분기 자체가 없다. 직접 쓰기든 GCS를 거치는 간접 쓰기든 같은 변환을 먼저 부르므로 경로에 따른 차이는 없다.

4.5 Loading Without Dropping the Field

필드를 통째로 빼지 않는 방법은 두 가지가 있다.

하나는 그 필드를 문자열로 고정해 JSON 문자열로 받는 것이다. 쿼리할 때 JSON 함수로 꺼내야 한다.

다른 하나는 이 장의 논리를 그대로 따르는 것이다. 배열 사이에 struct를 끼워 Dremel 모델이 요구하는 이름을 우리가 만들어 준다. Spark SQL의 transform 고차 함수로 각 단계를 감싸면 된다.

sql
SELECT
  * EXCEPT (boundary),
  struct(
    boundary.type AS type,
    transform(
      boundary.coordinates,
      ring -> struct(transform(ring, pt -> struct(pt AS position)) AS positions)
    ) AS rings
  ) AS boundary
FROM source

결과 타입은 rings: array<struct<positions: array<struct<position: array<double>>>>>이 되어 배열이 항상 struct 바로 아래에 온다. BigQuery에서 UNNEST로 좌표를 꺼낼 수 있다. 대신 쿼리할 때 한 겹을 더 벗겨야 한다. 두 방법 모두 아직 실제 적재로 돌려 보지는 않았다.

4.6 One More Constraint from the Same Root

같은 뿌리에서 나오는 것으로 보이는 제약이 하나 더 있다. protobuf의 repeated는 “값이 0개 이상 있다"는 뜻일 뿐이고 “비어 있는 원소"라는 개념이 없다. BigQuery가 쿼리 결과 배열에 NULL 원소가 있으면 오류를 내는 것도 이와 맞닿아 있다.

배열은 비교에서도 까다롭다. BigQuery의 ARRAY는 groupable이 아니어서 EXCEPT DISTINCT, SELECT DISTINCT, GROUP BY에 쓸 수 없다. 원본과 적재 결과를 대조할 때 배열 컬럼이 하나라도 있으면 컬럼을 나열해 비교하는 방식이 처음부터 막힌다. TO_JSON_STRING으로 행을 문자열 하나로 접으면 비교할 수 있지만, 이 문자열은 원소 순서와 STRUCT 필드 순서에 민감하다. 중첩이 깊어지면 안쪽 배열부터 정렬해 직렬화해야 한다. 깊이가 문서마다 다르면 키를 정렬하고 배열을 정규화하는 재귀를 JavaScript UDF에 맡기고, 그 결과를 FARM_FINGERPRINT로 해시해 비교한다. 배열이 필드의 성질이라는 같은 사실이 적재와 검증 양쪽에서 비용으로 돌아온다.

Discussion