프로젝트를 하다 보면 Map이나 Set 같은 값을 그대로 DB에 넣고 싶을 때가 있다. 정규화하기는 애매한 값이라든가, 특정 row에 딸린 id 목록이라든가, 통째로 읽고 쓰는 설정 덩어리 같은 것들이다. 관계로 풀어서 별도 테이블을 만들기는 번거롭고, 마침 MySQL에는 JSON 타입이 있다. 그래서 엔티티 필드를 Map<String, Object>로 두고 JSON 컬럼에 직렬화해서 저장한다. 편하다.
그런데 문득 궁금해졌다. 이게 DB 안에서는 실제로 어떻게 처리될까? 성능상 괜찮을까? "JSON 쓰지 마라" 같은 말은 들어봤지만, 정작 왜 그런지는 깊이 따져본 적이 없었다.
결론부터 말하면, JSON 컬럼은 데이터를 어떻게 접근하고 얼마나 큰지에 따라 충분히 쓸 만하기도, 독이 되기도 한다.
그래서 직접 MySQL 8.0을 띄워놓고 조사한 내용을 정리해본다. 환경은 아래와 같다.
- MySQL 8.0.46 / InnoDB
- 페이지 16KB, DYNAMIC 행 포맷
- 버퍼풀 128MB (기본값)
JSON을 넣으면 그대로 저장될까?
처음엔 넣은 문자열이 {"a": 1} 같은 형태 그대로 저장되는 줄 알았다. 조회하면 텍스트로 보이니까. 그런데 정말 그럴까?
확인을 위해 일부러 키 순서를 뒤섞고 공백을 넣고 중복 키까지 섞어서 넣어봤다.
INSERT INTO t_demo VALUES (1, '{ "zebra": 1, "apple": 2, "mango": [3, 2, 1], "apple": 99 }');
SELECT j FROM t_demo WHERE id = 1;
결과는 이렇게 나왔다.
{"apple": 99, "mango": [3, 2, 1], "zebra": 1}
넣은 것과 다르게 나온다. 텍스트가 그대로 저장된다면 설명되지 않는 부분이 몇 가지 있다.
- 키 순서가 입력(zebra→apple→mango)과 달리 정렬됐다 (apple→mango→zebra)
- 넣었던 공백이 사라졌다
- 중복 키
apple가 하나로 정리됐다 (이 버전에선 마지막 값 99가 남았다. 첫 값이 남는다고 알려진 경우도 있어 버전마다 다를 수 있는 듯하다) - 반면 배열
[3, 2, 1]은 순서가 그대로다 → 객체 키만 정렬되고 배열은 보존된다
즉 MySQL은 INSERT 시점에 JSON을 파싱해서 바이너리 포맷으로 저장하고, 조회할 때 다시 텍스트로 보여준다. DB 클라이언트 등에서 보이는 "문자열처럼 보이는 것"은 그 바이너리를 풀어낸 결과다.
바이너리는 왜, 어떻게 생겼나
왜 굳이 바이너리로 바꿀까? 텍스트로 저장하면 $.age 하나를 읽으려 해도 문서를 처음부터 끝까지 파싱해야 한다. 바이너리는 "어떤 키가 어디(offset)에 있는지" 색인을 함께 저장해서, 전체 파싱 없이 특정 키로 바로 접근할 수 있다.
대략 이런 구조다.
[헤더] 타입=object · 원소 개수 · 전체 byte 수
[key 색인] (apple → offset, 길이) (mango → ...) (zebra → ...) ← 키 정렬 저장
[value 색인] (타입=int, 99) (타입=array, → offset) (타입=int, 1)
[실제 key 바이트] apple mango zebra
[실제 value 바이트] [3, 2, 1] ...
키를 정렬해서 저장하는 이유도 여기 있다. 정렬돼 있으면 key 색인에서 이진 탐색(O(log k))으로 키를 찾을 수 있고, 정렬이 안 돼 있으면 전부 훑어야(O(k)) 한다.
그런데 이 색인이 공짜는 아니다. JSON_STORAGE_SIZE()로 바이너리 크기를 재서 원본 텍스트 길이와 비교해봤다.
| 입력 | 텍스트 길이 | 바이너리 크기 |
|---|---|---|
{"apple":99,"mango":[3,2,1],"zebra":1} |
45 B | 54 B |
{"a":1,"b":2,"c":3,"d":4,"e":5} |
31 B | 45 B |
바이너리가 오히려 더 크다. 특히 작고 키가 많은 문서일수록 색인 메타데이터 때문에 커진다(31B → 45B). "JSON 저장공간은 LONGTEXT와 대략 비슷하다"는 매뉴얼 설명과 달리, 작은 문서에서는 손해를 볼 수 있다. 빠른 부분 읽기와 저장 공간 오버헤드는 같은 색인 구조의 양면인 셈이다.
그 바이너리는 어디에 저장되나, 그리고 왜 데이터가 많아지면 느려질까
이제 JSON이 하나의 바이너리 값으로 변환됐다. 그럼 이 바이너리는 InnoDB 디스크의 어디에 저장될까?
InnoDB는 모든 데이터를 16KB 페이지 단위로 저장한다. 그리고 한 페이지에는 행이 최소 2개는 들어가야 한다(B+tree가 성립하려면). 그래서 한 행이 페이지 안에서 차지할 수 있는 크기는 사실상 페이지 절반인 약 8KB가 한계다.
JSON 크기에 따라 두 가지로 갈린다.
- in-page: JSON이 작으면 다른 컬럼들과 함께 행 안에 통째로 저장된다
- off-page: JSON이 커서 행이 ~8KB를 넘으면, JSON 값을 별도의 overflow 페이지로 빼내고 행에는 그 위치를 가리키는 20바이트 포인터만 남긴다
작은 JSON이라고 안심할 수 없다
큰 JSON이 off-page로 빠지는 건 직관적이다. 그런데 작은 JSON(in-page)이 더 흔하고, 그게 의외의 비용을 만든다.
작은 JSON이 행에 붙으면 행 하나가 커진다 → 한 페이지에 들어가는 행 수가 줄어든다 → 같은 수의 행을 읽어도 더 많은 페이지를 읽어야 한다. JSON을 건드리지도 않는 WHERE id = ? 같은 쿼리조차 약간이지만 손해를 본다.
이 손해가 얼마나 클지는 데이터 규모가 좌우한다.
InnoDB는 디스크 페이지를 메모리(버퍼풀)에 캐싱해두고 재사용한다. 메모리 읽기는 디스크보다 수백~수천 배 빠르기 때문에, 사실상 "디스크까지 가느냐, 메모리에서 끝나느냐"가 성능을 가른다.
- 데이터가 작으면 테이블이 통째로 버퍼풀에 올라가 디스크를 거의 안 간다
- 데이터가 버퍼풀보다 커지면 필요한 페이지가 메모리에 없을 때가 많아져 디스크를 자주 가게 된다
즉 성능은 데이터 크기에 선형으로 나빠지는 게 아니라, 버퍼풀을 넘어서는 지점에서 꺾인다. 그리고 in-page JSON은 행을 키워서 그 지점을 더 앞당긴다.
실측 결과
같은 100만 행짜리 테이블을 두 개 만들었다. 하나는 JSON 없이(t_slim), 하나는 ~230바이트짜리 작은 JSON을 붙여서(t_json). 먼저 크기부터 보자.
| 테이블 | 데이터 크기 | 행 수 |
|---|---|---|
t_slim |
29.6 MB | 1,024,000 |
t_json |
286.8 MB | 1,024,000 |
같은 행 수인데 작은 JSON 하나 붙였다고 데이터가 9.7배가 됐다. (인덱스 크기는 둘 다 ~20MB로 같았다. JSON은 클러스터드 인덱스, 즉 행 본문에만 들어가고 보조 인덱스와는 무관하기 때문이다.)
다음으로 버퍼풀(128MB)을 비운 콜드 상태에서 각 테이블을 풀스캔하며 디스크에서 읽은 양을 측정했다.
| 풀스캔 | t_slim |
t_json |
|---|---|---|
| 1회차 (콜드) | 29.5 MB | 286.5 MB |
| 2회차 (웜) | 0 MB | 236.3 MB |
t_slim은 한 번 읽고 나면 버퍼풀에 다 들어가서 2회차엔 디스크를 안 간다. 반면 t_json은 307MB라 버퍼풀(128MB)에 다 못 올라가서, 2회차에도 매번 236MB를 디스크에서 다시 읽는다. 작은 JSON 하나가 "느려지기 시작하는 지점"을 약 9배 앞당긴 셈이다.
그리고 버퍼풀은 인스턴스 전체가 공유한다. 한 테이블의 JSON이 버퍼풀을 잠식하면 다른 테이블의 캐시 효율까지 떨어뜨린다. 잘 튜닝된 DB의 버퍼풀 히트율은 보통 99% 이상인데, 99%에서 95%로 떨어지는 건 "4%p 하락"이 아니라 디스크 접근이 5배로 늘어나는 것이다. 작은 차이가 배수로 증폭된다.
패턴 1. 통째로 읽고 쓰기만 한다면
여기까지만 보면 JSON이 영 별로인 것 같지만, 잘 맞는 자리도 있다. 문서를 통째로 읽고 통째로 저장하는 경우다. 설정 객체를 통으로 불러와 통으로 덮어쓰는 식이라면 조인 없이 한 번에 끝나니 오히려 JSON이 합리적이다.
다만 아래 사항들은 고려할 필요가 있다.
- 스키마가 없어서 문서가 상한 없이 조용히 커진다. 어느새 off-page로 넘어가고 버퍼풀을 압박한다 (바로 위에서 살펴본 문제)
- off-page라면 행을 읽고 나서 overflow 페이지까지 따라가는 추가 random I/O가 붙는다
- JPA로 엔티티를 조회하면 JSON 컬럼이 같이 딸려온다. 큰 JSON이 안 쓰는 쿼리에도 따라오고, N+1이 겹치면 더 커진다
패턴 2. JSON 안의 값으로 검색하기
솔직히 검색은 잘 될 줄 알았다. 바이너리로 색인까지 한다니까. 그런데 결과는 예상과 달랐다.
헷갈리기 쉬운 지점이 있다. 2절에서 본 "색인으로 키를 빠르게 찾기"는 한 문서 안에서의 얘기다. 하지만 "테이블의 100만 행 중 어느 행의 JSON이 조건을 만족하나"는 다른 문제다. 인덱스가 없으면 100만 행을 다 열어서 파싱해야 한다.
게다가 일반 컬럼처럼 인덱스를 걸 수도 없다.
CREATE INDEX ix ON t_doc (j);
-- ERROR 3152: JSON column 'j' supports indexing only via generated columns on a specified JSON path.
JSON 컬럼은 통째로는 인덱스를 못 건다. 대신 JSON에서 꺼낸 특정 값만 인덱스해야 한다.
- map의 특정 키로 검색: 그 값을 꺼내는 함수 기반 인덱스(MySQL 8.0.13+)
- set(배열)의 멤버십: 멀티밸류 인덱스(8.0.17+). 배열 원소를 펼쳐서 각각 인덱스 엔트리로 만든다.
[a, b, c]→(a→행), (b→행), (c→행)
status(map)와 tags(배열, set) 필드가 든 100만 행에 다음과 같이 인덱스를 만들고, 같은 검색을 인덱스 전후로 측정했다.
-- 인덱스 생성
ALTER TABLE t_doc ADD INDEX ix_status ((CAST(j->>'$.status' AS UNSIGNED))); -- map: 함수 기반
ALTER TABLE t_doc ADD INDEX ix_tags ((CAST(j->'$.tags' AS UNSIGNED ARRAY))); -- set: 멀티밸류
-- 검색
SELECT COUNT(*) FROM t_doc WHERE CAST(j->>'$.status' AS UNSIGNED) = 50; -- map
SELECT COUNT(*) FROM t_doc WHERE 500 MEMBER OF (j->'$.tags'); -- set
| 검색 | 인덱스 전 (풀스캔) | 인덱스 후 | 개선 |
|---|---|---|---|
map status = 50 |
102만 행 스캔, 218 ms | 함수 인덱스, 12.7 ms | 약 17배 |
set tags에 500 포함 |
102만 행 스캔, 262 ms | 멀티밸류 인덱스, 5.24 ms | 약 50배 |
인덱스를 거니 풀스캔이 인덱스 lookup으로 바뀌며 빨라진다. 그런데 이것도 대가가 있다. 인덱스 추가 후 크기를 보면 데이터 70.6MB에 인덱스가 84.1MB로, 인덱스가 데이터보다 커졌다. 멀티밸류 인덱스가 한 행당 원소 개수만큼 엔트리를 만들기 때문이다.
더 중요한 한계가 있다. 함수/멀티밸류 인덱스는 인덱싱할 경로를 미리 정해서 고정해야 한다. $.status로 검색하려면 그 경로에 인덱스를 만들어둬야 하고, $.foo로도 검색하려면 또 다른 인덱스를 만들어야 한다. 즉 "아무 키나 막 넣어두고 나중에 뭐든 빠르게 검색"은 불가능하다. 스키마리스의 자유와 인덱스(고정 경로)는 상충한다. 자주 찾는 키가 많아지면, 그건 정규화하라는 신호다.
패턴 3. JSON 안의 키 하나만 자주 바꾸기
키가 여러 개인 큰 JSON에서, 그중 JSON 내부의 키 하나의 값만 자주 바꾸는 경우다. 여기서 MySQL과 JPA가 정반대로 움직인다.
MySQL 자체는 부분 업데이트를 지원한다. JSON_SET 같은 함수로 같거나 작은 크기의 값을 바꾸면, 문서 전체가 아니라 바뀐 부분만 갱신한다. 반면 JPA(Hibernate)는 Map 필드를 flush할 때 Jackson으로 통째 직렬화해서 컬럼 전체를 다시 쓴다. JSON_SET을 쓸 일이 없으니 부분 업데이트 조건 자체를 만족할 수 없다.
이게 얼마나 차이 나는지 redo log 발생량으로 측정했다. (redo log는 MySQL이 크래시 복구를 위해 변경 내용을 적어두는 로그다. 변경량이 많을수록 redo도 커진다.)
처음엔 2KB짜리 문서로 측정했는데, 부분 업데이트와 전체 재작성의 redo가 거의 같게 나왔다. 알고 보니 부분 업데이트의 redo 절감은 JSON이 off-page일 때만 나타나는 효과였다.
- 작은 JSON(in-page): 행 안에 통째로 박혀 있어서, 한 글자만 고쳐도 InnoDB는 그 행 레코드를 갱신하며 컬럼 값 전체를 redo에 적는다 (포스트잇 한 장을 통째로 다시 쓰는 셈)
- 큰 JSON(off-page): 별도 페이지에 따로 있어서, 그 페이지의 바뀐 바이트만 고치고 redo에도 그만큼만 적는다 (두꺼운 책의 표지 한 글자만 고치고 본문은 그대로 두는 셈)
그래서 문서를 20KB(off-page)로 키워서 다시 측정했더니 차이가 드러났다.
| 문서 크기 | 부분 업데이트(JSON_SET) |
전체 재작성(JPA 방식) | 배수 |
|---|---|---|---|
| 2KB (in-page) | 42 MB | 41 MB | 1.0배 |
| 20KB (off-page) | 0.26 MB | 22.35 MB | 84.8배 |
큰 문서에서는 부분 업데이트가 redo를 84배 아낀다. 그런데 JPA는 이 길을 막고 항상 전체 재작성 쪽으로 간다.
스프링 실측 결과
말로만 "JPA가 전체를 다시 쓴다"고 단정하기 찜찜해서, 실제로 Spring Boot 앱을 띄워서 확인했다. 엔티티의 Map 필드에서 키 하나(tag)만 바꾸고 저장해봤다.
update doc set data=cast(? as json) where id=?
-- 바인딩 파라미터: {tag=BBBB, payload=xxxx...(2000자 전부)}
tag 4바이트만 바꿨는데 payload까지 포함한 문서 전체가 바인딩됐다. JSON_SET은 보이지 않는다. (참고로 Hibernate 6에서 Map을 JSON으로 매핑하려면 classpath에 Jackson이 있어야 한다. spring-boot-starter-data-jpa에는 안 들어있어서 따로 추가해야 했다.)
정리하면, 큰 JSON을 자주 부분 변경하는데 JPA를 쓴다면, 변경 크기가 아니라 문서 크기에 비례하는 쓰기 비용을 매번 치르게 된다. 정 필요하면 네이티브 쿼리로 JSON_SET을 직접 날릴 수 있지만, 그건 영속성 컨텍스트를 우회하는 우회책이다.
성능 외의 트레이드오프
성능을 빼고 봐도, JSON은 관계형 DB가 기본으로 제공하던 안전장치를 상당 부분 포기한다.
- 타입·스키마 강제 없음: 잘못된 타입이나 오타 키가 들어와도 DB가 막지 못함 (8.0.17+의
CHECK (JSON_SCHEMA_VALID(...))로 일부 보완 가능) - 참조 무결성(FK) 불가: JSON 안의 값엔 FK를 못 걸고 조인도 안 됨. 참조가 깨진 고아 데이터가 생겨도 막아주지 못함
- 정렬·비교가 비직관적: 콜레이션이 아니라 JSON 타입 우선순위 규칙을 따름.
CAST없이 비교하면 의외의 결과가 나옴 - 옵티마이저 통계 부족: JSON 추출값은 통계가 없어 카디널리티 추정이 빗나가고, 비효율적인 실행 계획으로 이어짐
- 애플리케이션과의 강한 결합: Java 객체 구조를 그대로 직렬화해 넣기 때문에, 나중에 필드 이름을 바꾸거나 구조를 고치면 예전에 저장해둔 JSON을 못 읽게 됨. 결국 문서에 버전을 매겨 관리해야 함
그래서, JSON 컬럼 써도 되나요?
결론부터 말하면, "JSON이냐 정규화냐"의 이분법이 아니라, 이 데이터를 어떻게 접근하고 얼마나 큰지에 달렸다.
| 접근 패턴 | JSON 적합도 |
|---|---|
| 문서를 통째로 읽고 쓰기 (내부 안 뒤짐) | 좋음 |
| 내부 값으로 검색/필터/멤버십 | 함수·멀티밸류 인덱스 필수, 아니면 풀스캔 |
| 내부 값으로 정렬/조인/집계 | 정규화 권장 |
| 잦은 부분 업데이트 (특히 JPA) | 회피, 또는 네이티브 JSON_SET |
여기에 데이터 규모라는 변수가 가로지른다. 데이터가 버퍼풀 안에 들어갈 만큼 작으면 위의 단점 대부분이 잠복해 있어서 편의를 우선해도 괜찮다. 하지만 데이터가 버퍼풀을 넘어서면 행 비대·풀스캔·쓰기 증폭이 한꺼번에 드러난다.
map/set으로 좁혀 말하면 이렇다.
- set 멤버십 검색이 잦다 → 조인 테이블(정규화)이나 멀티밸류 인덱스
- map의 특정 키로 조회·갱신이 잦다 → 별도 테이블/컬럼으로 빼기
- map/set을 그냥 통째 스냅샷으로 읽고 쓴다 → JSON이 적합한 자리
정렬·조인·집계가 정규화 권장인 이유도 같은 맥락이다. 이것들은 관계형 DB의 본질인데, JSON으로 하려면 경로마다 함수 인덱스를 만들고 매번 CAST하고 배열은 JSON_TABLE로 펼쳐야 한다. 정규화가 기본으로 주는 걸 JSON 위에 비싸게 재구현하는 셈이다.
그래서 구체적으로, 어디까지 괜찮은가
결국 두 숫자로 좁혀진다 — 한 row의 JSON 크기와 전체 row 수.
- 한 row JSON이 작을 때 (in-page, ~8KB 미만): 예를 들어
{"theme":"dark","lang":"ko","pushAlarm":true}같은 설정 객체나 원소 수십 개짜리 태그 집합. 통째로 읽고 쓰는 용도라면 사실상 문제없다 - 클 때 (off-page, 단독으로 ~8KB 초과): 이력·태그가 수천 개로 누적된 수십 KB 배열. 통째 읽기에도 overflow 추가 I/O가 붙고, 키 하나만 바꿔도 JPA라면 매번 문서 전체를 다시 쓴다
그럼 "데이터가 버퍼풀에 들어가는" 한계는 어디쯤일까? 한 row의 JSON 크기별로 대략적인 허용 row 수를 계산해봤다.
| 한 row JSON | 행 크기(≈) | 버퍼풀 128MB | 버퍼풀 4GB |
|---|---|---|---|
| ~100 B | ~130 B | 약 100만 | 약 3,200만 |
| ~500 B | ~530 B | 약 25만 | 약 800만 |
| ~1 KB | ~1 KB | 약 12만 | 약 390만 |
| ~4 KB | ~4 KB | 약 3.2만 | 약 100만 |
| ~8 KB (in-page 경계) | ~8 KB | 약 1.6만 | 약 52만 |
| ~32 KB (off-page) | ~32 KB | 약 4천 | 약 13만 |
| ~256 KB | ~256 KB | 약 500 | 약 1.6만 |
버퍼풀을 두 가지로 잡은 데는 이유가 있다. 128MB는 MySQL 기본값이라 사실상 개발용이고, 운영에서는 보통 물리 RAM의 50~75%를 버퍼풀로 준다(예: RAM 16GB면 약 10GB, 64GB면 약 48GB. 클라우드 RDS는 인스턴스 메모리의 약 75%가 기본값이다). 그러니 실제 운영 DB는 오른쪽 열보다 여유로운 경우가 많고, 버퍼풀이 클수록 허용 row는 비례해서 늘고 "절벽"도 뒤로 밀린다.
그렇다고 무작정 키우면 안 된다. 버퍼풀은 RAM에 상주하므로 물리 RAM을 넘기면 스왑이 생겨 오히려 역효과다. 게다가 MySQL은 커넥션별 버퍼·redo 버퍼에, OS도 메모리가 필요하다. "RAM이 허락하는 선에서 데이터셋을 담을 만큼"이 적정선이고, 그 이상은 놀고 있는 메모리다.
내 DB의 버퍼풀이 얼마인지는 한 줄로 확인할 수 있다.
SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS pool_MB; -- 결과는 MB 단위
너무 막연해서 대략적으로만 계산해본 값이다. 행 크기를 "JSON 크기 + 약 30B(기본 컬럼·오버헤드)"로 잡고 버퍼풀 ÷ 행 크기로 구한 어림수다. 실제로는 버퍼풀을 인덱스·다른 테이블과 나눠 쓰고(체감 한계는 70~80% 수준), off-page라면 JSON을 안 읽는 쿼리의 캐시 부담은 더 작다. 정확한 선은 JSON_STORAGE_SIZE()로 행 크기를, 버퍼풀은 위 쿼리로 확인해 버퍼풀 ÷ 행 크기로 잡는 게 맞다.
정리하면, JSON 한 칸이 작고(in-page) + 전체 데이터가 버퍼풀에 들어가고 + 내부 검색을 안 하거나 인덱스를 걸 수 있다면 써도 된다. 이 중 하나라도 깨지는 지점(큰 문서 / 버퍼풀 초과 / 인덱스 없는 내부 검색)부터 비용이 드러난다.
마무리
막연히 JSON은 느리다고만 알고 있었는데, 직접 측정해보니 왜 그런지, 그리고 언제는 괜찮은지까지 손에 잡혔다. 결국 편의와 엔진 비용 사이의 트레이드오프였고, 그 비용의 정체는 바이너리 색인·off-page 저장·버퍼풀·redo 같은 스토리지 엔진의 동작에 있었다.
특히 예상이 빗나간 두 지점이 기억에 남는다. 검색이 잘 될 줄 알았는데 오히려 가장 느렸다는 것, 그리고 부분 업데이트가 작은 문서에서는 효과가 없다가 off-page에서야 84배로 벌어졌다는 것이다.
'개발 공부 > DB' 카테고리의 다른 글
| [컬럼형 DB 파헤치기] 2편 - MySQL vs ClickHouse 성능 측정 (0) | 2026.05.31 |
|---|---|
| [컬럼형 DB 파헤치기] 1편 - Row DB와 뭐가 다른가 (1) | 2026.05.24 |
| 왜 서로 다른 값을 INSERT 했는데 데드락이 걸릴까? (0) | 2026.02.02 |