데이터 베이스/설계 2편, 실무에서 반드시 마주치는 9가지 설계 패턴

Ch08. JSON 설계 - JSON 설계의 장단점과 한계

webmaster 2026. 8. 1. 12:26

JSON 설계의 장점

스키마 유연성

DROP TABLE IF EXISTS product_flex;
CREATE TABLE product_flex (
    product_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(200) NOT NULL,
    category VARCHAR(100) NOT NULL,
    attributes JSON,
    created_at DATETIME NOT NULL DEFAULT NOW()
);
-- 초기 상품 등록
INSERT INTO product_flex (name, category, attributes) VALUES
('갤럭시 S24', '스마트폰', '{"brand": "삼성", "price": 1200000}');

-- 나중에 새로운 속성 추가 - ALTER TABLE 불필요
INSERT INTO product_flex (name, category, attributes) VALUES
('갤럭시 S25', '스마트폰', '{"brand": "삼성", "price": 1300000, "ai_features": ["통역", "요약", "검색"], "satellite_sos": true}');

-- 두 상품 모두 정상 조회
SELECT name, attributes->>'$.brand' AS brand, attributes->>'$.ai_features' AS ai_features
FROM product_flex;

쿼리 결과

  • 일반 컬럼이었다면 "ALTER TABLE ADD COLUMN"이 필요했을 것이다.
  • 대용량 테이블에서 "ALTER TABLE"은 상당한 시간이 소요될 수 있다.

복잡한 중첩 구조 표현

-- 일반 관계형 모델: 3개 테이블 필요
-- product, product_spec, product_spec_detail
-- JSON: 하나의 컬럼에 모두 표현
INSERT INTO product_flex (
    name, 
    category, 
    attributes
) 
VALUES (
    '맥북 프로 16',
    '노트북', 
    JSON_OBJECT(
        'brand', '애플',
        'price', 3500000,
        'specs', JSON_OBJECT(
            'display', JSON_OBJECT(
                'size', 16.2,
                'resolution', '3456x2234',
                'technology', 'Liquid Retina XDR',
                'refresh_rate', 120
            ),
            'processor', JSON_OBJECT(
                'name', 'M3 Max',
                'cores', JSON_OBJECT(
                    'performance', 12, 
                    'efficiency', 4
                ),
                'gpu_cores', 40
            ),
            'memory', JSON_OBJECT(
                'size', 36,
                'type', 'Unified Memory',
                'bandwidth', '400GB/s'
            )
        )
    )
);

-- 중첩된 값 조회
SELECT
    name,
    attributes->>'$.specs.processor.name' AS cpu,
    attributes->>'$.specs.processor.cores.performance' AS p_cores,
    attributes->>'$.specs.memory.size' AS ram
FROM product_flex
WHERE category = '노트북';

쿼리 결과

  • 관계형 모델에서는 여러 테이블로 분리해야 하는 중첩 구조를 하나의 컬럼에 표현할 수 있다.

개발 생산성 향상

-- 애플리케이션의 설정 객체를 그대로 저장
INSERT INTO product_flex (
    name, 
    category, 
    attributes
) 
VALUES (
    '사용자 대시보드',
    '설정', 
    '{
        "layout": {
            "columns": 3,
            "widgets": [
                {"id": "sales", "position": 1, "size": "large"},
                {"id": "visitors", "position": 2, "size": "medium"},
                {"id": "orders", "position": 3, "size": "medium"}
            ]
        },
        "theme": "dark",
        "refresh_interval": 30
    }'
);

 

  • 애플리케이션에서 객체나 JSON을 그대로 저장하고 조회할 수 있어 개발이 단순해진다.
  • ORM이나 애플리케이션 코드에서 복잡한 매핑 없이 JSON 객체를 그대로 사용할 수 있다.

EAV 대비 장점

EAV vs JSON 비교

  • 상품 하나에 속성 5개인 경우
  • EAV: 6개 행 (상품 1행 + 속성 5행), JOIN 필요
  • JSON: 1개 행, JOIN 불필요
-- EAV
SELECT p.name,
    MAX(CASE WHEN pa.attr_name = 'brand' THEN pa.attr_value END) AS brand,
    MAX(CASE WHEN pa.attr_name = 'price' THEN pa.attr_value END) AS price
FROM product p
JOIN product_attribute pa ON p.product_id = pa.product_id
GROUP BY p.product_id, p.name;


-- JSON
SELECT name,
    attributes->>'$.brand' AS brand,
    attributes->>'$.price' AS price
FROM product_flex;
  • JSON이 훨씬 직관적이고 간단하다.

JSON 설계의 단점

데이터 무결성 부재

-- 잘못된 데이터도 저장됨
INSERT INTO product_flex (name, category, attributes) VALUES
('잘못된 상품1', '스마트폰', '{"brand": "삼성", "price": "백만원"}'), -- 가격이 문자열
('잘못된 상품2','스마트폰', '{"brand": "삼성", "pricee": 1000000}'),-- 속성 오타
('잘못된 상품3','스마트폰', '{"brand": "삼성"}'); -- 필수 속성 누락

-- 모두 정상 저장됨
SELECT name, attributes FROM product_flex WHERE name LIKE '잘못된%';

쿼리 결과

  • JSON은 스키마가 없으므로 데이터베이스 수준에서 데이터 무결성을 보장하지 않는다.
  • 일반 컬럼이었다면 "price INT NOT NULL"로 이런 문제를 방지할 수 있었다.

CHECK 제약조건으로 일부 보완

MySQL 8.0.16부터 JSON에 CHECK 제약조건을 사용할 수 있다.

DROP TABLE IF EXISTS product_validated;

CREATE TABLE product_validated (
    product_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(200) NOT NULL,
    attributes JSON NOT NULL,
    -- JSON 유효성 검사
    CONSTRAINT chk_brand CHECK (
        JSON_CONTAINS_PATH(attributes, 'one', '$.brand')
    ),
    CONSTRAINT chk_price CHECK (
        JSON_CONTAINS_PATH(attributes, 'one', '$.price')
        AND JSON_TYPE(attributes->'$.price') = 'INTEGER'
    )
);

-- 정상 데이터
INSERT INTO product_validated (
    name, 
    attributes
) 
VALUES (
    '정상 상품', 
    '{"brand": "삼성", "price": 1200000}'
);

 

무결성 보장

-- brand 누락 - 실패
INSERT INTO product_validated (name, attributes) VALUES
('실패 상품', '{"price": 1200000}');

-- Error Code: 3819. Check constraint 'chk_brand' is violated.

-- price가 문자열 - 실패
INSERT INTO product_validated (name, attributes) VALUES
('실패 상품' , '{"brand": "삼성", "price": "백만원"}');

-- Error Code: 3819. Check constraint 'chk_price' is violated.
  • 하지만 CHECK 제약조건은 복잡한 검증에 한계가 있고, 성능 오버헤드도 있다.

참조 무결성 불가

-- 일반 컬럼은 외래 키 가능
CREATE TABLE order_item (
	item_id BIGINT PRIMARY KEY,
	product_id BIGINT,
	FOREIGN KEY (product_id) REFERENCES product(product_id)
);
-- JSON 내부 값은 외래 키 불가능
-- {"product_id": 1} 이 값이 실제로 존재하는 product인지 DB가 검증 불가
  • JSON 내부의 값으로는 외래 키를 설정할 수 없다.

집계와 분석의 어려움

-- 일반 컬럼: 단순 집계
SELECT category, AVG(price), COUNT(*)
FROM product
GROUP BY category;

-- JSON: 복잡한 집계
SELECT
    category,
    AVG(attributes->'$.price') AS avg_price,
    COUNT(*) AS cnt
FROM product_flex
WHERE attributes->'$.price' IS NOT NULL
GROUP BY category;
  • JSON 데이터에 대한 집계 쿼리는 복잡하고 성능이 떨어진다.
  • 대규모 분석 작업에서는 JSON보다 일반 컬럼이 효율적이다.

 

왜 JSON 분석이 더 비효율적일까?

 

겉으로 보기에는 SQL 한 줄 차이처럼 보이지만, 데이터베이스 내부에서는 비용(Cost) 차이가 크다.

  • 파싱(Parsing) 오버헤드
    • 일반 컬럼(price)은 데이터베이스가 해당 위치에 "숫자"가 있다는 것을 이미 알고 있어서 바로 읽어낼 수 있다.
    • 하지만 JSON은 매 행(Row)마다 JSON 문서를 열고, 내부 구조를 순회하여 "$.price"라는 키를 찾는 과정이 필요하다.
    • 데이터가 100만 건이면 이 '찾는 과정'도 100만 번 반복된다.
    • 비유하자면, 일반 컬럼은 진열장에 놓인 물건을 바로 집는 것이고, JSON은 포장된 택배 박스를 일일이 뜯어서 물건을 확인하는 것과 같다.
    • 이런 오버헤드 때문에 일반 컬럼보다 JSON이 약간은 더 느리다.
  • 데이터 용량과 I/O
    • JSON은 값뿐만 아니라 키(key) 정보도 함께 저장한다.
    • "{ "price": 10000 }" 이라는 데이터는 단순히 숫자 "10000"만 저장하는 것보다 훨씬 많은 저장 공간을 차지한다.
    • 분석을 위해 대량의 데이터를 메모리로 로드할 때, 불필요한 키 정보까지 함께 읽어야 하므로 디스크 I/O와 메모리 효율이 떨어진다.

결국 대규모 데이터 분석이나 통계가 주 목적이라면, 해당 필드는 JSON 밖으로 꺼내어 정규화된 일반 컬럼으로 만드는 것이 정석이다. JSON의 유연함은 "저장"과 "조회"에는 강력하지만, "연산"에는 비용이 더 많이 든다는 점을 기억하자.

 

저장 공간

JSON은 키 이름도 함께 저장하므로 동일한 데이터라도 일반 컬럼보다 저장 공간을 더 사용한다.

DROP TABLE IF EXISTS product_flex;
DROP TABLE IF EXISTS product_normal;

-- 1. JSON 구조를 사용하는 테이블 생성
CREATE TABLE product_flex (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    category VARCHAR(50),
    attributes JSON
);

-- 2. 일반적인 정규화 구조를 사용하는 테이블 생성
CREATE TABLE product_normal (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    category VARCHAR(50),
    brand VARCHAR(50),
    price INT,
    stock INT
);

-- 3. 데이터 입력 (JSON 테이블)
INSERT INTO product_flex (
    category, 
    attributes
) 
VALUES (
    '스마트폰', 
    '{"brand": "Samsung", "price": 1200000, "stock": 100}'
);

-- 4. 데이터 입력 (일반 테이블)
INSERT INTO product_normal (
    category, 
    brand, 
    price, 
    stock
) 
VALUES (
    '스마트폰', 
    'Samsung', 
    1200000, 
    100
);

-- 데이터 크기 비교
SELECT
    'JSON' AS type,
    SUM(LENGTH(attributes)) AS total_bytes
FROM product_flex
WHERE category = '스마트폰'

UNION ALL

SELECT
    'Normal' AS type,
    SUM(LENGTH(brand) + 4 + 4) AS total_bytes -- brand VARCHAR + price INT + stock INT
FROM product_normal
WHERE category = '스마트폰';

쿼리 결과

  • 동일한 데이터를 저장할 때 JSON이 더 많은 공간을 사용한다.