컬럼일까요, JSON 컬럼일까요?

이 글은 직무 통합 과정에서 백엔드 실무를 맡게 된 프론트엔드 개발자가 실제 작업을 부딪히며 배운 것들을 주제 단위로 정리하는 시리즈입니다. 낯선 개념을 이미 아는 개념에 대응시키는 방식으로 씁니다.
필드 네 개를 저장해야 했습니다. 처음 든 생각은 당연히 "컬럼 네 개 추가"였는데, 리뷰에서 "JSON 컬럼 하나로 묶는 건 어떨까요"라는 제안을 받았습니다. 정규화가 정답 아니었나? 어렴풋이 "컬럼 추가는 로우마다 비용이 든다"는 이야기도 기억나는데, 그건 지금도 맞는 말일까? 조사해 보니 제가 알던 상식 절반은 유효 기간이 지나 있었습니다.
개별 컬럼과 JSON 컬럼, 무엇을 기준으로 갈라야 할까요?
이 글에서 다루는 내용
이 글은 필드를 개별 컬럼으로 펼칠지 JSON 컬럼 하나로 묶을지 판단하는 기준을 다룹니다. 먼저 "컬럼 추가는 비싸다"는 통념이 현대 DB에서 왜 더 이상 판단 기준이 못 되는지 확인하고, 그 자리를 대신할 세 가지 기준 — DB가 그 필드를 알아야 하는가, 민감정보 묶음인가, 백필 비용은 어느 쪽이 싼가 — 을 정리합니다. 마지막으로 저장 형태의 결정이 모델과 API 계약으로부터 독립적이라는 것, 1편의 주제가 여기서 다시 작동하는 것을 봅니다.
1. "컬럼 추가는 비싸다"는 옛말입니다
먼저 통념부터 정리하겠습니다. "컬럼을 추가하면 모든 로우를 다시 써야 해서 큰 테이블에는 위험하다" — 예전 스토리지 엔진에서는 사실이었지만 현대 DB에서는 대부분 아닙니다.
- MySQL 8.0+ —
ALTER TABLE ... ADD COLUMN이 기본적으로ALGORITHM=INSTANT로 동작합니다. 메타데이터만 바꾸고 끝나므로 테이블 크기와 무관하게 즉시 완료됩니다. - PostgreSQL — nullable 컬럼 추가는 카탈로그만 변경합니다. PostgreSQL 11부터는 상수 디폴트가 있는 컬럼 추가도 테이블 재작성 없이 끝납니다.
물론 세부 조건은 있습니다(자료형, 컬럼 위치 지정, 세대 제한 등). 그러나 "로우 재작성이 무서워서 컬럼 대신 JSON"이라는 논리는 이제 대체로 성립하지 않는다는 것이 요점입니다. 그렇다면 판단 기준은 비용이 아니라 다른 곳에 있어야 합니다.
Recap
MySQL 8.0의 INSTANT DDL과 PostgreSQL의 카탈로그 변경 덕분에 컬럼 추가는 대부분 즉시 끝납니다. "행 재작성 비용"은 더 이상 컬럼 vs JSON의 판단 기준이 아니며, 기준은 다른 곳에서 찾아야 합니다.
2. 진짜 판단 기준 세 가지
비용이 기준에서 빠지면 남는 것은 세 가지 질문입니다.
① DB가 그 필드를 알아야 하는가. 필터·정렬·인덱스·제약(constraint)이 필요한 필드는 개별 컬럼이어야 합니다. DB가 그 필드로 일을 해야 하기 때문입니다. 반대로 애플리케이션이 통째로 읽고 통째로 쓸 뿐 DB 레벨에서 아무것도 하지 않는 필드라면 JSON으로 묶을 수 있습니다 — 다만 공짜는 아닙니다. JSON 안의 값에는 컬럼 타입 검증도 제약도 걸리지 않으므로 정합성은 온전히 애플리케이션 쪽(값객체)의 몫이 됩니다. 판단이 틀렸을 때의 탈출구도 있습니다. MySQL 기준으로 JSON 경로에 생성 컬럼(generated column)을 만들어 인덱스를 걸 수 있어서, 나중에 조회 요구가 생겨도 되돌릴 수 없는 결정은 아닙니다.
② 민감정보를 묶음으로 다루는가. 암호화나 마스킹을 필드 하나하나가 아니라 블록 단위로 처리하고 싶다면 JSON 묶음이 유리합니다. 지우거나 잠글 때도 값 하나만 다루면 됩니다.
③ 백필과 스키마 거버넌스 비용. JSON은 값 하나만 갱신하면 되고 필드가 늘어도 스키마 변경이 없습니다. 개별 컬럼은 필드마다 마이그레이션 체인지셋·리뷰·배포가 따라붙습니다. 대신 그 거버넌스가 곧 스키마의 가시성이기도 합니다 — JSON 안에 무엇이 들었는지는 스키마만 봐서는 모릅니다.
프론트엔드로 번역하면 이 감각은 이미 있습니다 — 상태를 정규화된 개별 atom들로 쪼갤 것인가, 하나의 상태 객체로 묶을 것인가. 개별 구독과 파생 계산이 필요하면 쪼개고, 늘 통째로 읽고 쓰면 묶습니다. "누가 그 단위로 일을 하는가"가 기준이라는 점이 같습니다.
Recap
판단 기준은 세 가지입니다 — DB가 그 필드로 일(필터·정렬·인덱스·제약)을 해야 하면 컬럼, 암호화·마스킹을 블록으로 다루는 민감정보 묶음이면 JSON, 백필·거버넌스 비용은 JSON이 싸지만 그만큼 스키마 가시성과 DB 레벨 검증을 내줍니다. atom으로 쪼갤지 상태 객체로 묶을지의 감각과 같은 축입니다.
3. 저장 형태는 어댑터의 사정입니다
서두의 병역 필드 네 개(사항·종류·시작일·종료일)에 세 기준을 대보면 이렇게 됩니다. ① DB 레벨의 조회 조건이 아닙니다 — 병역으로 필터링하는 화면이 없습니다. ② "해당 없음"에 건강 사유 같은 민감한 맥락이 포괄될 수 있는 민감정보 묶음입니다. ③ 나중에 규칙이 바뀌어도 값 하나만 백필하면 됩니다. 세 기준이 모두 JSON을 가리켰고, 그래서 JSON 컬럼 하나가 됐습니다.
저장은 JPA의 컨버터가 맡습니다. 값객체와 JSON 문자열 사이를 오가는, 필드 하나짜리 직렬화 계층입니다.
// DB 어댑터 — 값객체 ↔ JSON 문자열을 오가는 컨버터. 모델은 이 사정을 모른다
@Convert(converter = MilitaryServiceJsonConverter::class)
@Column(name = "military")
val military: MilitaryService = MilitaryService()
여기서 1편의 주제가 그대로 다시 작동합니다. 영속성이 JSON이라고 해서 모델이나 Response까지 JSON 모양일 필요는 없습니다. 도메인 안에서 병역은 정합성 규칙을 가진 값객체고, 공개 API에서는 flat한 4개 필드며, DB에서만 JSON 문자열입니다. 세 표현이 서로를 구속하지 않습니다. 주(主)는 모델이고 저장 형태는 어댑터의 사정입니다.
마지막으로 이 설계에서 실제로 데였던 함정 하나를 남겨두겠습니다. "모른다"와 "해당 없다"는 다른 값입니다. 미입력(UNKNOWN)은 빈값이고, "해당 없음(NOT_APPLICABLE)"은 사용자가 선택한 실제 값입니다. 응답과 집계에서 UNKNOWN만 null로 처리하고 NOT_APPLICABLE은 값으로 취급해야 하는데, 저장 모양이 같은 JSON이다 보니 이 구분을 코드 어딘가에서 뭉개기 쉽습니다. TypeScript에서 undefined와 null을 구분해 쓰던 그 감각이 도메인 값 설계에서도 그대로 필요했습니다.
그런데 ③에서 "컬럼은 마이그레이션이 따라붙는다"고 했습니다. 그 마이그레이션이라는 것은 대체 어떻게 관리될까요 — 스키마에도 git이 필요하다는 이야기, 다음 글에서 하겠습니다.
Recap
병역 필드는 세 기준(DB 레벨 조회 없음, 민감정보 묶음, 백필 단순)이 모두 JSON을 가리킨 사례입니다. 영속성이 JSON이어도 모델은 값객체, 응답은 flat 필드로 — 저장 형태는 다른 표현들을 구속하지 않는 어댑터의 사정입니다. 그리고 미입력(UNKNOWN)과 "해당 없음(NOT_APPLICABLE)"은 다른 값이라는 구분을 잃지 않아야 합니다.
FE ↔ BE 대응표
이 글에서 다룬 대응입니다.
| BE 개념 | FE에서 가장 가까운 것 | 대응의 핵심 |
|---|---|---|
| 개별 컬럼 vs JSON 컬럼 | 정규화된 atom들 vs 상태 객체 | 그 단위로 일하는 쪽이 누구인가 |
| 저장 형태의 독립성 | 스토리지 포맷 ≠ 앱 상태 모양 | 표현들이 서로를 구속하지 않는다 |
| UNKNOWN ≠ NOT_APPLICABLE | undefined ≠ null | 미입력과 명시적 값의 구분 |
References
컬럼 추가 비용
- MySQL 8.0 Reference Manual — Online DDL Operations (Instant ADD COLUMN)
- PostgreSQL Documentation — ALTER TABLE (Notes)
JSON 컬럼
