MySQL JSON 컬럼 쿼리 심화

MySQL JSON 컬럼이란?

MySQL 5.7부터 도입된 JSON 데이터 타입은 구조화된 JSON 데이터를 네이티브로 저장하고 쿼리할 수 있다. 8.0에서 대폭 강화되어 JSON 경로 표현식, 인덱싱, 집계 함수까지 지원한다. 스키마 변경 없이 유연한 데이터를 저장해야 하는 경우 — 설정값, 메타데이터, 이벤트 페이로드 등 — JSON 컬럼이 정답이다.

기본 CRUD

-- 테이블 생성
CREATE TABLE products (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  attributes JSON NOT NULL,      -- JSON 컬럼
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- INSERT: JSON 문자열 또는 JSON_OBJECT()
INSERT INTO products (name, attributes) VALUES
  ('MacBook Pro', '{"cpu": "M3 Pro", "ram": 36, "storage": "1TB", "ports": ["USB-C", "HDMI", "MagSafe"]}'),
  ('ThinkPad X1', JSON_OBJECT('cpu', 'i7-1365U', 'ram', 32, 'storage', '512GB', 'ports', JSON_ARRAY('USB-C', 'USB-A', 'HDMI')));

-- UPDATE: JSON_SET (키 추가/수정), JSON_REPLACE (기존 키만 수정), JSON_REMOVE (삭제)
UPDATE products
SET attributes = JSON_SET(attributes, '$.color', 'Space Black', '$.ram', 64)
WHERE name = 'MacBook Pro';

UPDATE products
SET attributes = JSON_REMOVE(attributes, '$.ports[2]')
WHERE name = 'MacBook Pro';

JSON 경로 표현식

표현식 설명 예시
$.key 최상위 키 접근 $.cpu → “M3 Pro”
$.arr[N] 배열 인덱스 $.ports[0] → “USB-C”
$.obj.nested 중첩 객체 $.specs.display.size
$.* 모든 키 와일드카드
$[*] 모든 배열 요소 배열 전체 순회
$**.key 재귀 검색 모든 깊이에서 key 찾기

조회: ->와 ->> 연산자

-- -> : JSON 타입으로 반환 (따옴표 포함)
SELECT attributes->'$.cpu' FROM products;
-- 결과: "M3 Pro" (JSON 문자열)

-- ->> : 텍스트로 반환 (따옴표 없음, WHERE에서 사용)
SELECT attributes->>'$.cpu' FROM products;
-- 결과: M3 Pro (일반 문자열)

-- WHERE 절에서 사용
SELECT name, attributes->>'$.ram' AS ram_gb
FROM products
WHERE attributes->>'$.cpu' LIKE '%M3%'
  AND CAST(attributes->>'$.ram' AS UNSIGNED) >= 32;

-- 배열 요소 검색
SELECT name FROM products
WHERE JSON_CONTAINS(attributes->'$.ports', '"HDMI"');

-- 배열에 여러 값 중 하나라도 포함
SELECT name FROM products
WHERE JSON_OVERLAPS(attributes->'$.ports', '["Thunderbolt", "USB-A"]');

JSON 함수 심화

-- JSON_EXTRACT: 여러 경로 동시 추출
SELECT JSON_EXTRACT(attributes, '$.cpu', '$.ram') FROM products;
-- 결과: ["M3 Pro", 36]

-- JSON_KEYS: 최상위 키 목록
SELECT JSON_KEYS(attributes) FROM products;
-- 결과: ["cpu", "ram", "storage", "ports"]

-- JSON_LENGTH: 배열 길이 또는 객체 키 수
SELECT name, JSON_LENGTH(attributes->'$.ports') AS port_count FROM products;

-- JSON_TYPE: 값의 타입 확인
SELECT JSON_TYPE(attributes->'$.ram') FROM products;
-- 결과: INTEGER

-- JSON_VALID: JSON 유효성 검증
SELECT JSON_VALID('{"valid": true}');   -- 1
SELECT JSON_VALID('invalid json');       -- 0

-- JSON_MERGE_PATCH: 객체 병합 (덮어쓰기)
UPDATE products
SET attributes = JSON_MERGE_PATCH(attributes, '{"warranty": "3years", "ram": 48}')
WHERE id = 1;

-- JSON_MERGE_PRESERVE: 배열은 합치기
SELECT JSON_MERGE_PRESERVE('["a"]', '["b"]');
-- 결과: ["a", "b"]

JSON_TABLE: JSON을 관계형 테이블로 변환

-- JSON 배열을 행으로 풀기 (MySQL 8.0)
SELECT p.name, jt.port
FROM products p,
JSON_TABLE(
  p.attributes->'$.ports',
  '$[*]' COLUMNS (
    port VARCHAR(50) PATH '$'
  )
) AS jt;

-- 결과:
-- MacBook Pro | USB-C
-- MacBook Pro | HDMI
-- MacBook Pro | MagSafe

-- 중첩 JSON 파싱
SELECT o.id, jt.*
FROM orders o,
JSON_TABLE(
  o.items,                    -- JSON 배열 컬럼
  '$[*]' COLUMNS (
    product_name VARCHAR(100) PATH '$.name',
    quantity INT PATH '$.qty',
    price DECIMAL(10,2) PATH '$.price',
    total DECIMAL(10,2) PATH '$.total' DEFAULT '0' ON EMPTY
  )
) AS jt
WHERE jt.quantity > 1;

JSON_TABLE은 JSON 데이터를 일반 테이블처럼 JOIN, GROUP BY, 집계할 수 있게 해주는 강력한 함수다.

인덱싱: Generated Column + B-Tree

JSON 컬럼 자체는 인덱스를 걸 수 없다. Generated Column(가상 컬럼)을 만들어 인덱싱한다.

-- 가상 컬럼 + 인덱스
ALTER TABLE products
ADD COLUMN cpu VARCHAR(100) GENERATED ALWAYS AS (attributes->>'$.cpu') STORED,
ADD INDEX idx_cpu (cpu);

-- 이제 일반 컬럼처럼 인덱스 사용
SELECT * FROM products WHERE cpu = 'M3 Pro';
-- EXPLAIN → type: ref, key: idx_cpu ✅

-- 숫자 필드 인덱싱
ALTER TABLE products
ADD COLUMN ram_gb INT GENERATED ALWAYS AS (CAST(attributes->>'$.ram' AS UNSIGNED)) STORED,
ADD INDEX idx_ram (ram_gb);

-- MySQL 8.0.17+: Multi-Valued Index (배열 검색용)
ALTER TABLE products
ADD INDEX idx_ports ((CAST(attributes->'$.ports' AS CHAR(50) ARRAY)));

-- Multi-Valued Index로 배열 검색
SELECT * FROM products
WHERE JSON_CONTAINS(attributes->'$.ports', '"USB-C"');
-- EXPLAIN → Multi-Valued Index 사용 ✅

MySQL 인덱스 최적화에서 B-Tree, Covering Index 등 기본 인덱싱 전략을 확인할 수 있다.

ORM 연동: TypeORM과 JPA

// TypeORM — simple-json 또는 json 타입
@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ type: 'json' })
  attributes: {
    cpu: string;
    ram: number;
    ports: string[];
  };
}

// QueryBuilder에서 JSON 경로 사용
const products = await productRepository
  .createQueryBuilder('p')
  .where("p.attributes->>'$.ram' >= :ram", { ram: 32 })
  .andWhere("JSON_CONTAINS(p.attributes->'$.ports', :port)", { port: '"USB-C"' })
  .getMany();
// JPA — AttributeConverter
@Entity
public class Product {
    @Convert(converter = JsonConverter.class)
    @Column(columnDefinition = "json")
    private Map<String, Object> attributes;
}

// Native Query에서 JSON 함수 사용
@Query(value = "SELECT * FROM products WHERE attributes->>'$.cpu' = :cpu", nativeQuery = true)
List<Product> findByCpu(@Param("cpu") String cpu);

JSON vs 정규화: 언제 JSON을 쓸까?

JSON 컬럼 적합 정규화 테이블 적합
스키마가 유동적 (상품 속성, 설정) 스키마가 고정적 (주문, 사용자)
조회보다 저장이 중요 빈번한 WHERE/JOIN 대상
API 페이로드 원본 저장 집계/리포트 대상 데이터
EAV(Entity-Attribute-Value) 대체 외래 키 관계가 필요한 경우

주의사항

  • JSON 컬럼 크기 — MySQL JSON은 최대 1GB지만, 커넥션 풀과 네트워크 대역폭을 고려하면 수 KB 이내를 권장한다.
  • 부분 업데이트 비용JSON_SET은 내부적으로 전체 JSON을 재작성한다. 빈번한 부분 수정이 필요하면 정규화를 고려하라. MySQL 8.0의 Partial Update 최적화가 일부 케이스에서 동작하지만 보장은 아니다.
  • 인덱스 필수 — Generated Column이나 Multi-Valued Index 없이 ->> 연산자로 WHERE를 걸면 풀 테이블 스캔이다. EXPLAIN으로 반드시 확인하라.
  • 타입 안전성 — JSON은 스키마 강제가 없다. 애플리케이션 레벨에서 검증(Zod, class-validator)을 반드시 수행해야 한다.

정리

MySQL JSON 컬럼은 유연한 스키마 데이터를 RDB 안에서 관리하는 실용적인 솔루션이다. ->> 연산자로 경로 기반 조회, JSON_TABLE로 관계형 변환, Generated Column으로 인덱싱하면 NoSQL 없이도 반정형 데이터를 효과적으로 다룰 수 있다. 핵심은 “조회 패턴에 따라 인덱스를 걸고, 정규화와 JSON의 경계를 명확히 하는 것”이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux