DB와 JSON

Post image

RDBMS(관계형 데이터베이스)의 중요한 목적 중 하나는 데이터의 무결성을 보장하는 것이다. 이를 위해 RDBMS는 컬럼 기반의 정형 스키마와 정규화된 구조를 바탕으로 데이터를 저장한다. 이러한 방식은 데이터 중복을 최소화하고, 제약조건과 관계 모델링을 통해 일관된 상태를 유지하는 데 효과적이다.

그러나 현실 세계의 데이터가 항상 고정된 컬럼 구조에 적합한 것은 아니다. 특히 파일 메타데이터와 같이 데이터의 속성이 자주 변경되거나, 객체마다 서로 다른 필드를 가지는 경우에는 전통적인 정규화 방식만으로 이를 표현하는 데 한계가 있다. 모든 속성을 컬럼으로 정의할 경우 테이블 구조가 과도하게 비대해지고, 사용되지 않는 컬럼과 NULL 값이 증가하며, 이에 따른 처리 복잡성도 함께 커지게 된다.

이러한 문제를 완화하기 위해, 실제 시스템에서는 공통적으로 관리해야 하는 핵심 속성만 관계형 스키마로 정의하고, 가변적인 속성은 JSON 형태로 저장하는 방식을 자주 사용한다. 이는 관계형 데이터베이스의 무결성과 구조적 장점을 유지하면서도, 반정형 데이터에 대한 유연한 표현을 가능하게 한다.

또한 RDBMS는 버전이 발전함에 따라 JSON 데이터를 단순 저장하는 수준을 넘어, JSON 내부 필드에 대한 조회, 함수 기반 처리, 인덱싱과 같은 기능을 점차 강화해 왔다. 그 결과 현대의 RDBMS는 정형 데이터와 반정형 데이터를 하나의 저장소 내에서 함께 다룰 수 있는 방향으로 확장되고 있다.

MYSQL과 JSON

MySQL은 5.7 버전부터 네이티브 JSON 타입을 지원한다.
이는 JSON 데이터를 단순히 TEXT로 저장하는 방식과 비교했을 때 몇 가지 뚜렷한 차이가 있다.


1. 자동 유효성 검사

MySQL의 JSON 타입은 저장 시점에 JSON 문서의 유효성을 자동으로 검사한다.
따라서 형식이 잘못된 JSON 데이터는 저장되지 않고 에러가 발생한다.

2. 내부 바이너리 형식으로 저장

MySQL은 JSON 데이터를 단순 문자열 그대로 저장하지 않고,
내부 바이너리 형식으로 변환하여 저장한다.

이 방식의 장점은 다음과 같다.

  • 매번 문자열 전체를 파싱하지 않아도 된다.
  • 특정 키나 배열 인덱스에 더 빠르게 접근할 수 있다.
  • 중첩된 값이나 하위 객체를 보다 효율적으로 조회할 수 있다.
3. 다양한 JSON 함수 지원

MySQL은 JSON 타입 자체뿐 아니라 JSON 데이터를 다루기 위한
다양한 JSON 함수도 함께 제공한다.


하지만 인덱스 측면에서는 주의할 점도 있다. MySQL의 JSON 컬럼은 일반 스칼라 컬럼처럼 직접 인덱스를 걸 수 없다. 대신 JSON 내부의 특정 값을 꺼내는 생성 칼럼(generated column) 을 만든 뒤, 그 칼럼에 인덱스를 생성하는 방식으로 최적화한다.

PostgreSQL과 JSON

PostgreSQL은 JSON 데이터를 저장하기 위해 jsonjsonb 두 가지 타입을 제공한다.
두 타입 모두 JSON 데이터를 저장할 수 있지만, 가장 큰 차이는 저장 방식조회 효율에 있다.

  • json
    • 원문 텍스트를 그대로 저장.
  • jsonb
    • 분해된 바이너리 형식으로 저장.
    • 저장 시 변환 비용이 들지만, 이후 조회와 연산 성능이 더 우수.
    • 인덱스 지원.

대부분의 경우 jsonb를 사용하는 편이 더 적합하다.

인덱싱

MySQL과 PostgreSQL의 가장 큰 차이 중 하나는 JSON 데이터에 대한 인덱싱 지원 방식이다.

B-tree / Hash 인덱스

PostgreSQL의 jsonbB-treeHash 인덱스를 지원한다.
B-tree와 Hash는 보통 JSON 문서 전체를 하나의 값처럼 비교할 때 유용하다. 공식 문서도 jsonb에 대한 B-tree/Hash 인덱스는 대체로 완전한 JSON 문서의 동등성 확인에 적합하다고 설명한다.

인덱스 생성 예시는 다음과 같다.

CREATE INDEX idx_btree_jdoc ON api USING BTREE (jdoc);  
CREATE INDEX idx_hash_jdoc  ON api USING HASH (jdoc);

하지만 이런 인덱스는 JSON 내부 구조를 검색하는 용도에는 적합하지 않다.
그런 경우에는 GIN 인덱스를 사용하는 것이 맞다.

GIN 인덱스

PostgreSQL의 jsonb 컬럼에는 GIN 인덱스를 생성할 수 있다.
이를 통해 JSON 내부의 키, 값, 포함 관계, 그리고 jsonpath 조건 검색까지 효율적으로 처리할 수 있다.

또한 GIN 인덱스는 하나만 있는 것이 아니라, 목적에 따라 두 가지 연산자 클래스를 선택할 수 있다.

  • jsonb_ops
    • 기본값.
    • 많은 연산자를 지원.
  • jsonb_path_ops
    • 비기본.
    • 지원하는 연산자는 더 적다.
    • 대신 인덱스가 더 작고, 특정 검색에서는 더 빠를 수 있음.
jsonb_ops (기본값)

지원하는 주요 연산자는 다음과 같다.

  • ?
    지정한 키 또는 문자열 요소가 존재하는지 확인한다.
    예시:
        data ? 'name'
    
    → JSON 객체에 name 키가 있으면 참이다.
  • ?|
    여러 키(또는 문자열 요소) 중 하나라도 존재하는지 확인한다.
    예시:
    data ?| array['name', 'email']
    
    name 또는 email 중 하나라도 있으면 참이다.
  • ?&
    여러 키(또는 문자열 요소)가 모두 존재하는지 확인한다.
    예시:
    data ?& array['name', 'email']
    
    nameemail이 모두 있어야 참이다.
  • @>
    왼쪽 JSON이 오른쪽 JSON을 포함(containment) 하는지 확인한다.
    예시:
    data @> '{"status": "active"}'
    
    → JSON 내부에 "status": "active" 구조가 포함되어 있으면 참이다.
  • @?
    JSONPath 조건식에 일치하는 항목이 존재하는지 확인한다.
    예시:
    data @? '$.items[*] ? (@.price > 100)'
    
    items 배열 안에 price > 100 인 요소가 하나라도 있으면 참이다.
  • @@
    JSONPath 조건식 자체를 평가하여 boolean 결과를 반환한다.
    예시:
    data @@ '$.age > 20'
    
    → JSON의 age 값이 20보다 크면 참이다.

인덱스 생성 예시는 다음과 같다.

CREATE INDEX idxgin ON api USING GIN (jdoc);
jsonb_path_ops

jsonb_path_ops는 더 제한적인 대신, 특정 상황에서 더 효율적인 선택지다. 지원하는 주요 연산자는 다음과 같다.

  • @>
  • @?
  • @@

키 존재 여부를 확인하는 ?, ?|, ?& 연산자는 지원하지 않는다.

인덱스 생성 예시는 다음과 같다.

CREATE INDEX idxginp ON api USING GIN (jdoc jsonb_path_ops);
인덱스를 통한 검색

api 테이블에 jdoc jsonb 컬럼이 있고, 여기서 company 값이 "Magnafone"인 문서를 찾는다고 하자.

SELECT jdoc->'guid', jdoc->'name'  
FROM api  
WHERE jdoc @> '{"company": "Magnafone"}';

이 쿼리는 jdoc 컬럼 자체에 @> 연산을 적용하고 있으니 jdoc에 생성된 GIN 인덱스를 활용할 수 있다.

벤치마킹

벤치마킹 환경

이번 벤치마크는 다음 환경에서 수행했다.

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2)
AMD Ryzen 5 5600X 3.70GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.102
  [Host]     : .NET 9.0.12 (9.0.12, 9.0.1225.60609), X64 RyuJIT x86-64-v3
  Job-QIUXEI : .NET 9.0.12 (9.0.12, 9.0.1225.60609), X64 RyuJIT x86-64-v3
벤치마킹 시나리오

1. 비교 대상

  • MySQL JSON
  • PostgreSQL jsonb 인덱스 없음
  • PostgreSQL jsonb + GIN 인덱스

2. 데이터

  • Bogus로 동일한 구조의 JSON 문서를 생성
  • 테스트 건수는 10 / 1,000 / 10,000 / 100,000

3. 측정 작업

  • Insert: 생성한 JSON 문서를 각 DB 테이블에 대량 삽입
  • Update: 특정 조건에 맞는 JSON 문서를 찾아 값을 수정
  • Query: 같은 조건으로 JSON 내부 값을 조회
테스트에 사용한 JSON 구조
public class BenchmarkDocument  
{  
    public required string DocumentId { get; init; }  
    public required string Category { get; init; }  
    public required string Name { get; init; }  
    public required string Description { get; init; }  
    public required Profile Profile { get; init; }  
    public required Metrics Metrics { get; init; }  
    public required Flags Flags { get; init; }  
    public required string[] Tags { get; init; }  
    public required DateTime UpdatedAt { get; init; }  
}  
  
public sealed class Profile  
{  
    public required string Tier { get; init; }  
    public required string Region { get; init; }  
    public required string OwnerEmail { get; init; }  
}  
  
public sealed class Metrics  
{  
    public required int Score { get; init; }  
    public required int Stock { get; init; }  
    public required decimal Amount { get; init; }  
}  
  
public sealed class Flags  
{  
    public required bool Reviewed { get; init; }  
    public required bool Featured { get; init; }  
}
{
  "documentId": "doc-0001",
  "category": "electronics",
  "name": "Wireless Headphones X100",
  "description": "Noise-cancelling wireless headphones with 30 hours battery life.",
  "profile": {
    "tier": "premium",
    "region": "ap-northeast-2",
    "ownerEmail": "owner@example.com"
  },
  "metrics": {
    "score": 87,
    "stock": 142,
    "amount": 199.99
  },
  "flags": {
    "reviewed": true,
    "featured": false
  },
  "tags": ["audio", "wireless", "noise-cancelling", "bluetooth"],
  "updatedAt": "2026-03-09T20:15:00Z"
}

INSERT

SQL
MySqlInsert 
INSERT INTO benchmark_mysql (payload) VALUES (CAST(@Payload AS JSON));

PostgreSqlNoIndexInsert
INSERT INTO benchmark_pg_noindex (payload) VALUES (CAST(@Payload AS jsonb));

PostgreSqlGinInsert
INSERT INTO benchmark_pg_gin (payload) VALUES (CAST(@Payload AS jsonb));
결과
결과값 열기 / 닫기
MethodRowCountMeanErrorStdDevMedianMinMaxGen0Allocated
'Insert - PostgreSQL jsonb (GIN)'1010.88 ms8.192 ms0.449 ms10.71 ms10.54 ms11.39 ms-15.44 KB
'Insert - MySQL JSON'1016.12 ms9.107 ms0.499 ms15.94 ms15.73 ms16.68 ms-26.36 KB
'Insert - PostgreSQL jsonb (No GIN)'1019.80 ms290.174 ms15.905 ms11.06 ms10.18 ms38.16 ms-15.64 KB
'Insert - PostgreSQL jsonb (GIN)'1000695.73 ms405.566 ms22.230 ms688.04 ms678.37 ms720.79 ms-1072.83 KB
'Insert - PostgreSQL jsonb (No GIN)'1000700.01 ms224.318 ms12.296 ms698.81 ms688.36 ms712.86 ms-1080.64 KB
'Insert - MySQL JSON'1000938.38 ms603.450 ms33.077 ms921.15 ms917.48 ms976.52 ms-1928.66 KB
'Insert - PostgreSQL jsonb (No GIN)'100007,052.34 ms1,255.910 ms68.841 ms7,050.34 ms6,984.53 ms7,122.17 ms-10783.64 KB
'Insert - PostgreSQL jsonb (GIN)'100007,299.68 ms145.755 ms7.989 ms7,301.71 ms7,290.87 ms7,306.45 ms-10705.82 KB
'Insert - MySQL JSON'100008,310.88 ms10,085.672 ms552.830 ms8,038.33 ms7,947.25 ms8,947.07 ms1000.000019224.98 KB
'Insert - PostgreSQL jsonb (GIN)'10000071,595.40 ms89,859.219 ms4,925.487 ms74,359.54 ms65,908.67 ms74,517.98 ms6000.0000107034.21 KB
'Insert - PostgreSQL jsonb (No GIN)'10000071,612.43 ms4,839.844 ms265.288 ms71,648.69 ms71,330.88 ms71,857.73 ms6000.0000107815.46 KB
'Insert - MySQL JSON'10000077,690.55 ms6,776.047 ms371.418 ms77,706.88 ms77,311.24 ms78,053.54 ms11000.0000192194.38 KB

insert_benchmark

  • Insert 성능은 전반적으로 PostgreSQL jsonb가 MySQL JSON보다 우세
  • jsonbGIN 인덱스를 추가해도 Insert 성능 저하는 제한적
  • 대량 삽입 구간에서는 GIN 적용 시 실행 편차가 더 커지는 모습
  • MySQL JSON은 모든 구간에서 더 느렸고, 클라이언트 측 메모리 할당량도 더 큼

Query

SQL
MySqlQuery
SELECT COUNT(*)  
FROM benchmark_mysql  
WHERE JSON_UNQUOTE(JSON_EXTRACT(payload, '$.profile.tier')) = @Tier;  
  
PostgreSqlNoIndexQuery
SELECT COUNT(*)  
FROM benchmark_pg_noindex  
WHERE payload @> CAST(@FilterJson AS jsonb); 
  
PostgreSqlGinQuery 
SELECT COUNT(*)  
FROM benchmark_pg_gin  
WHERE payload @> CAST(@FilterJson AS jsonb);
결과
결과값 열기 / 닫기
MethodRowCountMeanErrorStdDevMinMaxMedianAllocated
'Query - PostgreSQL jsonb (No GIN)'101.167 ms1.181 ms0.0647 ms1.101 ms1.230 ms1.169 ms3.7 KB
'Query - PostgreSQL jsonb (GIN)'101.177 ms2.193 ms0.1202 ms1.051 ms1.291 ms1.189 ms3.51 KB
'Query - PostgreSQL jsonb (No GIN)'10001.630 ms3.227 ms0.1769 ms1.430 ms1.766 ms1.693 ms3.13 KB
'Query - PostgreSQL jsonb (GIN)'10001.878 ms3.128 ms0.1715 ms1.694 ms2.034 ms1.907 ms2.82 KB
'Query - MySQL JSON'102.119 ms4.400 ms0.2412 ms1.883 ms2.365 ms2.109 ms5.45 KB
'Query - MySQL JSON'10002.556 ms1.259 ms0.0690 ms2.487 ms2.625 ms2.556 ms6.38 KB
'Query - PostgreSQL jsonb (No GIN)'100004.742 ms6.853 ms0.3756 ms4.523 ms5.176 ms4.527 ms3.13 KB
'Query - PostgreSQL jsonb (GIN)'100004.883 ms7.160 ms0.3924 ms4.619 ms5.334 ms4.695 ms2.95 KB
'Query - MySQL JSON'100007.827 ms12.241 ms0.6710 ms7.184 ms8.523 ms7.773 ms6.38 KB
'Query - PostgreSQL jsonb (GIN)'10000036.106 ms44.767 ms2.4538 ms33.909 ms38.754 ms35.656 ms3.47 KB
'Query - PostgreSQL jsonb (No GIN)'10000037.393 ms57.768 ms3.1665 ms33.738 ms39.324 ms39.116 ms3.6 KB
'Query - MySQL JSON'10000061.964 ms316.713 ms17.3601 ms51.674 ms82.007 ms52.211 ms6.38 KB

query_benchmark

  • PostgreSQL jsonb가 전 구간에서 MySQL JSON보다 빠름
  • 데이터 건수가 커질수록 PostgreSQL과 MySQL의 격차가 더 분명
  • 대규모 데이터에서는 GIN이 소폭 유리한 결과
  • PostgreSQL은 인덱스 없이도 충분히 강한 조회 성능
  • MySQL은 평균 응답 시간뿐 아니라 메모리 할당량도 더 크게 나타남.

UPDATE

SQL
MySqlUpdate
UPDATE benchmark_mysql  
SET payload = JSON_SET(  
    payload,    '$.flags.reviewed', TRUE,    '$.metrics.score', CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.metrics.score')) AS UNSIGNED) + 1,    '$.updatedAt', @UpdatedAt)  
WHERE JSON_UNQUOTE(JSON_EXTRACT(payload, '$.profile.tier')) = @Tier;
  
PostgreSqlNoIndexUpdate
UPDATE benchmark_pg_noindex  
SET payload = jsonb_set(  
    jsonb_set(        jsonb_set(payload, '{flags,reviewed}', 'true'::jsonb, true),        '{metrics,score}',        to_jsonb(((payload #>> '{metrics,score}')::int + 1)),        true    ),    '{updatedAt}',    to_jsonb(CAST(@UpdatedAt AS text)),    true)  
WHERE payload @> CAST(@FilterJson AS jsonb);
  
PostgreSqlGinUpdate
UPDATE benchmark_pg_gin  
SET payload = jsonb_set(  
    jsonb_set(        jsonb_set(payload, '{flags,reviewed}', 'true'::jsonb, true),        '{metrics,score}',        to_jsonb(((payload #>> '{metrics,score}')::int + 1)),        true    ),    '{updatedAt}',    to_jsonb(CAST(@UpdatedAt AS text)),    true)  
WHERE payload @> CAST(@FilterJson AS jsonb);
결과
결과값 열기 / 닫기
MethodRowCountMeanErrorStdDevMedianMinMaxAllocated
'Update - PostgreSQL jsonb (No GIN)'102.490 ms5.599 ms0.3069 ms2.502 ms2.178 ms2.791 ms4.2 KB
'Update - PostgreSQL jsonb (GIN)'102.579 ms4.978 ms0.2729 ms2.547 ms2.323 ms2.866 ms4.14 KB
'Update - MySQL JSON'106.998 ms10.375 ms0.5687 ms7.162 ms6.365 ms7.466 ms6.19 KB
'Update - PostgreSQL jsonb (No GIN)'10008.758 ms20.583 ms1.1282 ms9.157 ms7.485 ms9.633 ms3.51 KB
'Update - PostgreSQL jsonb (GIN)'100010.077 ms23.819 ms1.3056 ms10.450 ms8.626 ms11.156 ms3.68 KB
'Update - MySQL JSON'100012.995 ms15.461 ms0.8475 ms13.264 ms12.046 ms13.675 ms5.55 KB
'Update - PostgreSQL jsonb (No GIN)'1000039.891 ms23.417 ms1.2835 ms39.372 ms38.948 ms41.352 ms3.81 KB
'Update - MySQL JSON'1000056.704 ms326.157 ms17.8778 ms46.828 ms45.944 ms77.341 ms5.91 KB
'Update - PostgreSQL jsonb (GIN)'1000064.534 ms89.235 ms4.8913 ms66.715 ms58.932 ms67.957 ms3.8 KB
'Update - PostgreSQL jsonb (No GIN)'100000538.435 ms2,743.160 ms150.3619 ms615.209 ms365.185 ms634.910 ms3.51 KB
'Update - MySQL JSON'100000631.533 ms4,656.165 ms255.2201 ms487.963 ms480.432 ms926.203 ms5.91 KB
'Update - PostgreSQL jsonb (GIN)'1000001,382.050 ms3,623.424 ms198.6121 ms1,465.259 ms1,155.367 ms1,525.524 ms3.8 KB

query_benchmark

  • 전반적인 Update 성능은 PostgreSQL jsonb (No GIN)이 가장 좋게 나타남
  • GIN 인덱스는 Update 성능에 명확한 쓰기 오버헤드를 만듦
  • 대규모 Update에서는 GIN의 비용은 매우 큼
  • 이번 결과는 “조회 최적화용 인덱스가 쓰기 성능을 희생한다”는 점을 잘 보여줌

벤치마크 총평

이번 벤치마크를 통해 MySQL JSON, PostgreSQL jsonb, 그리고 PostgreSQL jsonb + GIN의 차이는 단순히 “어느 쪽이 더 빠른가”보다, 어떤 작업을 더 중요하게 보느냐에 따라 선택이 달라진다는 점이 분명하게 드러났다.

먼저 Insert에서는 PostgreSQL jsonb가 전반적으로 MySQL JSON보다 더 빠른 성능을 보였고, GIN 인덱스를 추가하더라도 쓰기 오버헤드는 예상보다 크지 않았다. 즉, 단순 적재 관점에서는 PostgreSQL 쪽이 유리했고, GIN 인덱스도 Insert 단계에서는 비교적 감당 가능한 비용으로 보였다.

반면 Query에서는 PostgreSQL jsonb가 전 구간에서 MySQL보다 더 좋은 성능을 보였다. 다만 이번 실험에서 GIN 인덱스의 효과는 기대만큼 극적으로 드러나지 않았다. 이는 사용한 쿼리 패턴이 GIN의 장점을 최대한 끌어내는 형태가 아니었기 때문이며, 현재 결과는 PostgreSQL jsonb 자체의 조회 성능이 이미 충분히 우수하다는 점을 더 강하게 보여준다. 즉, PostgreSQL은 인덱스가 없어도 강했고, GIN은 특정 조건에서만 추가 이점을 제공하는 모습이었다.

가장 큰 차이는 Update에서 나타났다. PostgreSQL jsonb는 인덱스가 없을 때 가장 좋은 Update 성능을 보였지만, GIN 인덱스를 추가하면 데이터 규모가 커질수록 쓰기 비용이 크게 증가했다. 특히 대량 Update에서는 GIN 유지 비용이 매우 크게 드러나, 조회 최적화를 위한 인덱스가 쓰기 성능에는 분명한 페널티를 만든다는 점이 확인됐다. 이 부분은 JSON 문서를 자주 수정하는 시스템에서 매우 중요한 판단 기준이 된다.

You May Also Like