답사기 · 제6장 정독

함 속에
함이 있다

석탑을 해체하면 사리공 안에서 외함이 나오고, 외함을 열면 내함이 나오고, 내함을 열면 사리병이 나온다. JSON을 읽는 일이 이와 다르지 않다. 중괄호를 열면 그 안에 또 중괄호가 있고, 그 안에 또 있다. 제6장은 그 겹을 하나씩 여는 손놀림에 관한 장이다.

원전
DuckDB: Up and Running
지은이
Wei-Meng Lee
펴낸곳
O'Reilly Media, 2024
대상
Chapter 6. Using DuckDB with JSON Files
JSON2_A.JSON — 세 겹으로 든 함 { 외함 · 객체 하나 "id": 1 "name": "Sarah Johnson" "address": { 내함 "line1": "4321 Oak Street Apartment" "line2": "304 Los Angeles" "location": { 사리병 "state": "CA" · "city": "Calexico" · "zip": 90001 } } "email": "sarah_johnson478@gmail.com" "weight": 140.50 겹을 여는 두 개의 손 ① 대괄호 — 함을 한 겹씩 연다 address['line1'] 내함까지 address['location']['state'] 사리병까지 ② unnest() — 배열을 행으로 눕힌다 "people": [ {…}, {…}, {…} ] unnest 행 1 · {…} 행 2 · {…} 행 3 · {…} 배열은 눕히고, 객체는 연다. 이 두 동작의 조합이 제6장의 문법 전부다. 그 밖의 것은 어느 함수로 파일을 집어 드는가의 문제일 뿐이다.

답사의 손놀림. 제1장이 기둥의 장, 제2장이 문의 장, 제3장이 칸의 장, 제4장이 때의 장, 제5장이 자리의 장이었다면 제6장은 겹의 장이다. 왼쪽은 json2_a.json의 첫 객체를 단면으로 옮긴 것이고, 오른쪽은 그 겹에 손을 넣는 두 가지 방법이다. 사리장엄구를 해체하는 발굴자가 외함·내함·사리병을 순서대로 여는 일과, 대괄호를 한 겹씩 이어 붙이는 일은 같은 종류의 조심스러움을 요구한다.

JSON이 왜 한 장을 따로 받는가

WHY JSON DESERVES A DEDICATED CHAPTER

제2장에서 CSV, Parquet, 엑셀 파일, MySQL 데이터베이스 같은 여러 형식의 데이터를 DuckDB로 들여오는 법을 익혔다. 개발자들 사이에서 널리 쓰이는 또 하나의 중요한 파일 형식이 JSON(JavaScript Object Notation)이다.

JSON의 두드러진 특징 하나는 유연하고 동적인 구조다. 따라야 할 고정된 스키마가 없고, 동적인 키·값 쌍으로 자기 데이터를 표현할 수 있다. DuckDB에서 JSON 파일을 다루는 기법은 여럿이며, 그런 까닭에 JSON은 한 장을 따로 받을 만하다.

제2장에서 네 개의 문을 지날 때, 문턱이 가장 높았던 것은 엑셀이었다. 그런데 JSON은 문턱의 높낮이가 문제가 아니다. 문 안쪽의 구조가 파일마다 다르다는 것이 문제다. CSV는 어느 파일이든 행과 열이지만, JSON은 같은 확장자를 달고도 배열일 수 있고 줄바꿈으로 나뉜 낱개일 수 있고 세 겹으로 든 함일 수 있다. 그래서 이 장은 형식을 소개하는 장이 아니라 구조를 판별하고 그에 맞는 함수를 고르는 장이다.

이 장에서 살필 적재 수단은 셋이다.

  • read_json_auto() 가장 직접적인 방법. 늘 이것을 먼저 시도한다.
  • read_json() 자동 감지가 실패할 때 형식과 스키마를 손으로 지정한다.
  • COPY-FROM 큰 데이터셋에 유리하다. 다만 표를 먼저 손으로 지어야 한다.
검토 방법에 관한 밝힘

이 장은 다행히 결과의 상당 부분이 DuckDB CLI의 박스 출력파일 내용의 인용으로 지면에 인쇄되어 있다. 그런 값은 그대로 옮겼다. 도판 이미지에만 있는 표는 값을 옮기지 않고, 원서 본문이 그 도판에서 무엇을 지적했는지를 적었다.

표기 규칙은 앞 다섯 장과 같다. 문맥으로 복원되는 부분은 점선 밑줄, 복원하지 않은 부분은 , 검토자가 다른 곳의 인쇄분과 대조해 채운 값은 표에서 점선을 두른 글자로 표시했다.

아울러 이 권만 강조색이 다르다. 구조색으로 금동(金銅)의 호박빛을 썼으므로, 앞 권들에서 강조로 쓰던 치자금을 먹청으로 바꿨다.

JSON의 여섯 가지 그릇

PRIMER ON JSON

JSON은 사람이 읽고 해석하기 쉽고 컴퓨터가 조작하고 생성하기에 매우 효율적인, 가벼운 데이터 표현 형식이다. 지원하는 데이터 유형은 여섯이다.

型 · 一
객체
object

중괄호 한 쌍으로 감싼, 순서 없는 키·값 쌍의 모음이다. 키·값 쌍이 하나도 없는 객체도 가능하며 이를 빈 객체라 한다.

{ "key": "value" } {}
型 · 二
문자열
string

객체의 키는 반드시 문자열이어야 하며, 값은 문자열·불리언·숫자·배열·null 또는 또 다른 객체가 될 수 있다. 각 키·값 쌍은 쉼표로 나눈다. 마지막 쌍 뒤에는 쉼표가 필요하지 않다.

{ "firstName": "John", "lastName": "Doe" }
型 · 三
불리언
boolean

참 또는 거짓, 곧 true 또는 false다.

"isMember": true, "single": false,
型 · 四
숫자
number

정수이거나 부동소수점 수다.

"weight": 79.5, "height": 1.73, "children": 3
型 · 五
배열
array

객체의 순서 있는 나열이다. 대괄호 한 쌍으로 표시하고 모든 객체는 쉼표로 나눈다.

"phone": [ {"type":"work", "number":"1234567"}, {"type":"home", "number":"8765432"}, {"type":"mobile","number":"1234876"} ]
型 · 六
null
null

키에 값이 없을 때 null을 넣는다.

"oldMembershipNo": null
주의 — 키는 유일해야 한다

객체의 각 키는 유일해야 한다. 예컨대 다음은 firstName 키가 둘이므로 유효한 JSON 문자열이 아니다.

JSON유효하지 않다
{
    "firstName": "John",
    "firstName": "Doe"
}

원서는 이 여섯 유형을 하나의 예시를 조금씩 키워 가며 설명한다. firstName 하나에서 시작해 이름·불리언·숫자·중첩 객체·배열·null까지 차례로 얹어, 마지막에는 한 사람에 관한 온전한 기록 하나가 완성된다. 아래가 그 최종 형태다. 이 한 덩어리 안에 여섯 유형이 모두 들어 있다.

JSON여섯 유형이 한자리에
{
    "firstName": "John",
    "lastName": "Doe",
    "isMember": true,
    "single": false,
    "weight": 79.5,
    "height": 1.73,
    "children": 3,
    "address": {
        "line1": "123 Street",
        "line2": "San Francisco",
        "state": "CA",
        "postal": "12345"
    },
    "phone": [
        { "type": "work",   "number": "1234567" },
        { "type": "home",   "number": "8765432" },
        { "type": "mobile", "number": "1234876" }
    ],
    "oldMembershipNo": null
}
원서가 절을 이어 가며 조금씩 키운 예시의 최종 형태. 객체 · 문자열 · 불리언 · 숫자 · 중첩 객체 · 배열 · null이 모두 들어 있다

주목할 것은 "single": false, 뒤의 쉼표다. 원서의 불리언 예시는 마지막 쌍 뒤에 쉼표를 두고 있어, 바로 앞에서 밝힌 “마지막 쌍 뒤에는 쉼표가 필요하지 않다”는 규칙과 어긋난다. 엄격한 파서라면 거부한다. 예시를 옮기되 이 점은 적어 둔다.

가장 먼저 시도하는 손 · read_json_auto()

USING THE READ_JSON_AUTO() FUNCTION

시연을 위해 json1.json이라는 파일의 내용을 살핀다. JSON 객체의 배열이며, 각 객체는 id, name, address, email, weight 다섯 필드를 담는다.

json1.json객체의 배열
[
    {
        "id": 1,
        "name": "Sarah Johnson",
        "address": "4321 Oak Street Apartment 304
        "email":"sarah_johnson478@gmail.com",
        "weight": 140.50
    },
    {
        "id": 2,
        "name": "David Martinez",
        "address": "789 Maple Avenue Suite 102 Ne
        "email":"david_martinez431@gmail.com",
        "weight": 155.0
    },
    {
        "id": 3,
        "name": "Emily Wilson",
        "address": "567 Pine Road Unit 5B Chicago
        "email":"emily_wilson998@gmail.com",
        "weight": 200.1
    }
]
세 사람의 기록. 이 세 사람이 제6장의 처음부터 끝까지 함께 간다
Python + SQLread_json_auto()
import duckdb
conn = duckdb.connect()
conn.execute('''
    SELECT *
    FROM read_json_auto('json1.json')
''').df()

# 표로 남기려면 이렇게 고친다
conn.execute('''
    CREATE TABLE People
    as
    FROM 'json1.json'
''')
도판 6-1 · 다섯 개 필드를 가진 표가 돌아온다

read_json_auto() 함수는 JSON 파일의 여러 키·값 쌍을 자동으로 해석해 DuckDB로 적재한다. 결과가 다섯 열의 표이며, JSON 파일의 키·값 쌍과 맞아떨어진다.

제3장에서 다룬 DuckDB CLI로 같은 질의를 실행하면 다음 출력이 보인다. 여기서 중요한 것은 열마다 데이터 유형이 함께 표시된다는 점이다. 어떤 유형의 데이터가 적재되었는지 알아야 할 때 특히 도움이 된다.

DuckDB CLI원서 인쇄분
D SELECT *
  FROM read_json_auto('json1.json');
┌───────┬────────────────┬───┬───────────────────
│ id    │      name      │ … │        email
│ int64 │    varchar     │   │       varchar
├───────┼────────────────┼───┼───────────────────
│     1 │ Sarah Johnson  │ … │ sarah_johnson478@g
│     2 │ David Martinez │ … │ david_martinez431@
│     3 │ Emily Wilson   │ … │ emily_wilson998@gm
├───────┴────────────────┴───┴───────────────────
│ 3 rows                                 5 columns
└────────────────────────────────────────────────
3행 5열. id는 int64, name과 email은 varchar로 추론되었다

records 매개변수 — 펼치지 않고 그대로 두기

각 객체를 한 행으로 적재하되 모든 키·값 쌍을 단일 열에 담고 싶다면 records 매개변수를 false로 설정한다.

SQLrecords = false
conn.execute('''
    SELECT *
    FROM read_json_auto('json1.json', records = false)
''').df()
도판 6-2 · 모든 키·값 쌍이 하나의 열에 저장된다

기본적으로 recordstrue다. JSON 확장이 JSON 객체를 기대하므로, 필드를 개별 열로 자동으로 풀어 놓는다. DuckDB CLI로 보면 출력이 이렇다.

DuckDB CLIstruct로 남는다
D SELECT *
  FROM read_json_auto('json1.json', records = false);
┌────────────────────────────────────────────────
│                                     json
│ struct(id bigint, "name" varchar, address varch
├────────────────────────────────────────────────
│ {'id': 1, 'name': Sarah Johnson, 'address': 432
│ {'id': 2, 'name': David Martinez, 'address': 78
│ {'id': 3, 'name': Emily Wilson, 'address': 567
└────────────────────────────────────────────────
열 이름은 json 하나이고, 그 유형은 struct다. 함을 열지 않고 함째로 들고 있는 상태다

이 출력에서 "name"만 이중 인용부호에 싸여 있는 것이 눈에 띈다. name이 SQL의 예약어에 가까운 이름이어서 식별자로 감싼 것이다. 자동 추론이 이름 하나까지 살펴 처리하고 있다는 흔적이다.

JSON 파일에서 특정 필드만 골라 적재할 수도 있다. 예컨대 nameemail 쌍만 적재하려면 SELECT 목록에 그 둘만 적는다.

SQL선택적 적재
conn.execute('''
    SELECT name, email
    FROM read_json_auto('json1.json')
''').df()
도판 6-3 · name과 email 필드만 적재된다

대부분의 경우 read_json_auto()가 JSON 파일 적재의 일을 해낸다. 그러나 어떤 특수한 경우에는 손으로 적재해야 한다. 그럴 때 쓰는 것이 read_json()이다.

손으로 지정하는 손 · read_json()

USING THE READ_JSON() FUNCTION

read_json_auto()는 실은 자동 감지가 켜진 read_json()의 별칭이다. 대부분의 경우 read_json_auto()를 써야 한다. 드물게 그 함수가 파일의 형식을 자동으로 감지하지 못하는 경우가 있으며, 그때는 read_json()을 쓰고 데이터의 형식과 스키마를 지정한다.

두 함수의 관계가 이렇다는 사실이 이 절의 요점이다. 새 도구를 배우는 것이 아니라, 같은 도구의 자동 장치를 끄고 손으로 잡는 일이다.

format 값
arrayJSON 객체의 배열, 곧 대괄호로 감싼 형태다.
newline_delimited · nd줄바꿈 문자로 나뉜 낱개의 JSON 객체들이다.
unstructured줄바꿈 구분 JSON을 포함해 어떤 종류의 JSON이든 담을 수 있다. 더 자유로운 형태나 불규칙한 JSON까지 아우른다.
auto형식을 자동으로 판별한다.

객체의 배열

json1.json이 JSON 객체의 배열임을 앞에서 보았다. read_json()으로 그 내용을 읽으려면 형식을 auto로 두고, 어떤 열을 어떤 유형으로 적재할지 지정한다.

SQLcolumns로 스키마 지정
conn.execute('''
    SELECT *
    FROM read_json('json1.json',
    format = 'auto',
    columns =
        {
            id:'INTEGER',
            name:'STRING',
            weight:'FLOAT'
        })
''').df()
도판 6-4 · 세 열이 DuckDB로 적재된다

DuckDB CLI로 각 열의 유형을 확인할 수 있다. 이 출력은 원서 지면에 온전히 인쇄되어 있어, 이 장에서 값이 완전하게 확인되는 드문 대목이다.

DuckDB CLI원서 인쇄분 · 전문
D SELECT *
  FROM read_json('json1.json',
       format = 'auto',
       columns =
            {
                 id:'INTEGER',
                 name:'STRING',
                 weight:'FLOAT'
            });

┌───────┬────────────────┬────────┐
│  id   │      name      │ weight │
│ int32 │    varchar     │ float  │
├───────┼────────────────┼────────┤
│     1 │ Sarah Johnson  │  140.5 │
│     2 │ David Martinez │  155.0 │
│     3 │ Emily Wilson   │  200.1 │
└───────┴────────────────┴────────┘
idnameweight
int32varcharfloat
1Sarah Johnson140.5
2David Martinez155.0
3Emily Wilson200.1
같은 출력을 표로 옮긴 것. columns에 INTEGER를 지정했으므로 id의 유형이 앞 절의 int64에서 int32로 바뀌었다

여기서 유형의 변화를 눈여겨보아야 한다. 자동 감지에서 idint64였으나, columnsINTEGER를 지정하자 int32가 되었다. 손으로 잡는다는 것은 이런 종류의 통제권을 얻는 일이다. 그리고 140.50이라 적혀 있던 값이 140.5로 인쇄되는 것도 같은 이치다. JSON의 글자가 아니라 float이라는 유형이 그 값을 표현하고 있기 때문이다.

모든 키·값 쌍을 읽으려면 columns 매개변수를 그냥 생략한다.

SQLcolumns 생략
conn.execute('''
    SELECT *
    FROM read_json('json1.json',
    format = 'auto')
''').df()
도판 6-5 · 모든 키·값 쌍이 열로 적재된다

한 줄에 한 함 · 줄바꿈 구분 JSON

NEWLINE-DELIMITED (ND) JSON

json1_a.json이라는 또 하나의 파일이 있다고 하자. 세 줄의 JSON 객체가 각각 줄바꿈 문자로 나뉘어 있다. 이를 줄바꿈 구분 JSON 파일이라 한다.

json1_a.json한 줄이 한 객체
{"id": 1, "name": "Sarah Johnson", "address":
    {"line1":"4321 Oak Street Apartment","line2"
     "state":"CA", "zip":90001}, "email":"sarah_j
     "weight": 140.50}
{"id": 2, "name": "David Martinez", "address":
    {"line1":"789 Maple Avenue ","line2":"Suite
     "state":"NY","zip":10001}, "email":"david_
     "weight": 155.0}
{"id": 3, "name": "Emily Wilson", "address":
    {"line1":"567 Pine Road Unit 5B Chicago",
     "state":"IL", "zip":60601}, "email":"emily_w
     "weight": 200.1}
파일 이름에 _a가 붙은 이 판본은 주소가 중첩 객체로 바뀌어 있다. 그리고 세 번째 객체에는 line2 키가 없다
검토자의 주석 — 이 파일이 알려 주는 것

이 인쇄분에서 두 가지가 확인된다. 첫째, 이 장의 세 사람의 주소가 실제로는 line1·line2·state·zip으로 나뉜 중첩 객체라는 것. 둘째, Emily Wilson의 객체에는 line2 키가 없다는 것이다. 이 결손은 우연이 아니라 뒤에서 되풀어 쓰이는 장치다. 중첩을 전개할 때, 여러 파일을 합칠 때, 그리고 COPY-FROM의 한계를 말할 때 모두 이 빠진 키가 등장한다.

SQL두 가지 읽는 법
# ① format을 newline_delimited로 지정한다
conn.execute('''
    SELECT *
    FROM read_json('json1_a.json',
    format = 'newline_delimited',
    columns =
        {
            id:'INTEGER',
            name:'STRING',
            weight:'FLOAT'
        })
''').df()

# ② 전용 함수를 쓰면 format 지정이 필요 없다
conn.execute('''
    SELECT *
    FROM read_ndjson_auto('json1_a.json',
    columns =
        {
            id:'INTEGER',
            name:'STRING',
            weight:'FLOAT'
        })
''').df()
두 코드가 같은 출력을 낸다. 도판 6-4와 동일하다

newline_delimited 외에 ndunstructured 형식도 쓸 수 있다. unstructured 형식은 줄바꿈 구분 JSON을 포함해 어떤 종류의 JSON이든, 더 자유로운 형태나 불규칙한 JSON까지 담을 수 있다.

겹을 여는 손 · 중첩 JSON

NESTED JSON

객체가 중첩된 또 하나의 예시 파일 json2.json을 본다. address 키의 값이 line1, line2, state, zip 네 개의 키로 다시 갈라진다. 예외는 세 번째 객체이며, address 키의 값에 line2 키가 없다.

json2.jsonaddress가 함이 된다
[
    {
        "id": 1,
        "name": "Sarah Johnson",
        "address": {
            "line1":"4321 Oak Street Apartment",
            "line2":"304 Los Angeles",
            "state":"CA",
            "zip":90001
        },
        "email":"sarah_johnson478@gmail.com",
        "weight": 140.50
    },
    {
        "id": 2,
        "name": "David Martinez",
        "address": {
            "line1":"789 Maple Avenue ",
            "line2":"Suite 102 New York",
            "state":"NY",
            "zip":10001
        },
        "email":"david_martinez431@gmail.com",
        "weight": 155.0
    },
    {
        "id": 3,
        "name": "Emily Wilson",
        "address": {
            "line1":"567 Pine Road Unit 5B Chicago",
            // line2 키가 없다
            "state":"IL",
            "zip":60601
        },
        "email":"emily_wilson998@gmail.com",
        "weight": 200.1
    }
]
세 번째 객체에 line2가 없다는 사실을 기억한다. 주석 표기는 설명을 위해 검토자가 덧붙인 것이며 실제 JSON에는 주석이 없다
SQL그냥 읽으면
conn.execute('''
    SELECT *
    FROM read_json('json2.json')
''').df()
도판 6-6 · address 필드의 값이 단일 열 안에 담긴다. 함을 들었으되 열지 않은 상태다

address 필드의 값을 개별 열로 표현하고 싶다면, 곧 line1·line2·state·zip 네 열을 갖고 싶다면 SQL 문에서 개별 키를 지정한다.

SQL한 겹 열기
conn.execute('''
    SELECT
        id,
        name,
        address['line1'] as line1,
        address['line2'] as line2,
        address['state'] as state,
        address['zip'] as zip,
        email,
        weight
    FROM read_json('json2.json')
''').df()
도판 6-7 · address 필드의 내용이 네 열로 표현된다
idnameline1line2statezip
1Sarah Johnson4321 Oak Street Apartment304 Los AngelesCA90001
2David Martinez789 Maple Avenue Suite 102 New YorkNY10001
3Emily Wilson567 Pine Road Unit 5B Chicagoline2 키 없음IL60601
파일 내용에서 재구성한 결과. 세 번째 행의 line2 자리가 비는 것이 이 예시의 요점이다. DuckDB가 그 자리를 어떤 값으로 채우는지는 도판 이미지에만 있어 옮기지 않았다

함 속의 함 · 이중 중첩

address 키 안에 또 하나의 중첩 객체가 있으면 어떻게 되는가. json2_a.json이 그 경우다. address 키의 값 안에 location 키가 또 있고, 그 안에 세 개의 키·값 쌍이 든다.

json2_a.json세 겹
[
    {
        "id": 1,  "name": "Sarah Johnson",
        "address": {
            "line1":"4321 Oak Street Apartment",
            "line2":"304 Los Angeles",
            "location" : {
                "state":"CA",
                "city":"Calexico",
                "zip":90001
            }
        },
        "email":"sarah_johnson478@gmail.com",
        "weight": 140.50
    },
    {
        "id": 2,  "name": "David Martinez",
        "address": {
            "line1":"789 Maple Avenue ",
            "line2":"Suite 102 New York",
            "location" : {
                "state":"NY",
                "city":"Coney Island",
                "zip":10001
            }
        },
        "email":"david_martinez431@gmail.com",
        "weight": 155.0
    },
    {
        "id": 3,  "name": "Emily Wilson",
        "address": {
            "line1":"567 Pine Road Unit 5B Chicago",
            "location" : {
                "state":"IL",
                "city":"Brookfield",
                "zip":60601
            }
        },
        "email":"emily_wilson998@gmail.com",
        "weight": 200.1
    }
]
SQL대괄호를 이어 붙인다
conn.execute('''
    SELECT
        address['line1'] as line1,
        address['line2'] as line2,
        address['location']['state'] as state,
        address['location']['city'] as city,
        address['location']['zip'] as zip,
        email,
        weight
    FROM read_json('json2_a.json')
''').df()
도판 6-8 · location 필드의 내용이 세 열로 표현된다
line1statecityzip
4321 Oak Street ApartmentCACalexico90001
789 Maple Avenue NYConey Island10001
567 Pine Road Unit 5B ChicagoILBrookfield60601
파일 내용에서 재구성한 결과. 도시 이름이 주(州)와 어울리지 않는 조합이라는 점은 예시 데이터의 성격이므로 그대로 옮겼다

대괄호를 하나 더 이어 붙이는 것으로 한 겹을 더 들어간다. 문법이 단순한 만큼 정직하다. 다만 정직함에는 값이 따른다. 겹이 깊어질수록 SQL 문이 길어지고, 어느 겹에 무엇이 있는지를 사람이 미리 알고 있어야 한다. 자동 감지가 열을 만들어 주는 것은 첫 겹까지이며, 그 아래는 손으로 짚어야 한다. 발굴 기록을 남기지 않으면 다음 사람이 함을 열 수 없다는 사정과 같다.

배열을 눕히는 손 · unnest()

CUSTOM JSON FILE

되풀이되는 뚜렷한 양상이 없는 특정 구조를 담은 JSON 파일은 어떻게 다루는가. json3.json을 본다. people이라는 키 하나가 있고 그 값이 JSON 객체의 배열이다. 곧 파일의 최상위가 배열이 아니라 객체이며, 우리가 원하는 배열은 그 객체의 값으로 한 겹 들어가 있다.

json3.jsonpeople 키가 배열을 품는다
{
    "people": [
    {
        "id": 1,
        "name": "Sarah Johnson",
        "address": {
            "line1":"4321 Oak Street Apartment",
            "line2":"304 Los Angeles",
            "state":"CA",
            "zip":90001
        },
        "email":"sarah_johnson478@gmail.com",
        "weight": 140.50
    },
    {
        "id": 2,
        "name": "David Martinez",
        "address": { … line1 · line2 · state NY · zip 10001 … },
        "email":"david_martinez431@gmail.com",
        "weight": 155.0
    },
    {
        "id": 3,
        "name": "Emily Wilson",
        "address": { … line1 · state IL · zip 60601, line2 없음 … },
        "email":"emily_wilson998@gmail.com",
        "weight": 200.1
    }
    ]
}
둘째와 셋째 객체의 주소는 앞의 json2.json과 같은 내용이므로 줄여 적었다. 원서 지면에도 같은 값이 인쇄되어 있다

이 파일을 read_json()으로 적재해 본다. 흥미롭게도 people 키의 값이 단일 열로 적재된다.

SQL세 걸음으로 펼친다
# ① 그냥 읽는다 — people이 한 열이 된다
conn.execute('''
    SELECT *
    FROM read_json('json3.json')
''').df()

# ② unnest()로 배열의 원소를 행으로 바꾼다
conn.execute('''
    SELECT unnest(people) p
    FROM read_json('json3.json')
''').df()

# ③ 각 행의 내용을 p로 풀어 낸다
conn.execute('''
    SELECT
        p.id,
        p.name,
        p.address['line1'] as line1,
        p.address['line2'] as line2,
        p.address['state'] as state,
        p.address['zip'] as zip,
        p.email,
        p.weight
    FROM
    (
        SELECT unnest(people) p
        FROM read_json('json3.json')
    )
''').df()
도판 6-9 · 6-10 · 6-11 · people이 한 열로 들어오고, unnest()로 각 객체가 한 행이 되고, 마지막으로 각 행이 개별 열로 풀린다

이 질의는 각 행의 내용을 대괄호로 감싸는 방식으로도 쓸 수 있다. 두 어법이 같은 일을 한다.

SQL점 표기와 대괄호 표기
conn.execute('''
    SELECT
        p['id'],
        p['name'],
        p['address']['line1'] as line1,
        p['address']['line2'] as line2,
        p['address']['state'] as state,
        p['address']['zip'] as zip,
        p['email'],
        p['weight']
    FROM
    (
        SELECT unnest(people) p
        FROM read_json('json3.json')
    )
''').df()
p.id와 p['id']는 같다. 다만 대괄호 표기에는 별칭을 주지 않으면 열 이름이 표기 그대로 남는다

이 절의 세 걸음이 이 장 전체의 구조를 압축한다. 배열은 눕히고, 객체는 연다. unnest()가 세로로 늘리고, 대괄호가 가로로 펼친다. 파일이 아무리 낯설게 생겼어도 이 두 동작의 조합으로 표가 된다. 이 장의 나머지는 어느 함수로 파일을 집어 드는가의 문제일 뿐이다.

여러 함을 한꺼번에

LOADING MULTIPLE JSON FILES

여기까지는 개별 JSON 파일을 적재했다. 그러나 여러 JSON 파일을 한꺼번에 적재해야 하는 경우가 잦다. json4.jsonjson5.json을 본다.

json4.json · json5.json구조는 같고 구성이 다르다
# json4.json — 두 사람. 주소는 문자열, weight와 email이 있다
[
    {
        "id": 1, "name": "Sarah Johnson",
        "address": "4321 Oak Street Apartment 304
        "email":"sarah_johnson478@gmail.com",
        "weight": 140.50
    },
    {
        "id": 2, "name": "David Martinez",
        "address": "789 Maple Avenue Suite 102 Ne
        "email":"david_martinez431@gmail.com",
        "weight": 155.0
    }
]

# json5.json — 한 사람. 주소는 객체, weight와 email이 없고 height가 있다
[
    {
        "id": 3, "name": "Emily Wilson",
        "address": {
            "line1":"567 Pine Road Unit 5B Chicago",
            "state":"IL",
            "zip":60601
        },
        "height": 66
    }
]

두 파일 모두 일반적인 구조는 같다. 객체의 배열이다. 핵심적인 차이는 각 객체의 구성이다. 두 번째 JSON 파일에는 weightemail 키가 없고, address 키의 내용이 두 파일에서 서로 다르다. 이제 파일 이름의 목록을 read_json()에 넘겨 두 파일을 함께 적재한다.

SQL목록으로 넘긴다
conn.execute('''
    SELECT *
    FROM read_json(['json4.json','json5.json'])
''').df()
도판 6-12 · 두 JSON 파일을 적재한 결과
주의 — 유형 충돌

DuckDB에서 여러 JSON 파일을 적재할 때 어떤 필드가 한 파일에서는 문자열 값이고 다른 파일에서는 숫자 값이면, DuckDB는 그 필드의 데이터 유형을 추론하려 시도한다. 유형 충돌이 있으면 일관성을 지키기 위해 숫자 값을 문자열로 변환할 수 있으며, 그 결과 열 전체가 문자열로 다루어진다. 충돌이 심한 경우에는 오류를 낼 수도 있다. 이를 피하려면 파일 사이의 데이터 유형 일관성을 보장하거나, 질의에서 그 필드를 원하는 유형으로 명시적으로 변환한다.

원서가 이 결과에서 관찰한 것은 셋이다.

  • 합쳐진다 결과는 적재된 두 파일의 결합이다.
  • 이름으로 맞춘다 결과의 열은 JSON 파일 안 키의 이름과 일치한다. JSON 파일 안에서 키의 순서는 중요하지 않다.
  • 빈 자리를 채운다 각 행에서 빠진 필드에는 숫자 필드라면 NaN 값이, 문자열 필드라면 None 값이 들어간다.
idnameaddressemailweightheight
1Sarah Johnson문자열sarah_johnson478@…140.5NaN
2David Martinez문자열david_martinez431@…155.0NaN
3Emily Wilson객체NoneNaN66
본문의 세 관찰에 따라 검토자가 구성한 구조도다. 도판 6-12의 실제 렌더링을 옮긴 것이 아니며, address 열의 값이 어떤 형태로 표시되는지는 유형 충돌의 처리에 달려 있어 유형 이름만 적었다

일반적으로 여러 파일을 적재할 때는 모든 파일이 같은 구조를 갖도록 하는 것이 이상적이다.

일러두기

두 JSON 파일이 같은 구조를 갖도록 보장하는 파이썬 스크립트를 언제든 작성할 수 있다.

이름으로 한 줌 집어 오기 · glob

적재할 파일 이름의 목록을 주는 대신, 흔히 와일드카드 구문이라 불리는 glob 구문을 쓸 수도 있다. 여러 JSON 파일을 읽는 데 다음 와일드카드를 쓴다.

와일드카드하는 일
*임의 개수의 임의 문자와 일치한다. 없는 경우도 포함한다.
**임의 개수의 하위 디렉터리와 일치한다. 없는 경우도 포함한다.
?임의의 한 문자와 일치한다.
[abc]대괄호 안에 주어진 문자 가운데 하나와 일치한다.
[a-z]대괄호 안에 주어진 범위에서 한 문자와 일치한다.
SQLglob 두 예
# json으로 시작하고 .json으로 끝나는 현재 디렉터리의 모든 파일
conn.execute('''
    SELECT *
    FROM read_json('json*.json')
''').df()

# json 다음에 한 글자가 오고 .json으로 끝나는 파일
conn.execute('''
    SELECT *
    FROM read_json('json?.json')
''').df()
뒤의 코드는 json1.json, json2.json, json3.json, json4.json, json5.json 다섯 개를 적재한다. json1_a.json과 json2_a.json은 ?가 한 글자만 맞추므로 제외된다

? 하나가 _a가 붙은 두 파일을 걸러 낸다. 파일 이름을 짓는 방식이 곧 질의의 도구가 된다는 점에서, 이 절은 데이터 정리의 기초를 말하고 있다. 이름을 규칙 있게 붙여 두면 나중에 한 줌씩 집어 올 수 있다.

큰 짐을 옮기는 손 · COPY-FROM

USING THE COPY-FROM STATEMENT

JSON 파일을 DuckDB로 적재하는 세 번째 방법은 COPY-FROM 문이다. 이는 특히 큰 데이터셋을 상대할 때 read_json()보다 상당한 이점을 제공한다.

견주는 자리COPY-FROMread_json()
적재 방식 데이터를 일괄(batch)로 적재해 큰 파일을 더 효율적으로 다룬다. 메모리 소비가 줄고 속도가 오른다. 파일 전체를 메모리에 적재한다. 큰 파일에서 성능 병목을 낼 수 있다.
병렬성 DuckDB의 병렬 처리 능력을 온전히 활용해, 기가바이트 단위 데이터를 적재할 때 성능을 더 끌어올린다.
작업 흐름 최소한의 전처리로 데이터를 표에 직접 적재하고 스키마를 자동 추론해 데이터 일관성을 보장하므로, 데이터베이스 작업 흐름에 더 매끄럽게 들어맞는다. 스키마 일관성을 강제하거나 데이터를 데이터베이스에 넣기 위해 추가 단계가 필요한 경우가 많다.
적합한 자리 고성능 대규모 데이터 반입 더 작고 대화적인 작업

COPY-FROM 문으로 JSON 파일을 적재하려면 먼저 JSON 구조에 맞는 올바른 스키마로 표를 만들고, 그다음 COPY-FROM으로 파일을 적재한다.

Python + SQL표를 먼저 짓는다
conn = duckdb.connect()
conn.execute('''
    CREATE TABLE people (id INT, name STRING, address STRING,
                          email STRING, weight FLOAT);
    COPY people FROM 'json1.json' (FORMAT JSON, AUTO_DETECT true);
    SELECT * FROM people;
''').df()
도판 6-13 · COPY-FROM 문으로 JSON 파일을 적재한 결과
주의 — 이 방법을 쓰지 말아야 할 때

JSON 파일을 제대로 적재하려면 (FORMAT JSON, AUTO_DETECT true) 문을 써야 한다. 그리고 JSON 파일이 복잡한 구조를 가진 경우, 예컨대 json5.json 같은 경우에는 이 방법을 권하지 않는다.

대가 관계가 뚜렷하다. COPY-FROM은 빠르고 크게 다루지만, 스키마를 미리 알고 손으로 적어야 한다. 중첩된 함이나 파일마다 다른 구성 앞에서는 그 미리 아는 일이 불가능해진다. 제2장에서 COPY가 큰 CSV에 최고의 성능을 내면서도 엑셀에는 아예 쓸 수 없었던 그 대비가 여기서 되풀어진다. 빠른 길에는 늘 정해진 모양의 짐만 오른다.

다시 함에 담아 내보내다

EXPORTING TABLES TO JSON

DuckDB에 있는 표를 JSON 파일로 남기고 싶을 때가 있다. 다른 플랫폼과 데이터를 교환하려 할 때, 또는 단순히 데이터를 백업하는 방법으로 유용하다. 앞 절에서 다룬 COPY 문으로 쉽게 내보낸다.

Python + SQL표를 만들고 내보낸다
conn = duckdb.connect()
conn.execute('''
    CREATE OR REPLACE TABLE people
    as
    SELECT
        name,
        weight
    FROM read_ndjson_auto('json1_a.json')
''')
display(conn.execute('SELECT * FROM people').df())

conn.execute('''
    COPY people
    TO
    'people.json' (FORMAT JSON);
''')
도판 6-14 · people 표의 내용. FORMAT JSON은 선택적이다. DuckDB가 파일 확장자로 유형을 유추하기 때문이다

people.json의 내용은 이렇다.

people.json원서 인쇄분
{"id":1,"name":"Sarah Johnson","weight":140.5}
{"id":2,"name":"David Martinez","weight":155.0}
{"id":3,"name":"Emily Wilson","weight":200.1}
검토자의 주석 — 인쇄분의 어긋남

앞의 CREATE OR REPLACE TABLE 문은 nameweight 두 열만 골라 표를 만든다. 그런데 여기 인쇄된 people.json에는 id가 함께 들어 있다. 뒤에 나오는 people_array.json에는 id가 없다. 두 출력이 서로 어긋나므로, 원서 지면의 어느 한쪽이 다른 시점의 실행 결과로 보인다. 양쪽을 그대로 옮기되 이 어긋남을 적어 둔다. 질의문을 따른다면 두 열만 나오는 쪽이 맞다.

내용이 엄격한 표준 JSON이 아니라는 점을 눈여겨본다. 실은 줄바꿈 구분 JSON 파일이며, 각 줄이 별개의 JSON 객체를 담는다. 출력을 엄격한 JSON으로 만들려면 ARRAY TRUE를 지정한다.

SQL · people_array.jsonARRAY TRUE
conn.execute('''
    COPY people
    TO
    'people_array.json' (ARRAY TRUE);
''')

# people_array.json의 내용
[
    {"name":"Sarah Johnson","weight":140.5},
    {"name":"David Martinez","weight":155.0},
    {"name":"Emily Wilson","weight":200.1}
]
이것은 유효한 JSON 문자열이다

기본값이 줄바꿈 구분이라는 사실은 우연이 아니다. 대괄호로 전체를 감싸려면 마지막 줄이 언제인지 알아야 하고, 그러려면 스트림을 끝까지 붙들고 있어야 한다. 반면 한 줄에 한 객체를 쓰는 방식은 쓰는 즉시 끝난 줄이 되므로 얼마든 이어 붙일 수 있다. 이 장의 첫머리에서 만난 그 형식이 마지막에 기본값으로 돌아오는 셈이다.

맺음말 · SUMMARY

가장 직접적인 방법을 늘 먼저 시도한다. 그것이 실패할 때에야 손으로 잡는다.

제6장에서는 JSON 파일을 DuckDB로 적재하는 여러 방법을 익혔다. 원서의 맺음말은 세 방법을 고르는 순서를 못박아 준다.

選 · 一
read_json_auto()
first try

가장 직접적인 방법이며, 늘 이 방법을 먼저 시도해야 한다.

選 · 二
read_json()
when auto fails

JSON 파일이 복잡한 구조를 가지고 read_json_auto()가 제대로 적재하지 못하면, read_json()으로 적재를 시도해야 한다.

選 · 三
COPY-FROM
simple & large

이 방법을 쓰려면 JSON 파일을 적재하기 전에 DuckDB에 표를 손으로 먼저 만들어야 한다. JSON 파일의 구조가 비교적 단순한 경우에 쓴다. 그렇지 않다면 언제나 read_json_auto()read_json()을 먼저 시도해야 한다.

마지막으로 COPY-TO 문으로 DuckDB의 표를 JSON 파일로 내보내는 법도 익혔다.

답사를 마치며 이 장의 자리를 적어 둔다. 제6장에는 새로운 SQL 문법이 거의 없다. 대괄호와 unnest(), 그리고 함수 이름 몇 개가 전부다. 그런데도 이 장이 한 장을 따로 받는 까닭은, JSON이 형식이 아니라 형식의 부재이기 때문이다. CSV를 읽는 데는 판단이 필요하지 않다. JSON을 읽는 데는 매번 판단이 필요하다. 배열인가 낱줄인가, 몇 겹인가, 어느 키가 빠져 있는가.

그래서 이 장의 실질적인 소득은 함수 목록이 아니라 구조를 먼저 들여다보는 습관이다. Emily Wilson의 주소에 line2가 없다는 그 사소한 결손이 이 장 내내 되풀어 등장하는 것도 그 때문이다. 원서는 우연히 그렇게 만든 것이 아니다. 파일 하나를 열어 보지 않고 적재부터 시작하면 그 빈 자리를 나중에 알게 되고, 그때는 이미 늦다.

사리장엄구를 여는 발굴자는 먼저 함의 겉을 그리고 무게를 재고 이음새를 살핀 다음 연다. 함을 열기 전에 함을 아는 일, 그것이 이 장이 가르치는 태도다.