최근 회사에서 다국어 필드를 확장해야 하는 요구사항이 생겼다.
기존에는 name, name_en 정도로도 충분했지만, 서비스가 확장되면서 중국어, 일본어, 기타 언어까지 점점 늘어나기 시작했다.
문제는 이 구조가 확장될수록 점점 비효율적이 된다는 점이다.
언어가 추가될 때마다 name_cn, name_ja, name_fr처럼 컬럼을 계속 늘려야 하고, 스키마 변경과 배포 비용도 함께 증가한다.
특히 웹 리포트 기능처럼 사용자가 어떤 언어 필드를 조회할지 유동적인 경우, 정적인 컬럼 구조는 점점 한계에 부딪히게 된다.
이런 상황에서 자연스럽게 떠오른 선택지가 JSON 기반 저장 방식이었다.
하나의 컬럼에 다국어 값을 유연하게 담을 수 있고, 스키마 변경 없이 확장이 가능하기 때문이다.
하지만 한 가지 고민이 생긴다.
JSONB는 정말 성능적으로 괜찮을까?
기존처럼 테이블을 분리하거나 컬럼을 유지하는 방식보다 더 나은 선택일까?
이 글에서는 PostgreSQL의 JSONB와 테이블 분리 방식을 실제 성능 테스트를 통해 비교하고, 어떤 상황에서 어떤 선택이 더 적절한지 정리해보려고 한다.
문제 정의
DB에서는 다음과 같은 데이터 구조를 자주 다룬다:
- 유연한 스키마 (ex. 사용자 설정, 이벤트 로그)
- 빈번한 구조 변경
- 일부 필드만 자주 조회
이때 선택지는 크게 두 가지다:
- 정규화된 테이블 구조
- PostgreSQL의 JSONB 컬럼 활용
사실 난 jsonB로 저장하는 방법(정규화 X)은 대학교 데이터베이스 수업에서는 배우지도 못했었다. (데이터베이스의 특성을 배울땐 필요없어보이긴한다) 회사에 와보니 jsonB가 특정 상황에서는 오히러 더 유리하게 가져갈 수 있는 부분도 있는 것 같다.
비교 관점
성능 비교는 단순히 “빠르다”가 아니라 다음 기준으로 나눠야 한다. 단순히 빠르다만 성능 비교를 한다면 데이터베이스를 NoSQL을 사용할 것이다.
- 조회 성능
- 수정 성능
- 인덱싱 효율
- 저장 공간
- 확장성
그 전에 jsonB를 모르시는 분들도 있으니 특징을 간단히 정리하고 가도록 하겠다.
JSONB의 특징
장점
- 스키마 유연성
- 단일 row fetch (join 없음)
스키마의 유연성이 가장 장점으로 느껴진다. 스키마에 대한 책임을 클라이언트, FE에서 가질수도 있기 때문이다.
단점
- 부분 업데이트 비용 큼 (전체 rewrite)
- 복잡한 쿼리 시 성능 저하
테이블 분리 방식 특징
장점
- 정밀한 인덱싱 가능
- 부분 업데이트 효율적
- 복잡한 조건 검색에 유리
단점
- JOIN 비용 증가
- 스키마 변경 비용
사실 RDBS를 사용하는 사람들에게 가장 익숙한 정규화 방식이다.
성능 테스트 설계
테스트 환경
- DB: PostgreSQL 14.13
- 데이터 규모:
- 100만 rows
- 각 row당 10~20개의 key-value
참고로 DB에는 캐시 기능이 있으므로
DISCARD ALL;
옵션을 키고 진행하는 것을 추천한다.
-- JSONB 방식
CREATE TABLE user_settings (
user_id BIGINT PRIMARY KEY,
settings JSONB
);
-- 테이블 분리 방식
CREATE TABLE user_settings (
user_id BIGINT PRIMARY KEY
);
CREATE TABLE user_setting_items (
user_id BIGINT,
key TEXT,
value TEXT
);
데이터 생성
JSONB
INSERT INTO user_settings
SELECT i,
jsonb_build_object(
'theme', 'dark',
'lang', 'ko',
'timezone', 'UTC',
'notifications', true,
'volume', (random()*100)::int
)
FROM generate_series(1, 1000000) i;
테이블 분리
INSERT INTO user_setting_items
SELECT i, key, value
FROM generate_series(1, 1000000) i,
LATERAL (
VALUES
('theme', 'dark'),
('lang', 'ko'),
('timezone', 'UTC'),
('notifications', 'true'),
('volume', (random()*100)::int::text)
) t(key, value);
테스트 시나리오
단일 필드 조회(jsonB)
EXPLAIN ANALYZE
SELECT settings
FROM user_settings_jsonb
WHERE user_id = 100;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using user_settings_jsonb_pkey on user_settings_jsonb (cost=0.42..8.44 rows=1 width=99) (actual time=0.463..0.465 rows=1 loops=1)
Index Cond: (user_id = 100)
Planning Time: 0.425 ms
Execution Time: 0.969 ms
(4 rows)
Time: 6.541 ms
Index Scan using user_settings_jsonb_pkey
이는 user_id = 100 조건 때문에 PRIMARY KEY 인덱스를 사용한 것을 확인할 수 있었다. (user_id = PK이기 인덱스 생성)
또 시간을 보면 3가지로 나오는 것을 확인 할 수 있었는데
- Planning Time: 쿼리를 “어떻게 실행할지 계획 세우는 시간
- Execution Time: PostgreSQL 내부에서 실제 쿼리 실행한 시간
- Time: psql 클라이언트가 체감한 전체 시간
으로 정리할 수 있다.
해당 글에서는 Time은 네트워크 및 클라이언트 처리까지 포함된 end-to-end 응답 시간이기에 실제 쿼리 실행시간인 Execution Time을 기준으로 분석하였다.
단일 필드 조회(테이블 분리)
EXPLAIN ANALYZE
SELECT key, value
FROM user_setting_items
WHERE user_id = 100;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------
Gather (cost=1000.00..58957.25 rows=5 width=12) (actual time=6.946..1768.382 rows=5 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on user_setting_items (cost=0.00..57956.75 rows=2 width=12) (actual time=1166.661..1753.201 rows=2 loops=3)
Filter: (user_id = 100)
Rows Removed by Filter: 1666665
Planning Time: 0.100 ms
Execution Time: 1768.456 ms
(8 rows)
Time: 1768.949 ms (00:01.769)
조회를 해보니 Execution Time이 엄청 높게 나와서 QUERY PLAN을 봐보니 인덱스를 못 타고 있었다.. 실무에서는 항상 인덱스를 사용하기에 user_setting_items에 index를 추가하고 다시 실행시간을 비교해보자
CREATE INDEX idx_user_setting_items_user_id
ON user_setting_items (user_id);
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using idx_user_setting_items_user_id on user_setting_items (cost=0.43..8.52 rows=5 width=12) (actual time=1.285..1.290 rows=5 loops=1)
Index Cond: (user_id = 100)
Planning Time: 2.612 ms
Execution Time: 2.360 ms
(4 rows)
Time: 9.527 ms
결론적으로 단일조회에서 정리해보면 다음과 같다.
| 항목 | JSONB | 정규화 |
| Execution Time | 0.969 ms | 2.360 ms |
| Planning Time | 0.425 ms | 2.612 ms |
| Total Time (psql) | 6.541 ms | 9.527 ms |
| Scan 방식 | Index Scan (PK) | Index Scan (user_id index) |
| 반환 row 수 | 1 row | 5 rows |
단건 조회 기준으로는 JSONB가 더 빠른 성능을 보였으며, 이는 하나의 row만 읽으면 되는 구조적 이점 때문이다. 반면 정규화 방식은 동일한 데이터를 여러 row로 나누어 저장하기 때문에 추가적인 row 접근 비용이 발생한다.
특정 필드만 조회(jsonB)
EXPLAIN ANALYZE
SELECT settings->>'theme'
FROM user_settings_jsonb
WHERE user_id = 100;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using user_settings_jsonb_pkey on user_settings_jsonb (cost=0.42..8.45 rows=1 width=32) (actual time=0.649..0.652 rows=1 loops=1)
Index Cond: (user_id = 100)
Planning Time: 1.796 ms
Execution Time: 2.210 ms
(4 rows)
Time: 12.669 ms
특정 필드만 조회(테이블 분리)
EXPLAIN ANALYZE
SELECT value
FROM user_setting_items
WHERE user_id = 100
AND key = 'theme';
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using idx_user_setting_items_user_id on user_setting_items (cost=0.43..8.53 rows=1 width=4) (actual time=0.124..0.127 rows=1 loops=1)
Index Cond: (user_id = 100)
Filter: (key = 'theme'::text)
Rows Removed by Filter: 4
Planning Time: 1.147 ms
Execution Time: 0.161 ms
(6 rows)
Time: 1.989 ms
| 항목 | JSONB | 졍규화 |
| 조회 조건 | user_id = 100 | user_id = 100 AND key = 'theme' |
| Scan 방식 | PK Index Scan | user_id Index Scan + Filter |
| 반환 row 수 | 1 row | 1 row |
| 제거된 row | 없음 | 4 rows |
| Planning Time | 1.796 ms | 1.147 ms |
| Execution Time | 2.210 ms | 0.161 ms |
| psql Time | 12.669 ms | 1.989 ms |
특정 key 하나만 조회하는 경우, JSONB는 row 하나를 찾은 뒤 JSON 내부에서 값을 추출해야 한다. 반면 정규화 방식은 작은 row 하나만 반환하면 되기 때문에 더 빠르게 나온다.
업데이트 성능(jsonB)
EXPLAIN ANALYZE
UPDATE user_settings_jsonb
SET settings = jsonb_set(settings, '{volume}', '50')
WHERE user_id = 100;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------
Update on user_settings_jsonb (cost=0.42..8.45 rows=0 width=0) (actual time=3.822..3.824 rows=0 loops=1)
-> Index Scan using user_settings_jsonb_pkey on user_settings_jsonb (cost=0.42..8.45 rows=1 width=38) (actual time=0.297..0.299 rows=1 loops=1)
Index Cond: (user_id = 100)
Planning Time: 0.455 ms
Execution Time: 4.145 ms
(5 rows)
Time: 12.791 ms
업데이트 성능(테이블 분리)
EXPLAIN ANALYZE
UPDATE user_setting_items
SET value = '50'
WHERE user_id = 100
AND key = 'volume';
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------
Update on user_setting_items (cost=0.43..8.53 rows=0 width=0) (actual time=1.032..1.032 rows=0 loops=1)
-> Index Scan using idx_user_setting_items_user_id on user_setting_items (cost=0.43..8.53 rows=1 width=38) (actual time=0.062..0.063 rows=1 loops=1)
Index Cond: (user_id = 100)
Filter: (key = 'volume'::text)
Rows Removed by Filter: 4
Planning Time: 0.176 ms
Execution Time: 3.240 ms
(7 rows)
Time: 4.393 ms
업데이트에서는 정규화 방식이 더 빠르게 측정되었다. JSONB는 내부 필드 하나만 바꾸더라도 JSONB 값을 새로 만들어 row를 갱신해야 한다. 반면 정규화 방식은 volume에 해당하는 작은 row 하나만 수정하면 되므로 상대적으로 효율적이다.
추가 JSONB 인덱스 설정
JSONB
추가로 나의 상황인 경우는
JSON 안에 특정 값이 있는 row 찾기
SELECT *
FROM user_settings
WHERE settings @> '{"theme": "dark"}';
와 같은 쿼리를 사용할 일이 없어 GIN 인덱스를 추가하지는 않았다.
왜냐면 난 특정 user_id 안에서 item을 찾는 상황만 비교해보고 싶었기에 해당 테스트는 진행하지 않았다. (특정 테이블에 특정 language 찾는 상황, 특정 테이블에 전체 language 찾는 상황)
만약 그런 상황이 존재한다면 아래 쿼리로 INDEX를 생성 후 테스트하는 것이 좋아보인다.
CREATE INDEX idx_settings_gin
ON user_settings USING GIN (settings);
📊 요약
| 테스트 | JSONB Execution Time | 테이블 분리 Execution Time | 승자 |
| 단일 조회 | 0.969 ms | 2.360 ms | JSONB |
| 특정 필드 조회 | 2.210 ms | 0.161 ms | 테이블 분리 |
| 업데이트 | 4.145 ms | 3.240 ms | 테이블 분리 |
결론
정리하자면 다음과 같다.
JSONB가 유리한 경우
- 스키마 자주 변함
- 단일 row read 위주
테이블 분리가 유리한 경우
- 조건 검색 많음
- 특정 필드 update 많음
웹 리포트나 프론트에 의존적인, 자유도가 높아야되는 컬럼, 단일 조회는 jsonB, 그 외 보편적인 상황은 테이블 분리로 작업을 하는게 좋아보인다.
한 줄 정리
JSONB는 “한 객체를 통째로 읽는 패턴”에 적합하고,
테이블 분리는 “개별 속성을 자주 조회·수정하는 패턴”에 적합하다.
'etc' 카테고리의 다른 글
| [경험] 첫 취준 후기 (0) | 2024.12.16 |
|---|---|
| [우아한테크코스] 프리코스 4주차 (1) | 2024.11.25 |
| [우아한테크코스] 프리코스 3주차 (1) | 2024.11.04 |
| [우아한테크코스] 프리코스 2주차 (0) | 2024.11.04 |
| [우아한테크코스] 프리코스 1주차 (8) | 2024.10.22 |