Ver2.5 PostgreSQL: JSONB 데이터 타입, 제대로 한번 파헤쳐보자!

PostgreSQL: JSONB 데이터 타입, 제대로 한번 파헤쳐보자!
"아... 또 스키마 변경이야?", "이 필드 하나 추가하자고 DB 마이그레이션을 또 돌려야 해?"
개발자라면 누구나 한 번쯤 겪어봤을 고통의 순간. RDB의 엄격한 스키마는 데이터의 정합성을 지켜주는 든든한 수호자지만, 때로는 변화에 대응하기 힘든 족쇄처럼 느껴지기도 해. 특히나 요즘처럼 하루가 다르게 서비스가 바뀌고, 고객의 요구사항이 빛의 속도로 추가되는 '애자일' 시대에는 더더욱 말이지.
바로 이럴 때, 우리에게 한 줄기 빛처럼 다가온 존재가 있어. 바로 **PostgreSQL의 JSONB 데이터 타입!**
단순히 JSON 문자열을 텍스트 필드에 저장하는 것과는 차원이 다른, RDB의 심장부에서 NoSQL의 유연함을 품게 해주는 마법 같은 녀석이야. 오늘은 이 JSONB가 왜 등장했고, 어떻게 우리의 개발 라이프를 윤택하게 만들어주는지, 아주 밑바닥부터 제대로 한번 파헤쳐 볼 거야. 커피 한 잔 들고, 편하게 따라와 봐!
JSONB, 넌 대체 누구냐? (JSON vs JSONB)
먼저 가장 기본적인 질문부터 시작해보자. "PostgreSQL에는 `json` 타입도 있고 `jsonb` 타입도 있는데, 둘 다 JSON 저장하는 거 아냐? 뭐가 다른데?" 좋아, 아주 좋은 질문이야. 이 둘의 차이를 아는 게 JSONB 활용의 첫걸음이거든.
결론부터 말하면, **우리는 거의 모든 경우에 `jsonb`를 사용하게 될 거야.** 왜 그런지 한번 보자.
-
`json` 타입: 이 녀석은 우리가 입력한 JSON 텍스트를 그대로 저장해. 공백, 키 순서, 심지어 중복된 키까지도 모두 보존하지. 말 그대로 '텍스트 원본 저장소'야. 장점은 입력 속도가 아주 빠르다는 거. 별다른 처리 없이 그냥 텍스트를 쑤셔 넣으면 되니까. 하지만 데이터를 조회하거나 가공할 때는 매번 JSON 텍스트를 파싱해야 해서 성능이 떨어져.
-
`jsonb` 타입: 여기서 'b'는 **binary**를 의미해. `jsonb`는 입력된 JSON 텍스트를 그대로 저장하지 않고, PostgreSQL이 가장 효율적으로 처리할 수 있는 **최적화된 바이너리 형태**로 변환해서 저장해. 이 과정에서 몇 가지 변화가 생겨.
쓸데없는 공백은 모두 제거돼.
키의 순서는 보장되지 않아. (어차피 JSON 객체는 순서가 없는 데이터 구조니까!)
중복된 키가 있다면, 마지막에 입력된 값만 살아남아.
가장 중요한 것! **인덱싱이 가능해져.** 이게 핵심이야.
입력 시 바이너리 변환 과정 때문에 `json` 타입보다는 약간 느리지만, 그 차이는 미미해. 반면 데이터를 조회하고, 특정 키의 값을 찾고, 필터링하는 모든 작업에서 `jsonb`는 `json`을 압도하는 성능을 보여줘. 그래서 특별한 이유(예: JSON 원본을 무조건 그대로 보존해야 하는 감사 로그)가 없는 한, 우리는 항상 `jsonb`를 선택하는 게 맞아. **유연함과 성능, 두 마리 토끼를 모두 잡은 타입**이라고 할 수 있지.
친구야, 이것만 기억해!
`json` = 날것 그대로의 텍스트. 입력은 빠르지만 조회는 느림.
`jsonb` = 똑똑하게 가공된 바이너리. 입력은 살짝 느리지만 조회/가공은 압도적으로 빠름. 우리의 선택은? 무조건 `jsonb`!
일단 만들어나 보자! - 기본 CRUD & 연산자 파티
백문이 불여일견! 직접 테이블을 만들고 데이터를 넣고 빼면서 JSONB와 친해져 보자. 우리는 온라인 쇼핑몰의 상품 정보를 관리하는 `products` 테이블을 예제로 다룰 거야. 상품마다 속성이 제각각이라 JSONB를 쓰기에 아주 좋은 예시지.
1. 테이블 생성 (CREATE)
먼저 `products` 테이블을 만들어보자. 상품의 고유 ID, 이름 같은 핵심 정보는 일반 컬럼으로 만들고, 계속 바뀔 수 있는 상세 스펙은 `details`라는 `jsonb` 컬럼에 담을 거야. 이게 바로 **하이브리드 데이터 모델링**의 시작이야.
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
details JSONB
);
아주 간단하지? `details` 컬럼의 타입으로 `JSONB`를 지정해주기만 하면 끝이야.
2. 데이터 삽입 (INSERT)
이제 상품 데이터를 넣어보자. JSON 데이터는 작은따옴표(`'`)로 감싼 문자열 형태로 넣어주면 돼. PostgreSQL이 알아서 `jsonb` 타입으로 변환해 줄 거야.
-- 노트북 상품 정보
INSERT INTO products (name, details) VALUES
('고성능 노트북 15인치',
'{
"category": "electronics",
"brand": "AwesomeBook",
"price": 1500000,
"specs": {
"cpu": "i7-12700H",
"ram": "16GB DDR5",
"storage": "1TB NVMe SSD"
},
"tags": ["개발용", "고사양", "가성비"],
"is_on_sale": true
}'
);
-- 스마트폰 상품 정보
INSERT INTO products (name, details) VALUES
('플래그십 스마트폰',
'{
"category": "electronics",
"brand": "GalaxyPrime",
"price": 1200000,
"specs": {
"ap": "SuperChip 2",
"ram": "12GB LPDDR5X",
"camera": "200MP"
},
"colors": ["Black", "White", "Purple"],
"release_year": 2023
}'
);
-- 티셔츠 상품 정보
INSERT INTO products (name, details) VALUES
('코딩 중독 티셔츠',
'{
"category": "fashion",
"brand": "DevStyle",
"price": 29000,
"materials": ["cotton 95%", "polyurethane 5%"],
"sizes": ["S", "M", "L", "XL"],
"is_on_sale": false
}'
);
봐봐, 노트북, 스마트폰, 티셔츠의 상세 스펙(`details`) 구조가 전부 다르지? 만약 이걸 전통적인 RDB 방식으로 설계했다면 `cpu`, `ram`, `ap`, `camera`, `materials`... 이 모든 걸 별도의 컬럼으로 만들거나 복잡한 EAV(Entity-Attribute-Value) 모델을 써야 했을 거야. 생각만 해도 끔찍하지. 하지만 JSONB 덕분에 이렇게 유연하게 데이터를 저장할 수 있게 된 거야.
3. 데이터 조회 (SELECT)와 핵심 연산자
자, 이제 저장된 데이터를 꺼내 볼 시간이야. 여기서부터가 진짜 재미있는 부분이야. JSONB를 위한 전용 연산자들이 등장하거든. 처음엔 좀 헷갈릴 수 있지만, 몇 번 써보면 금방 익숙해질 거야.
가장 기본! `->` 와 `->>` 연산자
이 두 개는 무조건 알아야 해. JSONB 연산자의 알파이자 오메가야.
->: JSON 객체에서 특정 키에 해당하는 **값을 `jsonb` 타입으로** 가져온다.->>: JSON 객체에서 특정 키에 해당하는 **값을 `text` 타입으로** 가져온다.
이게 무슨 차이냐고? 코드를 보면 바로 이해될 거야.
-- 'price' 필드를 -> 연산자로 가져오기
SELECT name, details->'price' FROM products;
-- 결과
-- name | ?column?
-- --------------------+-----------
-- 고성능 노트북 15인치 | 1500000
-- 플래그십 스마트폰 | 1200000
-- 코딩 중독 티셔츠 | 29000
-- (결과 타입은 jsonb)
-- 'price' 필드를 ->> 연산자로 가져오기
SELECT name, details->>'price' FROM products;
-- 결과
-- name | ?column?
-- --------------------+----------
-- 고성능 노트북 15인치 | 1500000
-- 플래그십 스마트폰 | 1200000
-- 코딩 중독 티셔츠 | 29000
-- (결과 타입은 text)
결과만 보면 똑같아 보이지? 하지만 타입이 달라. `->`로 가져온 `1500000`은 여전히 JSONB 세상의 숫자(jsonb number)고, `->>`로 가져온 `1500000`은 그냥 문자열(text) `'1500000'`이야. 이 차이가 왜 중요하냐면, **WHERE 절에서 조건을 걸 때** 결정적인 역할을 하거든.
-- 가격이 100만원 이상인 상품 찾기 (올바른 예)
SELECT name, details->>'price'
FROM products
WHERE (details->>'price')::numeric > 1000000;
-- name | ?column?
-- --------------------+----------
-- 고성능 노트북 15인치 | 1500000
-- 플래그십 스마트폰 | 1200000
-- (주의!) -> 연산자를 쓰면 에러 발생
SELECT name, details->'price'
FROM products
WHERE details->'price' > 1000000; -- ERROR: operator does not exist: jsonb > integer
두 번째 쿼리가 에러나는 이유는 `jsonb` 타입과 `integer` 타입을 직접 비교하는 `>` 연산자가 없기 때문이야. 그래서 `->>` 연산자로 값을 `text` 타입으로 꺼낸 다음, `::numeric` 이나 `::integer` 같은 캐스팅을 통해 숫자 타입으로 변환해서 비교해야 해. **WHERE 절에서는 `->>`를 쓰고 타입을 변환한다!** 이거 정말 중요하니까 꼭 기억해둬.
중첩된 객체 파고들기: `#>` 와 `#>>` 연산자
만약 노트북의 CPU 정보처럼 한 단계 더 들어간 값을 꺼내고 싶다면 어떡할까? `->`를 여러 번 쓸 수도 있지만, 더 편한 방법이 있어.
#>: 경로(path)를 지정해서 중첩된 객체의 **값을 `jsonb` 타입으로** 가져온다.#>>: 경로(path)를 지정해서 중첩된 객체의 **값을 `text` 타입으로** 가져온다.
경로는 `{key1, key2, ...}` 형태로 지정해. 배열의 원소를 가져오고 싶으면 인덱스(0부터 시작)를 쓰면 돼.
-- 노트북의 CPU 정보 가져오기
SELECT name, details#>'{specs,cpu}' FROM products WHERE name LIKE '%노트북%';
-- 결과: "i7-12700H" (jsonb 타입의 문자열)
-- 노트북의 CPU 정보를 text로 가져오기
SELECT name, details#>>'{specs,cpu}' FROM products WHERE name LIKE '%노트북%';
-- 결과: i7-12700H (text 타입)
-- 노트북의 첫 번째 태그 가져오기
SELECT name, details#>>'{tags,0}' FROM products WHERE name LIKE '%노트북%';
-- 결과: 개발용 (text 타입)
`#>>` 연산자 덕분에 `(details->'specs')->>'cpu'` 처럼 길게 쓰지 않고 `details#>>'{specs,cpu}'` 로 깔끔하게 표현할 수 있어. 코드가 훨씬 보기 좋아지지?
꿀팁: 연산자 선택 가이드
너무 많아서 헷갈린다고? 이렇게 정리해봐.
단순히 값을 `text`로 뽑아서 보고 싶을 때: `->>` 또는 `#>>`
`WHERE` 절에서 값으로 필터링할 때: `->>` 또는 `#>>` 쓰고 `::type`으로 형변환!
뽑아낸 값에 대해 또 다른 JSON 연산을 하고 싶을 때: `->` 또는 `#>`
4. 데이터 수정 (UPDATE)
JSONB 데이터의 일부만 살짝 바꾸고 싶을 때도 많겠지? 이럴 땐 `jsonb_set()` 함수가 해결사로 등판해.
jsonb_set(target_jsonb, path, new_value, [create_if_missing])
target_jsonb: 수정할 대상이 되는 `jsonb` 데이터 (우리 예제에서는 `details` 컬럼)path: 수정할 값의 위치를 나타내는 경로 (`#>` 연산자에서 썼던 것과 같아)new_value: 새로 집어넣을 값. `to_jsonb()` 함수로 `jsonb` 타입으로 만들어줘야 해.create_if_missing(optional): 경로에 해당하는 키가 없을 때 새로 만들지 여부 (기본값은 `true`).
-- '고성능 노트북 15인치'의 가격을 1450000으로 할인하기
UPDATE products
SET details = jsonb_set(details, '{price}', '1450000'::jsonb)
WHERE name = '고성능 노트북 15인치';
-- '플래그십 스마트폰'의 RAM을 16GB로 업그레이드하기
UPDATE products
SET details = jsonb_set(details, '{specs,ram}', '"16GB LPDDR5X"'::jsonb)
WHERE name = '플래그십 스마트폰';
-- 문자열 값을 넣을 땐 따옴표를 포함해서 jsonb로 만들어야 하는 점 주의!
객체 전체를 합치거나 특정 키를 삭제하는 건 더 쉬워.
병합 (Concatenation): `||` 연산자
키 삭제 (Key Deletion): `-` 연산자
경로로 요소 삭제 (Path Deletion): `#-` 연산자
-- '코딩 중독 티셔츠'에 재고 정보 추가하기
UPDATE products
SET details = details || '{"stock": 100}'::jsonb
WHERE name = '코딩 중독 티셔츠';
-- '플래그십 스마트폰'에서 출시년도(release_year) 정보 삭제하기
UPDATE products
SET details = details - 'release_year'
WHERE name = '플래그십 스마트폰';
-- '고성능 노트북 15인치'의 태그 목록에서 두 번째 태그('고사양') 삭제하기
-- 인덱스는 0부터 시작하므로 두 번째는 인덱스 1
UPDATE products
SET details = details #- '{tags,1}'
WHERE name = '고성능 노트북 15인치';
어때? SQL 안에서 마치 프로그래밍 언어로 객체를 다루는 것처럼 자유자재로 데이터를 주무를 수 있다는 게 느껴져? 이게 바로 JSONB의 매력이야.
5. 데이터 삭제 (DELETE)
데이터 삭제는 일반적인 SQL과 완전히 똑같아. `WHERE` 절에서 JSONB 연산자를 활용해 특정 조건을 만족하는 행을 지우면 돼.
-- 카테고리가 'fashion'인 모든 상품 삭제하기
DELETE FROM products
WHERE details->>'category' = 'fashion';
쿼리 속도, 광명을 찾다: JSONB 인덱싱의 모든 것
자, 이제 JSONB의 꽃이자 가장 중요한 부분인 **인덱싱**에 대해 이야기할 시간이야. 만약 네가 "JSONB를 써봤는데 생각보다 너무 느려요!"라고 느꼈다면, 십중팔구는 인덱스를 제대로 걸지 않았기 때문일 거야.
인덱스가 없으면 PostgreSQL은 JSONB 컬럼을 조회할 때 테이블의 모든 행을 하나하나 다 읽어서, 그 안에 있는 JSONB 데이터를 일일이 열어보고 조건에 맞는지 확인해야 해. 이걸 **풀 테이블 스캔(Full Table Scan)**이라고 부르는데, 데이터가 몇 만 건만 넘어가도 속도가 처참하게 느려지지.
하지만 걱정 마. PostgreSQL은 JSONB를 위해 **GIN(Generalized Inverted Index)**이라는 아주 강력한 인덱싱 기술을 제공하거든. 이것만 잘 활용하면 쿼리 속도를 수십, 수백 배까지 끌어올릴 수 있어.
1. GIN 인덱스: JSONB의 만능 열쇠
GIN은 '일반화된 역 인덱스'의 약자야. 이게 뭐냐면, JSONB 문서 안의 모든 **키(key)와 값(value)을** 뽑아서, 어떤 값이 어떤 문서에 들어있는지를 기록해두는 방식이야. 책 맨 뒤의 '찾아보기' 페이지랑 똑같아. 'CPU'라는 단어가 몇 페이지에 나오는지, '16GB'라는 단어가 몇 페이지에 나오는지 미리 정리해두는 거지.
GIN 인덱스는 다음과 같은 연산자들을 사용할 때 빛을 발해. 이 연산자들은 "포함 관계"나 "존재 여부"를 묻는 것들이야.
@>(contains): 왼쪽 JSONB가 오른쪽 JSONB를 포함하는가? **가장 중요하고 많이 쓰는 연산자!**<@(is contained by): `@>`의 반대. 왼쪽 JSONB가 오른쪽 JSONB에 포함되는가??(exists): 주어진 `text`가 최상위 키로 존재하는가??|(exists any): 주어진 `text` 배열의 키 중 하나라도 최상위 키로 존재하는가??&(exists all): 주어진 `text` 배열의 모든 키가 최상위 키로 존재하는가?
자, 그럼 GIN 인덱스를 만들어보자. `details` 컬럼 전체에 대해 GIN 인덱스를 거는 거야.
CREATE INDEX idx_products_details_gin ON products USING GIN (details);
이 한 줄이면 준비 끝! 이제 GIN 인덱스를 활용하는 쿼리를 날려보자.
-- 'EXPLAIN ANALYZE'로 쿼리 실행 계획 보기
EXPLAIN ANALYZE
SELECT name FROM products
WHERE details @> '{"brand": "AwesomeBook"}';
이 쿼리를 인덱스 생성 전후에 각각 실행해보면, 실행 계획(Execution Plan)이 완전히 달라지는 걸 볼 수 있어. 인덱스가 없을 땐 `Seq Scan on products` (순차 스캔)라고 나오지만, 인덱스를 만든 후에는 `Bitmap Heap Scan on products`와 `Bitmap Index Scan on idx_products_details_gin` 같은 문구가 보일 거야. 이게 바로 인덱스를 탔다는 증거! 실행 시간(execution time)도 극적으로 줄어드는 걸 두 눈으로 확인할 수 있지.
GIN 인덱스는 정말 강력해. 중첩된 객체나 배열 안의 값도 잘 찾아줘.
-- CPU가 'i7-12700H'인 상품 찾기
SELECT name FROM products
WHERE details @> '{"specs": {"cpu": "i7-12700H"}}';
-- 태그에 '가성비'가 포함된 상품 찾기
SELECT name FROM products
WHERE details @> '{"tags": ["가성비"]}';
이 모든 쿼리가 GIN 인덱스 덕분에 아주 빠르게 실행돼. **"JSONB 안의 내용으로 무언가를 찾고 싶다"면 일단 GIN 인덱스를 거는 게 국룰**이야.
2. B-tree 인덱스: 특정 값의 범위 검색 전문가
GIN 인덱스가 만능처럼 보이지만, 약점도 있어. 바로 등호(`=`)나 부등호(`<`, `>`)를 사용한 **범위 검색**에는 최적화되어 있지 않다는 거야. 예를 들어 "가격이 100만원 이상인 상품" 같은 쿼리 말이지.
이럴 때 등장하는 게 바로 **B-tree 인덱스**야. 우리가 흔히 아는 그 인덱스! 하지만 JSONB 컬럼 자체에는 B-tree 인덱스를 걸 수 없어. 대신 **특정 경로의 값을 추출한 표현식(expression)**에 대해 B-tree 인덱스를 걸 수 있어.
아까 `WHERE` 절에서 썼던 `(details->>'price')::numeric` 기억나? 바로 이 표현식에 인덱스를 거는 거야.
CREATE INDEX idx_products_price_btree ON products (((details->>'price')::numeric));
괄호를 두 번 쓰는 게 좀 이상해 보이지만, 표현식에 인덱스를 걸 때 사용하는 PostgreSQL 문법이니 그냥 따라 하면 돼. 이렇게 인덱스를 만들고 나면, 아래와 같은 쿼리의 속도가 비약적으로 향상돼.
EXPLAIN ANALYZE
SELECT name, details->>'price'
FROM products
WHERE (details->>'price')::numeric > 1000000;
실행 계획을 보면 `Index Scan using idx_products_price_btree` 라고 나오면서 B-tree 인덱스를 아주 잘 활용하는 걸 볼 수 있을 거야. 정렬(`ORDER BY`)을 할 때도 이 인덱스가 사용돼서 속도가 빨라져.
GIN vs B-tree, 언제 뭘 쓸까?
GIN 인덱스:
- **언제?** JSON 문서의 구조나 내용이 다양하고, 어떤 키/값이 들어올지 예측하기 어려울 때.
- **어떤 쿼리?** "이 키가 존재해?", "이 값을 포함하고 있어?" (`@>`, `?` 등)B-tree 인덱스 (표현식):**
- **언제?** 항상 정해진 경로에 있는 특정 값으로 검색이나 정렬을 자주 할 때.
- **어떤 쿼리?** "가격이 X보다 크다/작다", "카테고리가 'fashion'이다" (`>`, `<`, `=`, `ORDER BY`)
똑똑한 개발자는 이 둘을 섞어 써. 일반적인 검색을 위해 GIN 인덱스를 기본으로 깔아두고, 유독 자주 쓰는 필터링/정렬 조건이 있다면 해당 경로에 B-tree 인덱스를 추가하는 거지. 이런 지식이 바로 너의 경쟁력이 되는 거야.
3. GIN 인덱스 최적화: `jsonb_path_ops`
GIN 인덱스는 강력하지만, 단점도 있어. 인덱스 크기가 꽤 크고, 생성/업데이트하는 데 시간이 좀 걸린다는 거야. JSONB 문서 안의 모든 키와 값을 다 색인해야 하니까.
만약 네가 GIN 인덱스의 여러 기능 중 `@>` 연산자만 주로 사용한다면, 더 가볍고 빠른 GIN 인덱스를 만들 수 있어. 바로 `jsonb_path_ops`라는 특별한 연산자 클래스를 사용하는 거야.
-- 기존 GIN 인덱스 삭제
DROP INDEX idx_products_details_gin;
-- jsonb_path_ops를 사용한 GIN 인덱스 생성
CREATE INDEX idx_products_details_gin_path_ops ON products USING GIN (details jsonb_path_ops);
`jsonb_path_ops` GIN 인덱스는 값(value)은 색인하지 않고, 오직 키와 경로 정보만 색인해. 그래서 인덱스 크기가 훨씬 작고, 관리 부담도 적어. 대신 `?` (키 존재 여부) 같은 연산자는 지원하지 않고 오직 `@>` 연산자의 성능 향상에만 집중해. 하지만 실무에서 JSONB 검색은 대부분 `@>` 연산자로 해결되기 때문에, **`jsonb_path_ops`는 성능과 효율성을 모두 잡는 아주 좋은 선택지**가 될 수 있어.
이런 것까지 된다고? - JSONB 고급 함수와 패턴
기본적인 CRUD와 인덱싱에 익숙해졌다면, 이제 JSONB를 한 단계 더 깊게 활용해 볼 시간이야. PostgreSQL은 JSONB 데이터를 마치 관계형 데이터처럼 다룰 수 있게 해주는 강력한 함수들을 제공해. 이걸 잘 쓰면 정말 복잡한 요구사항도 깔끔한 SQL로 해결할 수 있어.
1. JSON 펼치기: `jsonb_each`, `jsonb_array_elements`
가끔은 JSON 객체의 모든 키-값 쌍을 행으로 펼쳐서 보거나, JSON 배열의 모든 원소를 각각의 행으로 만들고 싶을 때가 있어. 이럴 때 사용하는 함수들이야.
jsonb_each(jsonb)/jsonb_each_text(jsonb): JSON 객체를 (key, value) 행의 집합으로 변환. `_text`가 붙으면 value가 `text` 타입으로 나와.jsonb_array_elements(jsonb)/jsonb_array_elements_text(jsonb): JSON 배열을 (value) 행의 집합으로 변환. `_text`가 붙으면 value가 `text` 타입으로 나와.
이 함수들은 보통 `LATERAL JOIN`과 함께 쓰여. 각 `products` 행에 대해 함수를 실행해서 새로운 가상의 테이블을 만드는 거지.
-- '고성능 노트북 15인치'의 모든 스펙을 키-값 쌍으로 펼쳐보기
SELECT p.name, s.key, s.value
FROM products p,
LATERAL jsonb_each_text(p.details->'specs') AS s
WHERE p.name = '고성능 노트북 15인치';
-- 결과
-- name | key | value
-- --------------------+---------+---------------
-- 고성능 노트북 15인치 | cpu | i7-12700H
-- 고성능 노트북 15인치 | ram | 16GB DDR5
-- 고성능 노트북 15인치 | storage | 1TB NVMe SSD
-- '코딩 중독 티셔츠'의 모든 사이즈를 각각의 행으로 펼쳐보기
SELECT p.name, s.size
FROM products p,
LATERAL jsonb_array_elements_text(p.details->'sizes') AS s(size)
WHERE p.name = '코딩 중독 티셔츠';
-- 결과
-- name | size
-- ------------------+------
-- 코딩 중독 티셔츠 | S
-- 코딩 중독 티셔츠 | M
-- 코딩 중독 티셔츠 | L
-- 코딩 중독 티셔츠 | XL
이게 왜 강력하냐고? 이렇게 펼쳐진 데이터에 대해 `GROUP BY`, `COUNT`, `JOIN` 같은 관계형 연산을 자유롭게 적용할 수 있기 때문이야. 예를 들어 "가장 많이 사용된 상품 태그 TOP 5" 같은 통계를 낼 수도 있겠지.
2. 다시 JSON으로 묶기: `jsonb_agg`, `jsonb_object_agg`
펼치는 게 있다면, 다시 묶는 것도 있어야겠지? 여러 행의 데이터를 하나의 JSON 배열이나 객체로 합쳐주는 집계 함수들이야.
jsonb_agg(expression): 여러 행의 `expression` 결과를 모아서 하나의 JSON 배열로 만든다.jsonb_object_agg(key, value): 여러 행의 `key`와 `value`를 모아서 하나의 JSON 객체로 만든다.
이 함수들은 주로 **비정규화(Denormalization)**를 통해 읽기 성능을 최적화할 때 사용돼. 예를 들어, 사용자(users)와 그 사용자가 작성한 게시글(posts) 테이블이 있다고 가정해보자. 보통은 `JOIN`을 해서 가져오겠지만, `jsonb_agg`를 쓰면 사용자 정보에 최근 게시글 3개를 JSON 배열로 붙여서 저장해둘 수 있어.
-- 카테고리별로 상품 이름과 가격을 모아서 JSON 객체로 만들기
SELECT
details->>'category' AS category,
jsonb_object_agg(name, details->'price') AS products_info
FROM products
GROUP BY category;
-- 결과
-- category | products_info
-- -------------+-----------------------------------------------------------------------------------------
-- electronics | { "고성능 노트북 15인치" : 1450000, "플래그십 스마트폰" : 1200000 }
-- fashion | { "코딩 중독 티셔츠" : 29000 }
이런 식으로 데이터를 미리 가공해서 JSONB 컬럼에 저장해두면, 복잡한 `JOIN` 없이 한 번의 `SELECT`로 원하는 데이터를 가져올 수 있어서 애플리케이션의 응답 속도를 크게 향상시킬 수 있어. 물론 데이터 중복과 동기화 문제를 신경 써야 하지만, 읽기 작업이 압도적으로 많은 서비스에서는 아주 유용한 전략이야.
3. 궁극의 필터링, JSONPath
지금까지 배운 연산자들로도 대부분의 검색은 가능하지만, 정말 복잡하고 동적인 조건을 처리해야 할 때가 있어. 예를 들면 "태그 중에 '개발용'이면서, 스펙에 'ram' 키가 있고, 가격이 150만원 이하인 상품" 같은... 이런 복잡한 조건을 하나의 쿼리로 표현하고 싶을 때 **JSONPath**가 등장해.
JSONPath는 XML의 XPath에서 영감을 받은, JSON 구조를 탐색하고 필터링하기 위한 표준 언어야. PostgreSQL 12 버전부터 공식적으로 지원되기 시작했어.
JSONPath의 기본 문법은 이래.
$: 루트 객체.key: 하위 객체의 키로 접근[*]: 배열의 모든 원소? (filter expression): 필터 표현식
JSONPath는 @? (path exists) 또는 @@ (path matches) 연산자와 함께 사용돼.
-- 가격이 50만원에서 130만원 사이인 상품 찾기
SELECT name FROM products
WHERE details @? '$.price ? (@ >= 500000 && @ <= 1300000)';
-- 결과
-- name
-- ------------------
-- 플래그십 스마트폰
-- 태그 중에 '가성비'가 포함된 상품 찾기 (GIN 인덱스의 @>와 유사)
SELECT name FROM products
WHERE details @@ '$.tags[*] == "가성비"';
-- 결과
-- name
-- --------------------
-- 고성능 노트북 15인치
JSONPath는 배우는 데 시간이 좀 걸리지만, 한번 익혀두면 정말 어떤 복잡한 조건이라도 표현할 수 있는 강력한 무기가 돼. 특히 이런 복잡한 쿼리 로직을 애플리케이션 코드가 아닌 DB단에서 처리할 수 있다는 건 큰 장점이지. 혼자서 해결하기 너무 복잡하게 느껴진다면, **재능넷** 같은 플랫폼에서 PostgreSQL 전문가에게 커피 한 잔 값으로 자문을 구해보는 것도 시간을 아끼는 스마트한 방법이야. 전문가의 경험은 때로 몇 시간의 구글링보다 더 가치 있거든.
그래서 언제 써야 할까? - 올바른 사용처와 안티패턴
지금까지 JSONB의 화려한 기능들을 살펴봤어. 너무 좋아서 "와, 이제 모든 데이터를 그냥 JSONB에 다 때려 넣으면 되는 거 아냐?" 라고 생각할 수도 있어. 하지만 그건 아주 위험한 생각이야. JSONB는 만병통치약이 아니라, 특정 문제를 해결하는 데 탁월한 **'스페셜리스트'**거든. 언제 JSONB를 쓰고, 언제 쓰지 말아야 하는지를 아는 것이 진짜 실력이야.
이걸 이해하려면 **Schema-on-Write**와 **Schema-on-Read**라는 개념을 알아야 해.
Schema-on-Write (전통적 RDBMS): 데이터를 **쓰기(Write) 전에** 스키마(구조)를 정의하고, 모든 데이터는 그 스키마를 따라야 해. 데이터의 정합성과 무결성을 최우선으로 생각해. 엄격하지만 예측 가능하지.
Schema-on-Read (NoSQL, JSONB): 일단 데이터를 유연하게 **쓰고(Write)**, 나중에 **읽을(Read) 때** 데이터의 구조를 해석하고 처리해. 유연하고 빠르지만, 데이터가 지저분해지거나 쿼리가 복잡해질 수 있어.
JSONB는 PostgreSQL에 Schema-on-Read의 유연성을 부여해주는 도구야. 이걸 염두에 두고 사용처를 생각해보자.
👍 JSONB가 빛을 발하는 순간 (Best Use Cases)
반정형 데이터(Semi-structured Data) 저장소: 이게 가장 대표적인 용도야. 우리 예제처럼 상품의 속성이 제각각이거나, 사용자의 프로필 설정, IoT 기기에서 쏟아지는 다양한 형태의 센서 데이터, 애플리케이션 로그 등을 저장하기에 완벽해.
빠른 프로토타이핑과 애자일 개발: 서비스 초기 단계에는 데이터 모델이 계속 바뀌어. 그때마다 `ALTER TABLE`로 스키마를 바꾸는 건 정말 고된 일이야. 일단 핵심 데이터만 컬럼으로 잡고 나머지는 JSONB에 넣어서 빠르게 개발하고, 나중에 서비스가 안정화되면 자주 쓰는 필드를 정식 컬럼으로 승격시키는 전략(Promoting fields)을 사용할 수 있어.
읽기 성능을 위한 비정규화 캐시: 위에서 잠깐 언급했지? 복잡한 `JOIN`이 필요한 데이터를 미리 계산해서 JSONB 컬럼에 캐시처럼 저장해두는 거야. 블로그 글을 보여줄 때, 글 내용과 함께 댓글 목록, 태그 목록, 추천수 등을 하나의 JSONB에 담아두면 한 번의 쿼리로 페이지 전체를 렌더링할 수 있어. 데이터 동기화만 잘 관리한다면 성능 향상에 매우 효과적이야.
다양한 외부 API 응답 저장: 다른 서비스의 API를 호출하고 그 응답을 저장해야 할 때, 응답 형식이 언제 바뀔지 모르잖아? 그럴 때 응답 JSON 전체를 그대로 JSONB 컬럼에 저장해두면 아주 편리해.
만약 네가 만드는 서비스가 **재능넷**처럼 다양한 카테고리의 재능을 다룬다고 상상해봐. '개발 강의' 재능에 필요한 속성과 '타로점 상담' 재능에 필요한 속성은 완전히 다르겠지? '개발 강의'는 '사용 언어', '프레임워크' 같은 필드가 필요하고, '타로점'은 '상담 방식', '주요 스프레드' 같은 필드가 필요할 거야. 이 모든 걸 각각의 컬럼으로 만드는 건 비효율적이지. 이럴 때 공통 속성(가격, 전문가 ID 등)은 컬럼으로, 각 재능의 고유 속성은 JSONB 컬럼에 저장하면 정말 깔끔하게 모델링할 수 있어.
👎 JSONB를 피해야 할 순간 (Anti-Patterns)
핵심 관계형 데이터를 대체하려는 시도: 사용자 ID, 주문 번호, 은행 계좌 정보처럼 데이터의 정체성을 나타내고 다른 테이블과 강력한 관계(Foreign Key)를 맺어야 하는 데이터는 절대로 JSONB 안에 넣으면 안 돼. 이런 데이터는 고유한 컬럼으로 존재하며 RDBMS의 강력한 제약 조건과 트랜잭션 보호를 받아야 해.
PostgreSQL을 MongoDB처럼 쓰기: "JSONB가 좋으니까 모든 테이블을 `id, data jsonb` 딱 두 개의 컬럼으로 만들자!" 이건 최악의 생각이야. PostgreSQL의 장점인 관계형 모델링, 데이터 무결성, 트랜잭션 ACID를 모두 포기하는 것과 같아. JSONB는 RDBMS를 보완하는 도구이지, 대체하는 도구가 아니야.
JSON 내부의 모든 필드에 강력한 제약 조건이 필요할 때: JSONB 필드에 `CHECK` 제약 조건을 걸어서 특정 키가 항상 존재하게 하거나 값의 타입을 검증할 수는 있어. 하지만 그 로직이 복잡해지고 여러 필드에 걸쳐 있다면, 그건 그 필드들이 더 이상 '유연한 데이터'가 아니라 '고정된 스키마'라는 신호야. 그럴 땐 JSONB에서 꺼내서 정식 컬럼으로 만드는 게 맞아.
데이터 분석/집계가 매우 복잡하고 중요할 때: JSONB 함수들로도 많은 집계가 가능하지만, 데이터가 수억 건을 넘어가고 복잡한 통계 분석이 주된 작업이라면 전통적인 방식의 정규화된 스키마가 훨씬 더 효율적이고 관리하기 편해.
결론: 하이브리드 모델이 정답이다!
최고의 전략은 **RDBMS의 안정성**과 **JSONB의 유연성**을 결합하는 **하이브리드 모델**이야. 데이터의 성격을 잘 파악해서, 정형적이고 관계가 중요한 데이터는 일반 컬럼에, 비정형적이고 유연해야 하는 데이터는 JSONB 컬럼에 저장하는 거지. 이렇게 두 세계의 장점만 쏙쏙 빼먹는 것이 현명한 데이터베이스 설계의 핵심이야.
정리하며: JSONB, 너 내 동료가 돼라!
와, 정말 긴 여정이었어. PostgreSQL의 JSONB에 대해 정말 A부터 Z까지 깊게 파헤쳐 본 것 같아. 오늘 우리가 함께 배운 내용들을 다시 한번 정리해볼까?
JSONB는 텍스트가 아닌 **최적화된 바이너리**로 JSON을 저장하여, **빠른 조회와 인덱싱**이 가능하다.
->>, `#>>` 같은 연산자로 값을 `text`로 꺼내고,@>같은 연산자로 **포함 관계**를 검색한다.성능을 위해서는 **인덱스가 필수!** 일반적인 검색엔 **GIN 인덱스**, 특정 값의 범위 검색/정렬엔 **B-tree 인덱스**를 사용한다.
jsonb_each,jsonb_agg같은 고급 함수를 이용하면 JSON 데이터를 관계형 데이터처럼 다루거나, 비정규화를 통해 성능을 최적화할 수 있다.JSONB는 만병통치약이 아니다. **RDBMS의 장점과 결합한 하이브리드 모델**이 가장 이상적인 활용법이다.
이제 너는 JSONB라는 강력한 무기를 손에 넣었어. 더 이상 스키마 변경에 대한 두려움 때문에 새로운 아이디어를 시도하길 망설이지 않아도 돼. 변화에 유연하게 대응하면서도, PostgreSQL이라는 든든한 RDBMS의 안정성 위에서 서비스를 구축할 수 있게 된 거야.
물론 오늘 배운 내용이 전부는 아니야. 더 깊이 들어가면 JSONB 데이터에 대한 유효성 검사, 트리거 활용 등 더 많은 고급 기법들이 있어. 하지만 오늘 배운 것만 제대로 소화해도 실무에서 마주치는 대부분의 문제를 해결하고, 동료들에게 "오, 이 친구 DB 좀 아는데?" 하는 소리를 들을 수 있을 거라고 확신해.
가장 중요한 건 직접 해보는 거야. 작은 토이 프로젝트든, 회사 업무의 일부든, 오늘 배운 내용을 적용해서 직접 테이블을 만들고 쿼리를 날려봐. `EXPLAIN ANALYZE`로 인덱스의 효과를 눈으로 확인하는 짜릿함을 느껴보고, 복잡했던 로직이 JSONB 함수 하나로 깔끔하게 정리되는 마법을 경험해봐. 그 경험들이 쌓여 너를 더 나은 개발자로 만들어 줄 거야.
데이터의 세계는 계속해서 진화하고 있어. 관계형 모델과 NoSQL 모델의 경계는 점점 허물어지고, 두 진영의 장점을 모두 흡수한 '하이브리드 DB'가 대세가 되고 있지. 그 중심에 있는 PostgreSQL과 JSONB를 마스터하는 것은, 이 변화의 물결에 올라타는 가장 확실한 방법 중 하나일 거야. 자, 이제 너의 데이터베이스에 JSONB라는 최고의 동료를 영입할 시간이야!
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.