MySQL - JSON 데이터를 Index를 사용하여 조회하기

Share

Last Updated on 2월 9, 2025 by Jade(정현호)

안녕하세요 

이번 글은 MySQL에서 저장된 JSON 데이터를 사용하여 조회조건 사용시 Index를 사용한 조회에 대해서 알아보도록 하겠습니다.

MySQL에서의 JSON 사용

MySQL에서는 JSON 문서 형식을 저장하여 사용할 수 있으며 전용 데이터 타입인 JSON 타입과 Text 컬럼 타입에서 사용할 수 있습니다.

MySQL에서 JSON 데이터를 다루고 조회하고 하는 내용은 별도의 페이지에서 다루고 해당 포스팅에서는 MySQL에서 JSON 데이터를 조회시 인덱스를 사용하는 내용을 중점적으로 확인해보도록 하겠습니다.

또는 다음의 공식 문서에서 자세한 내용을 확인할 수 있습니다.

 

내용을 시작하기 앞에서 기능을 확인해보기 위해서 테스트 테이블을 다음과 같이 생성하도록 하겠습니다.

mysql> CREATE TABLE customers (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
custinfo JSON
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

mysql> INSERT INTO customers VALUES
(NULL, '{"user":"Jack","user_id":37,"zipcode":[94582,94536],"favorite_brand": ["Louis Vuitton","Chanel"]}'),
(NULL, '{"user":"Jill","user_id":22,"zipcode":[94568,94507,94582],"favorite_brand": ["Armani","Burberry"]}'),
(NULL, '{"user":"Bob","user_id":31,"zipcode":[94477,94507],"favorite_brand": ["Dior","Prada"]}'),
(NULL, '{"user":"Mary","user_id":72,"zipcode":[94536],"favorite_brand": ["Chanel","Versace"]}'),
(NULL, '{"user":"Ted","user_id":56,"zipcode":[94507,94582],"favorite_brand": ["Chanel","celine","Louis Vuitton"]}');

* 위에서 테이블 생성시 custinfo 컬럼에 대해서 JSON 데이터 타입을 사용하였지만, text 컬럼 데이터 타입도 이번 포스팅에서 나오는 모든 내용에 대해서 동일하게 적용됩니다.


생성한 테이블 데이터는 다음과 같습니다.

mysql> select * from customers;
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  1 | {"user": "Jack", "user_id": 37, "zipcode": [94582, 94536], "favorite_brand": ["Louis Vuitton", "Chanel"]}          |
|  2 | {"user": "Jill", "user_id": 22, "zipcode": [94568, 94507, 94582], "favorite_brand": ["Armani", "Burberry"]}        |
|  3 | {"user": "Bob", "user_id": 31, "zipcode": [94477, 94507], "favorite_brand": ["Dior", "Prada"]}                     |
|  4 | {"user": "Mary", "user_id": 72, "zipcode": [94536], "favorite_brand": ["Chanel", "Versace"]}                       |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

 

유저명(user)이 "Jill"인 레코드를 찾는 예제입니다.

mysql> select * from customers
where json_extract(custinfo, '$.user') = 'Jill';
+----+-------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                    |
+----+-------------------------------------------------------------------------------------------------------------+
|  2 | {"user": "Jill", "user_id": 22, "zipcode": [94568, 94507, 94582], "favorite_brand": ["Armani", "Burberry"]} |
+----+-------------------------------------------------------------------------------------------------------------+

 

참고로 JSON 데이터는 테이블의 collation이 ci 타입이라도 대소문자를 가립니다.

-- 테이블 구조 확인
mysql> show create table customers;
< ... 중략 ... >
ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

-- 데이터 조회
mysql> SELECT * FROM customers
WHERE JSON_EXTRACT(custinfo, '$.user') = 'jill';
Empty set (0.00 sec)

 

JSON 데이터 조회시 JSON 함수를 사용할 수도 있지만, 다음과 같이 -> 또는 ->> 를 사용할 수도 있습니다.

mysql> select custinfo->>"$.user" 
from customers
where custinfo->>"$.user" = 'Jill';
+---------------------+
| custinfo->>"$.user" |
+---------------------+
| Jill                |
+---------------------+

 

다음과 같이 범위조회도 가능합니다.

mysql> select * from customers
where json_extract(custinfo, '$.user_id') >= 40;
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  4 | {"user": "Mary", "user_id": 72, "zipcode": [94536], "favorite_brand": ["Chanel", "Versace"]}                       |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

 

MySQL에서 JSON 데이터에서 특정 필드만 조회하기 위해서는 위에서 사용한 JSON_EXTRACT 함수나 -> 또는 ->> 를 이용할 수 있습니다.

-> 연산자는 필드 값의 더블 퀘테이션을 유지하여 조회하며, JSON_EXTRACT() 함수와 같은 출력 결과를 가집니다.
->> 연산자는 필드 값에서 더블 퀘테이션(큰따옴표)를 제외하고 출력합니다.

다음의 조회 결과를 통해서 확인해볼 수 있습니다.

mysql> select json_extract(custinfo, '$.user')
from customers
where custinfo->>"$.user" = 'Jill';
+----------------------------------+
| JSON_EXTRACT(custinfo, '$.user') |
+----------------------------------+
| "Jill"                           |
+----------------------------------+

mysql> SELECT custinfo->>"$.user" 
FROM customers
WHERE custinfo->>"$.user" = 'Jill';
+---------------------+
| custinfo->>"$.user" |
+---------------------+
| Jill                |
+---------------------+

mysql> select custinfo->"$.user" 
from customers
where custinfo->>"$.user" = 'Jill';
+--------------------+
| custinfo->"$.user" |
+--------------------+
| "Jill"             |
+--------------------+


MySQL에서 JSON 데이터를 다루는 방법은 다양하고 포스팅에서는 위와 같은 간단한 내용만 정리하고 다음 내용을 확인해보도록 하겠습니다.
                

JSON 데이터 조회시 Index 사용

다음과 같이 where 절에는 user 필드에 'Jill' 이란 검색 조건으로 조회를 하고 있습니다. 결과는 정상적으로 출력 됩니다. 다만 실행 계획을 해보면 index가 없기 때문에 full table scan으로 수행됩니다.

-- 데이터 조회
mysql> SELECT custinfo->"$.user"
FROM customers
WHERE custinfo->"$.user" = 'Jill';
+--------------------+
| custinfo->"$.user" |
+--------------------+
| "Jill"             |
+--------------------+

-- 실행계획
mysql> explain
SELECT custinfo->"$.user"
FROM customers
WHERE custinfo->"$.user" = 'Jill';
+----+-------------+-----------+------+---------------+------+------+----------+-------------+
| id | select_type | table     | type | possible_keys | key  | rows | filtered | Extra       |
+----+-------------+-----------+------+---------------+------+------+----------+-------------+
|  1 | SIMPLE      | customers | ALL  | NULL          | NULL |    5 |   100.00 | Using where |
+----+-------------+-----------+------+---------------+------+------+----------+-------------+


* 포스팅의 모든 실행계획의 결과의 일부 컬럼은 가로 길이에 따른 부분으로 일부 컬럼이 생략(제외) 되어 기술되어 있습니다.

MySQL에서는 JSON 문서의 검색시 인덱스를 사용하는 방법을 제공하고 있으며 방법은 크게 3가지가 있습니다.

  • Generated Columns(5.7)
  • Functional Index(MySQL 8.0.13)
  • Multi-Valued Indexes(MySQL 8.0.17)


위의 방법 중에서 Generated Columns는 이전에 포스팅을 통해서 다룬적이 있는 내용으로 조금 더 상세한 내용은 다음을 참조하시면 됩니다.

            

Generated Columns

Generated Columns은 MySQL 5.7 버전에서 추가된 기능으로 이 컬럼의 데이터는 지정된 식을 기반으로 계산되어 생성되거나 지정된 표현 형태로 출력을 하는 기능을 합니다.

Generated Columns 기능을 사용하여 user 필드 값을 조회하기 위해서 다음과 같이 컬럼 추가와 인덱스 생성해보도록 하겠습니다.

-- 컬럼 추가
mysql> alter table customers add column user varchar(100)
GENERATED ALWAYS AS (custinfo->>"$.user") VIRTUAL not null,
algorithm=inplace,lock=none;
Query OK, 0 rows affected (0.09 sec)
Records: 0  Duplicates: 0  Warnings: 0

-- 인덱스 생성
mysql> alter table customers 
add index idx_user(user),
algorithm=inplace,lock=none;


이제 다시 실행계획을 확인해보도록 하겠습니다.

mysql> explain
SELECT custinfo->"$.user"
FROM customers
WHERE custinfo->"$.user" = 'Jill';
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
| id | select_type | table     | type | possible_keys | key      | key_len | rows | filtered | Extra |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
|  1 | SIMPLE      | customers | ref  | idx_user      | idx_user | 402     |    1 |   100.00 | NULL  |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+

 

Generated Columns 을 통한 인덱스 사용시 또 하나의 장점은 where 조건 사용 표현 방식에서 함수나 JSON 표현식을 사용하지 않고 일반 sql 구문과 같이 사용할 수 있다는 점입니다.

mysql> SELECT custinfo->"$.user"
FROM customers
WHERE user = 'Jill';
+--------------------+
| custinfo->"$.user" |
+--------------------+
| "Jill"             |
+--------------------+

mysql> explain
SELECT custinfo->"$.user"
FROM customers
WHERE user = 'Jill';
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
| id | select_type | table     | type | possible_keys | key      | key_len | rows | filtered | Extra |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
|  1 | SIMPLE      | customers | ref  | idx_user      | idx_user | 402     |    1 |   100.00 | NULL  |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+

 

where 절을 일반 SQL 구문으로 사용할 수 있기 때문에 조금 더 편의성이 좋게 사용할 수 있거나 기존의 작성된 SQL구문을 변경하지 않고 그대로 사용할 수 있다는 장점이 있습니다.

다만, JSON 내 여러 필드에 대해서 계속적으로 index 필요시에 계속적으로 또는 매번 Generated Columns을 만들어야 한다는 점도 고려할 부분이긴 합니다.

현재 테이블 구조를 다시 살펴보도록 하겠습니다.

mysql> show create table customers;
CREATE TABLE `customers` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `custinfo` json DEFAULT NULL,
  `user` varchar(100) COLLATE utf8mb4_general_ci 
        GENERATED ALWAYS AS (
            json_unquote(json_extract(`custinfo`,_utf8mb4'$.user'))) VIRTUAL NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_user` (`user`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
* 가로 길이에 따라서 일부 내용을 개행하였습니다.

 

Generated Columns 사용해서 컬럼을 추가하면 컬럼 추가시 컬럼 간의 사이(중간)에 추가시 Online DDL의 inplace의 사용에 제약이 발생합니다.

JSON 데이터인 custinfo 컬럼과 Generated Columns 로 생성한 user 컬럼 사이에 컬럼을 추가해보도록 하겠습니다.

mysql> alter table customers 
add column col1 varchar(100) after custinfo, 
algorithm=inplace,lock=none;

ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. 
Reason: INPLACE ADD or DROP of virtual columns 
cannot be combined with other ALTER TABLE actions. 
Try ALGORITHM=COPY.

 

위와 같이 inplace는 불가하지만 다음과 같이 instant는 가능합니다.

mysql> alter table customers 
add column col1 varchar(100) after custinfo, 
algorithm=instant;
Query OK, 0 rows affected (0.11 sec)
Records: 0  Duplicates: 0  Warnings: 0

 

컬럼 추가시 맨 마지막 순서(맨 끝)에 컬럼 추가는 inplace로 가능합니다.

mysql> alter table customers 
add column col2 varchar(100), 
algorithm=inplace,lock=none;

Query OK, 0 rows affected (0.15 sec)
Records: 0  Duplicates: 0  Warnings: 0

 

다음 테스트를 위해 추가한 컬럼을 삭제하겠습니다.

mysql> alter table customers drop column col2;
mysql> alter table customers drop column col1;
mysql> alter table customers drop column user;

                

Functional Index

MySQL 8.0.13 버전에서 Functional Index 기능이 도입되었습니다. 이 버전부터 MySQL은 함수나 표현식을 사용하여 생성된 값에 대한 함수 인덱스를 지원합니다

functional index은 JSON 값과 같이 다른 방법으로 인덱싱할 수 없는 값의 인덱싱을 가능하게 합니다.

다음과 같이 Functional Index 생성을 시도해보도록 하겠습니다.

mysql> alter table customers
add index idx_user (( CAST(custinfo->>'$.user' AS CHAR(100)) )),
algorithm=inplace,lock=none;
ERROR 1846 (0A000): LOCK=NONE is not supported. 
Reason: ADD COLUMN col...VIRTUAL, ADD INDEX(col). Try LOCK=SHARED.

LOCK=NONE는 지원 불가하고 SHARED 를 사용해야 합니다.

"lock=shared" 를 사용해서 생성을 진행하면 정상적으로 생성되는 것을 확인할 수 있습니다.

mysql> alter table customers
add index idx_user (( CAST(custinfo->>'$.user' AS CHAR(100)) )),
algorithm=inplace,lock=shared;

Query OK, 0 rows affected (0.10 sec)
Records: 0  Duplicates: 0  Warnings: 0

 

user_id 필드에 대해서도 인덱스를 추가하겠습니다. user_id 필드는 데이터가 숫자형임으로 unsigned 으로 생성하겠습니다.
숫자형 데이터의 경우는 Index 생성시 SIGNED 또는 UNSIGNED를 사용합니다.

mysql> alter table customers
add index idx_user_id (( CAST(custinfo->>'$.user_id' AS unsigned) )),
algorithm=inplace,lock=shared;

Query OK, 0 rows affected (0.16 sec)
Records: 0  Duplicates: 0  Warnings: 0

 

데이터를 먼저 조회해서 조회가 가능한지 확인해보도록 하겠습니다.

mysql> select json_extract(custinfo, '$.user')
from customers
where custinfo->>"$.user" = 'Jill';
+----------------------------------+
| JSON_EXTRACT(custinfo, '$.user') |
+----------------------------------+
| "Jill"                           |
+----------------------------------+
<!-- 데이터 정상 조회 가능

 

이렇게 조회한 SQL의 실행계획을 확인해보도록 하겠습니다.

mysql> explain
select json_extract(custinfo, '$.user')
from customers
WHERE custinfo->>"$.user" = 'Jill';
+----+-------------+-----------+------+---------------+------+------+------+----------+-------------+
| id | select_type | table     | type | possible_keys | key  | ref  | rows | filtered | Extra       |
+----+-------------+-----------+------+---------------+------+------+------+----------+-------------+
|  1 | SIMPLE      | customers | ALL  | NULL          | NULL | NULL |    5 |   100.00 | Using where |
+----+-------------+-----------+------+---------------+------+------+------+----------+-------------+


예상과 달리 Index를 사용하지 못하고 Full table scan으로 수행되었습니다.
(type: ALL, key: NULL)

JSON 추출 함수는 utf8mb4_bin 정렬 순서를 가진 문자열을 반환합니다. 하지만 Index를 생성시에 특별히 명시하지 않으면 테이블의 collation으로 됩니다.

쿼리의 표현식과 저장된 인덱스 간의 정렬 순서가 일치하지 않기 때문에 생성한 인덱스가 사용되지 않는 것입니다.

이와 같은 문제점을 해결하기 위해서는 명시적으로 collation을 binary 타입으로 지정해서 생성합니다.

-- 먼저 기존 인덱스 삭제
mysql> alter table customers drop index idx_user;

-- COLLATE utf8mb4_bin 를 명시적으로 기술하여 인덱스를 생성
mysql> alter table customers
add index idx_user (( CAST(custinfo->>'$.user' AS CHAR(100)) COLLATE utf8mb4_bin )),
algorithm=inplace,lock=shared;

 

다시 데이터 조회 및 실행계획을 확인해보도록 하겠습니다.

-- 데이터 조회
mysql> select json_extract(custinfo, '$.user')
from customers
WHERE custinfo->>"$.user" = 'Jill';
+----------------------------------+
| JSON_EXTRACT(custinfo, '$.user') |
+----------------------------------+
| "Jill"                           |
+----------------------------------+

-- 실행계획
mysql> explain 
select json_extract(custinfo, '$.user')
FROM customers
WHERE custinfo->>"$.user" = 'Jill';
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
| id | select_type | table     | type | possible_keys | key      | key_len | rows | filtered | Extra |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
|  1 | SIMPLE      | customers | ref  | idx_user      | idx_user | 403     |    1 |   100.00 | NULL  |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+


[참고] MySQL Character Set과 Collation에 관한 내용은 다음 포스팅을 참조하시면 됩니다.


이번에는 user_id 필드에 대해서 조회 및 실행계획을 확인해보도록 하겠습니다.

-- 데이터 조회
mysql> select *
from customers
WHERE custinfo->>"$.user_id" >= 40;
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  4 | {"user": "Mary", "user_id": 72, "zipcode": [94536], "favorite_brand": ["Chanel", "Versace"]}                       |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

-- 실행계획
mysql> explain SELECT *
FROM customers
WHERE custinfo->>"$.user_id" >= 40;
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+
| id | select_type | table     | type  | possible_keys | key         | key_len | rows | filtered | Extra       |
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | range | idx_user_id   | idx_user_id | 9       |    2 |   100.00 | Using where |
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+

정상적으로 조회 및 index를 사용되는 것을 확인할 수 있습니다.

CAST() 함수에서 사용 가능한 타입입니다.

  • BINARY[(N)]
  • CHAR[(N)]
  • DATE
  • DATETIME
  • TIME
  • DECIMAL[(M[,D])]
  • SIGNED [INTEGER]
  • UNSIGNED [INTEGER]

               

Multi-Valued Indexes

이번에 확인해볼 내용은 Multi-Valued Indexes 입니다. 먼저 데이터를 다시 살펴보도록 하겠습니다.

mysql> select * from customers;
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  1 | {"user": "Jack", "user_id": 37, "zipcode": [94582, 94536], "favorite_brand": ["Louis Vuitton", "Chanel"]}          |
|  2 | {"user": "Jill", "user_id": 22, "zipcode": [94568, 94507, 94582], "favorite_brand": ["Armani", "Burberry"]}        |
|  3 | {"user": "Bob", "user_id": 31, "zipcode": [94477, 94507], "favorite_brand": ["Dior", "Prada"]}                     |
|  4 | {"user": "Mary", "user_id": 72, "zipcode": [94536], "favorite_brand": ["Chanel", "Versace"]}                       |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+


위에서는 Generated Columns나 Functional Index을 user 필드와 user_id 필드를 대상으로 생성하였습니다.

이번에는 zipcode 필드favorite_brand 필드에 대해서 생성을 해보도록 하겠습니다.

먼저 favorite_brand 필드에 대해서 favorite_brand 이름으로 Generated Columns를 생성하고 index를 생성해보도록 하겠습니다.

mysql> alter table customers 
add column favorite_brand varchar(100)
GENERATED ALWAYS AS (custinfo->>"$.favorite_brand") 
VIRTUAL not null ;

mysql> alter table customers 
add index idx_favorite_brand(favorite_brand);

 

다음과 같이 2가지 방법으로는 데이터가 조회가 되지 않습니다.

mysql> select 
JSON_EXTRACT(custinfo, '$.favorite_brand')
from customers
where favorite_brand ='Louis Vuitton';
Empty set (0.00 sec)

mysql> select 
JSON_EXTRACT(custinfo, '$.favorite_brand')
from customers
where favorite_brand like 'Louis Vuitton%';
Empty set (0.00 sec)

 

%% 를 사용한 like 검색 형태로만 검색이 가능합니다. 그렇기 때문에 확인되는 내용과 같이 index가 사용되지는 않습니다.

mysql> select 
JSON_EXTRACT(custinfo, '$.favorite_brand')
from customers
where favorite_brand like '%Louis Vuitton%';
+--------------------------------------------+
| JSON_EXTRACT(custinfo, '$.favorite_brand') |
+--------------------------------------------+
| ["Louis Vuitton", "Chanel"]                |
| ["Chanel", "celine", "Louis Vuitton"]      |
+--------------------------------------------+

mysql> explain 
select JSON_EXTRACT(custinfo, '$.favorite_brand')
from customers
where favorite_brand like '%Louis Vuitton%';
+----+-------------+-----------+------+---------------+------+------+----------+-------------+
| id | select_type | table     | type | possible_keys | key  | rows | filtered | Extra       |
+----+-------------+-----------+------+---------------+------+------+----------+-------------+
|  1 | SIMPLE      | customers | ALL  | NULL          | NULL |    5 |    20.00 | Using where |
+----+-------------+-----------+------+---------------+------+------+----------+-------------+
<!-- 인덱스는 사용되지 못함


정리하면 Array(배열) 형태에서는 Generated Columns을 통해서는 효율적인 조회는 어렵습니다.

참고로 현재 테이블의 collation은 utf8mb4_general_ci 이기 때문에 다음과 같이 Generated Columns에서는 대소문자를 구별하지는 않습니다.

mysql> select 
JSON_EXTRACT(custinfo, '$.favorite_brand')
from customers
where favorite_brand like '%louis vuitton%';
+--------------------------------------------+
| JSON_EXTRACT(custinfo, '$.favorite_brand') |
+--------------------------------------------+
| ["Louis Vuitton", "Chanel"]                |
| ["Chanel", "celine", "Louis Vuitton"]      |
+--------------------------------------------+

 

이번에는 zipcode 필드에 대해서는 Functional Index를 생성해보도록 하겠습니다.

mysql> alter table customers
add index idx_zipcode (( CAST(custinfo->>'$.zipcode' AS UNSIGNED )  )),
algorithm=inplace,lock=shared;

ERROR 3751 (01000): Data truncated for functional index 'idx_zipcode' at row 1

Array 형태의 데이터에 대해서 정상적으로 생성되지 못하는 것을 확인할 수 있습니다.

이와 같이 JSON의 데이터 유형 중에서 Array(배열)로 되어있는 데이터에 대해서 사용할 수 있는 Index가 Multi-Valued Indexes 입니다.

Multi-Valued Indexes 기능은 MySQL 8.0.17 버전에서 추가되었습니다.

통상적으로 일반 인덱스는 각 데이터 레코드(1:1)에 대해 하나의 인덱스 레코드를 가지지만, Multi-Valued Indexes는 1개 이상의 값을 가진 배열 형태의 값을 저장하고 사용할 수 있는 Index로 JSON 데이터 유형에서 Array로 되어 있는 경우 사용할 수 있습니다.

인덱스 생성시에 CAST(... AS ... ARRAY)를 사용합니다.

ARRAY는 Create table 또는 Create index 구문에서 Multi-Valued 인덱스를 생성하는 데 사용되는 경우를 제외하고는 지원되지 않으며 이 내용은 필수적인 사항입니다. 인덱싱되는 열은 JSON 유형의 데이터이어야 합니다.

다음과 같이 zipcode 필드에 대해서 Multi-Valued Indexes를 생성하도록 하겠습니다.

mysql> alter table customers
add index idx_zipcode( (cast(custinfo->'$.zipcode' as unsigned array)) ),
algorithm=inplace,lock=none;

ERROR 1846 (0A000): LOCK=NONE is not supported. 
Reason: ADD COLUMN col...VIRTUAL, ADD INDEX(col). Try LOCK=SHARED.

위와 같이 algorithm=inplace,lock=none 방식으로는 불가능합니다.

이번에는 algorithm=inplace,lock=shared 으로 시도해보겠습니다. algorithm=inplace,lock=shared 으로는 생성되는 것을 확인할 수 있습니다.

mysql> alter table customers
add index idx_zipcode( (cast(custinfo->'$.zipcode' as unsigned array)) ),
algorithm=inplace,lock=shared;

Query OK, 0 rows affected (0.13 sec)
Records: 0  Duplicates: 0  Warnings: 0

 

특이한점은 두번째 Multi-Valued Indexes 생성부터는 algorithm=inplace 이 불가능하다는 점입니다. favorite_brand 에 대해서 Multi-Valued Indexes을 생성해보도록 하겠습니다.
생성전에 위에서 생성한 Generated Columns와 index를 삭제하도록 하겠습니다.

mysql> alter table customers drop index idx_favorite_brand;
mysql> alter table customers drop column favorite_brand;

 

Multi-Valued Indexes 생성하도록 하겠습니다.

mysql> alter table customers
add index idx_favorite_brand( (cast(custinfo->'$.favorite_brand' as char(100) array)) ),
algorithm=inplace,lock=shared;

ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. 
Reason: INPLACE ADD or DROP of 
virtual columns cannot be combined with other ALTER TABLE actions. 
Try ALGORITHM=COPY.

INPLACE로 불가능하다는 에러가 발생됩니다.

COPY 방식으로 생성하도록 하겠습니다.

mysql> alter table customers
add index idx_favorite_brand( (cast(custinfo->'$.favorite_brand' as char(100) array)) ),
algorithm=copy;

Query OK, 5 rows affected (0.31 sec)
Records: 5  Duplicates: 0  Warnings: 0

 

Multi-Valued Indexes를 사용하기 위해서는 다음의 함수를 WHERE 절에서 사용해야 합니다.

  • MEMBER OF()
  • JSON_CONTAINS()
  • JSON_OVERLAPS()


MEMBER OF()JSON_OVERLAPS() 함수는 MySQL 8.0.17 버전에서 Multi-Valued Indexes 와 같이 추가된 새로운 함수입니다.

JSON_OVERLAPS()는 두 개의 JSON 문서를 비교합니다. 두 문서에 공통된 Key-Value 쌍이나 배열 요소가 포함되어 있으면 함수는 TRUE(1)를 반환하고 그렇지 않으면 FALSE(0)를 반환합니다. 따라서 JSON_OVERLAPS()는 JSON_CONTAINS()의 보완 기능을 합니다. JSON_CONTAINS()는 모든 키-값 쌍이나 배열 요소가 두 JSON 문서 모두에 존재하는지 여부를 나타냅니다.

MEMBER OF()는 첫 번째 피연산자(스칼라 또는 JSON 문서)가 두 번째 피연산자로 전달된 JSON 배열의 멤버인지 여부를 테스트합니다. 멤버인 경우 TRUE(1)를 반환하고, 그렇지 않으면 FALSE(0)를 반환합니다. 피연산자의 타입 변환은 수행되지 않습니다.

우선, 현재 테이블의 데이터는 다음과 같습니다.

mysql> select * from customers;
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  1 | {"user": "Jack", "user_id": 37, "zipcode": [94582, 94536], "favorite_brand": ["Louis Vuitton", "Chanel"]}          |
|  2 | {"user": "Jill", "user_id": 22, "zipcode": [94568, 94507, 94582], "favorite_brand": ["Armani", "Burberry"]}        |
|  3 | {"user": "Bob", "user_id": 31, "zipcode": [94477, 94507], "favorite_brand": ["Dior", "Prada"]}                     |
|  4 | {"user": "Mary", "user_id": 72, "zipcode": [94536], "favorite_brand": ["Chanel", "Versace"]}                       |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

 

MEMBER OF(), JSON_CONTAINS(), JSON_OVERLAPS() 3개의 함수, 특히 JSON_CONTAINS(), JSON_OVERLAPS() 함수의 차이를 설명 보다 쉽게 이해하기 위해서 함수를 사용하여 조회를 해보도록 하겠습니다.

mysql> select * from customers
where 94507 member of(custinfo->'$.zipcode');
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  2 | {"user": "Jill", "user_id": 22, "zipcode": [94568, 94507, 94582], "favorite_brand": ["Armani", "Burberry"]}        |
|  3 | {"user": "Bob", "user_id": 31, "zipcode": [94477, 94507], "favorite_brand": ["Dior", "Prada"]}                     |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

mysql> select * from customers
where json_contains(custinfo->'$.zipcode', cast('[94507,94582]' as json));
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  2 | {"user": "Jill", "user_id": 22, "zipcode": [94568, 94507, 94582], "favorite_brand": ["Armani", "Burberry"]}        |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

mysql> select * from customers
where json_overlaps(custinfo->'$.zipcode', cast('[94507,94582]' as json));
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  1 | {"user": "Jack", "user_id": 37, "zipcode": [94582, 94536], "favorite_brand": ["Louis Vuitton", "Chanel"]}          |
|  2 | {"user": "Jill", "user_id": 22, "zipcode": [94568, 94507, 94582], "favorite_brand": ["Armani", "Burberry"]}        |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
|  3 | {"user": "Bob", "user_id": 31, "zipcode": [94477, 94507], "favorite_brand": ["Dior", "Prada"]}                     |
+----+--------------------------------------------------------------------------------------------------------------------+

 

json_overlaps는 조회 조건으로 지정한 값 중 하나라도 포함되어 있으면 TRUE를 반환하고, json_contains는 지정한 값이 모두 포함되어 있어야만 TRUE를 반환합니다.

3개의 쿼리 모두 정상적으로 데이터가 추출되는 것은 확인하였으니 생성한 Index를 사용하여 조회 되는지 실행계획을 확인해보도록 하겠습니다.

mysql> explain 
select * from customers
where 94507 member of(custinfo->'$.zipcode');
+----+-------------+-----------+------+---------------+-------------+---------+------+----------+-------------+
| id | select_type | table     | type | possible_keys | key         | key_len | rows | filtered | Extra       |
+----+-------------+-----------+------+---------------+-------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | ref  | idx_zipcode   | idx_zipcode | 9       |    3 |   100.00 | Using where |
+----+-------------+-----------+------+---------------+-------------+---------+------+----------+-------------+

mysql> explain 
select * from customers
where json_contains(custinfo->'$.zipcode', cast('[94507,94582]' as json));
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+
| id | select_type | table     | type  | possible_keys | key         | key_len | rows | filtered | Extra       |
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | range | idx_zipcode   | idx_zipcode | 9       |    6 |   100.00 | Using where |
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+

mysql> explain 
select * from customers
where json_overlaps(custinfo->'$.zipcode', cast('[94507,94582]' as json));
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+
| id | select_type | table     | type  | possible_keys | key         | key_len | rows | filtered | Extra       |
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | range | idx_zipcode   | idx_zipcode | 9       |    6 |   100.00 | Using where |
+----+-------------+-----------+-------+---------------+-------------+---------+------+----------+-------------+


3개 쿼리 모두 생성한 Multi-Valued Indexes를 정상적으로 사용하는 것을 확인할 수 있습니다.

그 다음은 두번째로 favorite_brand 필드에 생성한 index에 대해서 확인해보도록 하겠습니다.

mysql> select * from customers
where 'celine' member of(custinfo->'$.favorite_brand');
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

mysql> select * from customers
where 'dior' member of(custinfo->'$.favorite_brand');
Empty set (0.00 sec)

mysql> select * from customers
where 'Dior' member of(custinfo->'$.favorite_brand');
+----+------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                       |
+----+------------------------------------------------------------------------------------------------+
|  3 | {"user": "Bob", "user_id": 31, "zipcode": [94477, 94507], "favorite_brand": ["Dior", "Prada"]} |
+----+------------------------------------------------------------------------------------------------+

mysql> explain
select * from customers
where 'Dior' member of(custinfo->'$.favorite_brand');
+----+-------------+-----------+------+--------------------+--------------------+---------+------+----------+-------------+
| id | select_type | table     | type | possible_keys      | key                | key_len | rows | filtered | Extra       |
+----+-------------+-----------+------+--------------------+--------------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | ref  | idx_favorite_brand | idx_favorite_brand | 403     |    1 |   100.00 | Using where |
+----+-------------+-----------+------+--------------------+--------------------+---------+------+----------+-------------+


정상적으로 조회 및 Index를 사용하는 것을 확인할 수 있으며 또한 대소문자를 구분하여 조회를 해야함을 확인할 수 있습니다.

다만, 특이한점으로는 이전의 Functional Index에서는 명시적으로 collation에 대해서 binary 타입을 사용해서 생성했어야 Index를 사용 가능하였지만, 방금 생성한 Multi-Valued Indexes를 통해서 favorite_brand 필드에 인덱스 생성시에는 별도의 collation을 지정하지 않았지만 Index 사용시 대소문자를 구분이 되고 있는 것을 확인할 수 있습니다.
(위의 where 'Dior' member of 조회 쿼리의 플랜 참조)

MySQL의 Multi-valued Indexes의 Limitations and Restrictions에서 다음과 같이 정해진 기준이 존재합니다.

Multi-valued Indexes에 대해 다음 두 가지 문자 집합 및 정렬 순서 조합 이외의 문자 집합 및 정렬 순서는 지원되지 않습니다.
- binary 정렬 순서를 가진 binary 문자 집합
- utf8mb4_0900_as_cs 정렬 순서를 가진 utf8mb4 문자 집합

즉, binary 정렬 순서를 가진 collation 만 사용 가능하다는 의미이고, Multi-valued Indexes 생성시에 utf8mb4_bin 으로 생성되었다고 이해할 수 있습니다.

json_contains() 와 json_overlaps() 를 사용한 조회 결과 및 실행계획도 살펴보도록 하겠습니다.

-- 데이터 조회
mysql> select * from customers
where json_contains(custinfo->'$.favorite_brand', 
cast('["Louis Vuitton","Chanel"]' as json));
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  1 | {"user": "Jack", "user_id": 37, "zipcode": [94582, 94536], "favorite_brand": ["Louis Vuitton", "Chanel"]}          |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
+----+--------------------------------------------------------------------------------------------------------------------+

-- 실행 계획
mysql> explain
select * from customers
where json_contains(custinfo->'$.favorite_brand', 
cast('["Louis Vuitton","Chanel"]' as json));
+----+-------------+-----------+-------+--------------------+--------------------+---------+------+----------+-------------+
| id | select_type | table     | type  | possible_keys      | key                | key_len | rows | filtered | Extra       |
+----+-------------+-----------+-------+--------------------+--------------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | range | idx_favorite_brand | idx_favorite_brand | 403     |    5 |   100.00 | Using where |
+----+-------------+-----------+-------+--------------------+--------------------+---------+------+----------+-------------+

-- 데이터 조회
mysql> select * from customers
where json_overlaps(custinfo->'$.favorite_brand', 
cast('["Louis Vuitton","Chanel"]' as json));
+----+--------------------------------------------------------------------------------------------------------------------+
| id | custinfo                                                                                                           |
+----+--------------------------------------------------------------------------------------------------------------------+
|  1 | {"user": "Jack", "user_id": 37, "zipcode": [94582, 94536], "favorite_brand": ["Louis Vuitton", "Chanel"]}          |
|  5 | {"user": "Ted", "user_id": 56, "zipcode": [94507, 94582], "favorite_brand": ["Chanel", "celine", "Louis Vuitton"]} |
|  4 | {"user": "Mary", "user_id": 72, "zipcode": [94536], "favorite_brand": ["Chanel", "Versace"]}                       |
+----+--------------------------------------------------------------------------------------------------------------------+

-- 실행 계획
mysql> explain
select * from customers
where json_overlaps(custinfo->'$.favorite_brand', 
cast('["Louis Vuitton","Chanel"]' as json));
+----+-------------+-----------+-------+--------------------+--------------------+---------+------+----------+-------------+
| id | select_type | table     | type  | possible_keys      | key                | key_len | rows | filtered | Extra       |
+----+-------------+-----------+-------+--------------------+--------------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | range | idx_favorite_brand | idx_favorite_brand | 403     |    5 |   100.00 | Using where |
+----+-------------+-----------+-------+--------------------+--------------------+---------+------+----------+-------------+


정상적으로 데이터 추출 및 생성한 Multi-valued Indexes를 통해서 조회되는 것을 확인할 수 있습니다.

지금까지 Array 유형의 데이터에 Multi-valued Indexes를 생성하여 테스트를 해보았습니다.

그럼 Array 가 아닌 Key:Value 형태의 user 필드와 user_id 필드에서는 어떻게 동작하는지 확인해보도록 하겠습니다.
먼저 user 필드와 user_id 필드에 생성된 인덱스를 삭제하도록 하겠습니다.

mysql> alter table customers drop index idx_user;
mysql> alter table customers drop index idx_user_id;

 

Index를 새로 생성하도록 하겠습니다.

mysql> alter table customers
add index idx_user( (cast(custinfo->'$.user' as char(100) array)) ),
algorithm=inplace,lock=shared;

mysql> alter table customers
add index idx_user_id( (cast(custinfo->'$.user_id' as unsigned array)) ),
algorithm=copy;

 

user 필드 대상으로 데이터 조회 및 실행 계획 확인을 해보도록 하겠습니다.

-- 데이터 추출 확인
mysql> select custinfo->"$.user"
FROM customers
where 'Jill' member of(custinfo->'$.user');
+--------------------+
| custinfo->"$.user" |
+--------------------+
| "Jill"             |
+--------------------+

-- Multi-valued Indexes 실행계획
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------------+
| id | select_type | table     | type | possible_keys | key      | key_len | rows | filtered | Extra       |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | ref  | idx_user      | idx_user | 403     |    1 |   100.00 | Using where |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------------+

인덱스를 사용하여 정상적으로 데이터가 추출되는 것을 확인할 수 있습니다.

다음의 실행 계획은 Functional Index를 사용하였을 때 실행 계획입니다.

+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
| id | select_type | table     | type | possible_keys | key      | key_len | rows | filtered | Extra |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+
|  1 | SIMPLE      | customers | ref  | idx_user      | idx_user | 403     |    1 |   100.00 | NULL  |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------+


Extra 컬럼 값에서 차이가 있기 때문에 완전히 같다고는 볼수 없지만 거의 유사한 실행계획이 도출되는 것을 확인할 수 있습니다.

해당 필드가 Key:value 유형으로 데이터가 존재하였지만, 스키마 리스의 장점으로 Key:Array 로 변경될 경우 인덱스 변경 없이 유연하게 대응한다는 측면에서 본다면 Multi-valued Indexes를 사용하는 것이 여러모로 유연적일 수 있다고 생각합니다.

다음과 같이 1개 레코드의 필드 값을 Array로 변경해보도록 하겠습니다.

mysql> update customers
SET custinfo = JSON_SET(custinfo, '$.user', JSON_ARRAY('Bob', 'Smith'))
where 'Bob' member of(custinfo->'$.user');

Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0


데이터 조회 및 실행계획을 살펴보도록 하겠습니다.

mysql> select custinfo->"$.user" 
from customers
where 'Bob' member of(custinfo->'$.user');
+--------------------+
| custinfo->"$.user" |
+--------------------+
| ["Bob", "Smith"]   |
+--------------------+

mysql> explain
select custinfo->"$.user" 
from customers
where 'Bob' member of(custinfo->'$.user');
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------------+
| id | select_type | table     | type | possible_keys | key      | key_len | rows | filtered | Extra       |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | ref  | idx_user      | idx_user | 403     |    1 |   100.00 | Using where |
+----+-------------+-----------+------+---------------+----------+---------+------+----------+-------------+


key:value에서 key:array 로 데이터 유형이 변경되었지만 데이터 조회시 index 사용이 정상적으로 되는 것을 확인할 수 있습니다.

Multi-valued Indexes을 사용한다면 이처럼 스키마-리스인 JSON 데이터가 Array로 변경되었을 경우에도 Index의 변경 없이 유연하게 대응이 가능할 수 있음을 확인할 수 있습니다.

이번에는 user_id 필드를 통해서 조회 및 실행계획을 확인해보도록 하겠습니다.

mysql> SELECT JSON_EXTRACT(custinfo, '$.user') AS user,
JSON_EXTRACT(custinfo, '$.user_id') AS user_id
FROM customers
where 22 member of(custinfo->'$.user_id');
+--------+---------+
| user   | user_id |
+--------+---------+
| "Jill" | 22      |
+--------+---------+

mysql> explain
SELECT JSON_EXTRACT(custinfo, '$.user') AS user,
JSON_EXTRACT(custinfo, '$.user_id') AS user_id
FROM customers
where 22 member of(custinfo->'$.user_id');
+----+-------------+-----------+------+---------------+-------------+---------+------+----------+-------------+
| id | select_type | table     | type | possible_keys | key         | key_len | rows | filtered | Extra       |
+----+-------------+-----------+------+---------------+-------------+---------+------+----------+-------------+
|  1 | SIMPLE      | customers | ref  | idx_user_id   | idx_user_id | 9       |    1 |   100.00 | Using where |
+----+-------------+-----------+------+---------------+-------------+---------+------+----------+-------------+

user_id 필드도 정상적으로 데이터 조회 및 index를 사용하는 것을 확인할 수 있습니다.

다만 숫자형일 경우 Multi-Valued Indexes를 사용하기 위해 MEMBER OF(), JSON_CONTAINS() , JSON_OVERLAPS() 함수를 이용해야 하기 때문에 between 이나 >= <= 와 같은 범위 처리시에 index 사용이 어려운 부분이 있습니다.
                                        

Conclusion

이번 포스팅 내용을 간단하게 정리하면 다음과 같습니다.

JSON 데이터 형식에서 JSON 필드와 값을 대상으로한 조회조건 사용시 Index Scan은 다음의 3개의 기능을 통해서 사용 가능합니다.

  • Generated Columns(5.7)
  • Functional Index(MySQL 8.0.13)
  • Multi-Valued Indexes(MySQL 8.0.17)


긴 글 읽어주셔서 감사합니다. 이번 글은 여기서 마무리하도록 하겠습니다.
                     

Reference

Reference Link
mysql.com/doc/create-index
mysql.com/doc/cast-functions
mysql.com/worklog/id=1075
mysql.com/worklog/id=8763
mysql.com/worklog/id=8955
mysql.com/worklog/id=10604

연관된 다른 글

 

 

 

 

    

2
0
글에 대한 당신의 생각을 기다립니다. 댓글 의견 주세요!x