MikroORM JSON 컬럼
관계형 데이터베이스는 정해진 스키마가 장점이지만, 모든 데이터가 고정된 컬럼 구조에 딱 맞아떨어지는 건 아니다. 사용자별 설정값, 메타데이터, 다국어 텍스트처럼 구조가 유동적인 데이터를 저장해야 할 때가 있다. 이런 데이터를 정규화하면 테이블이 불필요하게 늘어나고, EAV(Entity-Attribute-Value) 패턴을 쓰면 쿼리가 복잡해진다.
PostgreSQL의 jsonb 타입은 이 문제를 해결한다. JSON 데이터를 바이너리로 저장하면서도 내부 필드에 인덱스를 걸고 쿼리할 수 있다. MikroORM에서는 type: 'json'으로 간단하게 JSON 컬럼을 정의하고, TypeScript 타입 안전성까지 유지할 수 있다.
JSON vs JSONB
PostgreSQL에는 json과 jsonb 두 가지 JSON 타입이 있다. 차이를 알아야 올바른 선택을 할 수 있다.
json 타입은 입력된 텍스트를 그대로 저장한다. 저장할 때 파싱 비용이 없지만, 읽을 때마다 파싱해야 한다. 키 순서와 중복 키가 보존되고, 인덱스를 걸 수 없다.
jsonb 타입은 바이너리 형태로 분해해서 저장한다. 저장 시 파싱 비용이 있지만, 읽기가 빠르다. 키 순서가 보장되지 않고 중복 키는 마지막 값만 유지된다. 가장 중요한 차이는 GIN 인덱스를 걸 수 있다는 점이다.
json → 텍스트 그대로 저장, 인덱스 불가, 쓰기 빠름
jsonb → 바이너리 저장, GIN 인덱스 가능, 읽기/쿼리 빠름
실무에서는 거의 항상 jsonb를 쓴다. MikroORM에서 type: 'json'으로 정의하면 PostgreSQL에서는 자동으로 jsonb 컬럼이 생성된다.
기본 정의
MikroORM에서 JSON 컬럼을 정의하는 방법은 간단하다.
@Entity()
export class User {
@Property({ type: 'json', nullable: true })
settings?: UserSettings;
}
type: 'json'을 지정하면 MikroORM이 내부적으로 JsonType을 사용한다. 이 커스텀 타입이 드라이버별 차이를 통일해준다. 어떤 드라이버는 JSON을 자동 파싱해서 객체로 반환하고, 어떤 드라이버는 문자열 그대로 반환하는데, JsonType이 이 차이를 흡수해서 항상 파싱된 객체를 돌려준다.
TypeScript 인터페이스로 타입 정의
JSON 컬럼의 구조를 인터페이스로 정의하면 타입 안전성을 확보할 수 있다.
interface UserSettings {
theme: 'light' | 'dark';
language: string;
notifications: {
email: boolean;
push: boolean;
frequency: 'immediate' | 'daily' | 'weekly';
};
}
@Entity()
export class User {
@PrimaryKey()
id!: number;
@Property({ type: 'json' })
settings: UserSettings = {
theme: 'light',
language: 'ko',
notifications: {
email: true,
push: true,
frequency: 'immediate',
},
};
}
기본값을 프로퍼티 초기화로 설정하면 INSERT 시 자동으로 기본 설정이 들어간다. 다만 이건 런타임 타입 체크일 뿐이고, 데이터베이스 레벨에서 스키마 검증은 하지 않는다. 외부에서 직접 DB에 값을 넣으면 타입이 깨질 수 있다는 점을 인지해야 한다.
nullable과 기본값 전략
JSON 컬럼을 nullable로 할지, 빈 객체를 기본값으로 할지는 의미가 다르다.
// "설정이 없음" (null)과 "빈 설정" ({})은 다른 의미
@Property({ type: 'json', nullable: true })
metadata?: Record<string, unknown>;
// 항상 객체가 존재하되, 비어 있을 수 있음
@Property({ type: 'json' })
metadata: Record<string, unknown> = {};
"아직 설정한 적 없음"과 "설정했는데 비어 있음"을 구분해야 한다면 nullable을 쓰고, 그 구분이 필요 없다면 빈 객체 기본값이 더 편하다. 빈 객체 기본값을 쓰면 user.metadata.someKey 접근 시 null 체크를 생략할 수 있다.
JSON 속성으로 쿼리
MikroORM에서는 JSON 내부 속성을 일반 필드처럼 쿼리할 수 있다. 중첩된 속성도 객체 구조를 그대로 사용하면 된다.
// 단일 속성 쿼리
const darkUsers = await em.find(User, {
settings: {
theme: 'dark',
},
});
// 중첩 속성 쿼리
const emailUsers = await em.find(User, {
settings: {
notifications: {
email: true,
frequency: 'immediate',
},
},
});
PostgreSQL에서 이 쿼리는 다음과 같이 변환된다.
select "u0".*
from "user" as "u0"
where ("settings"->>'theme') = 'dark'
MikroORM이 값의 타입을 감지해서 적절한 캐스팅을 자동으로 추가한다. boolean이면 ::bool, 숫자면 ::float8으로 캐스팅한다.
-- boolean 값 쿼리
where ("settings"->'notifications'->>'email')::bool = true
-- 숫자 값 쿼리
where ("settings"->>'maxRetries')::float8 = 3
이 기능은 PostgreSQL, MySQL, SQLite, MongoDB 모두에서 동작한다. 드라이버마다 내부적으로 생성하는 SQL은 다르지만, MikroORM API는 동일하다.
주의: 부분 일치 vs 정확 일치
JSON 쿼리는 지정한 키만 비교하는 부분 일치다. { theme: 'dark' }로 쿼리하면 settings 객체에 다른 키가 뭐가 있든 theme이 'dark'인 레코드를 모두 반환한다. JSON 객체 전체가 정확히 일치하는지 비교하는 게 아니다.
인덱스
JSON 컬럼 자체에 B-tree 인덱스를 거는 건 의미가 없다. JSON 내부의 특정 필드를 자주 쿼리한다면 그 필드에 인덱스를 걸어야 한다. MikroORM에서는 엔티티 레벨 @Index() 데코레이터에 dot path를 사용한다.
@Entity()
@Index({ properties: 'settings.theme' })
@Index({ properties: ['settings.language', 'settings.theme'] }) // 복합 인덱스
export class User {
@Property({ type: 'json', nullable: true })
settings?: UserSettings;
}
이 코드는 PostgreSQL에서 다음 DDL을 생성한다.
create index "user_settings_theme_index"
on "user" (("settings"->>'theme'));
->> 연산자는 JSON 값을 텍스트로 추출한다. 즉 이 인덱스는 settings.theme의 텍스트 값에 대한 B-tree 인덱스다.
GIN 인덱스
특정 필드가 아니라 JSON 객체 전체를 대상으로 다양한 쿼리를 해야 한다면 GIN 인덱스가 적합하다. GIN(Generalized Inverted Index)은 JSON의 모든 키-값 쌍을 인덱싱해서 @> (contains) 연산자를 빠르게 처리한다.
MikroORM에서 GIN 인덱스를 직접 정의하는 데코레이터 옵션은 없으므로 마이그레이션에서 직접 SQL을 작성한다.
CREATE INDEX "user_settings_gin" ON "user" USING GIN ("settings");
GIN 인덱스는 쓰기 성능을 약간 희생하는 대신 다양한 JSON 쿼리를 빠르게 처리한다. 어떤 키로 검색할지 미리 알 수 없는 메타데이터 같은 경우에 유용하다.
유니크 인덱스
JSON 속성에 유니크 제약 조건도 걸 수 있다.
@Entity()
@Unique({ properties: 'profile.email' })
export class User {
@Property({ type: 'json' })
profile!: { email: string; name: string };
}
변경 감지
MikroORM의 Unit of Work는 엔티티의 변경 사항을 자동으로 감지해서 flush() 시 UPDATE 쿼리를 생성한다. JSON 컬럼도 마찬가지인데, 동작 방식을 이해해야 의도치 않은 문제를 피할 수 있다.
const user = await em.findOneOrFail(User, 1);
user.settings.theme = 'dark';
await em.flush(); // UPDATE 실행됨
MikroORM은 엔티티를 로드할 때 JSON 값의 스냅샷을 저장해둔다. flush() 시점에 현재 값과 스냅샷을 깊은 비교(deep comparison)해서 변경 여부를 판단한다. 중첩된 속성을 변경해도 정상적으로 감지된다.
// 이것도 감지됨
user.settings.notifications.push = false;
await em.flush();
객체 전체 교체 vs 속성 수정
둘 다 정상 동작한다. 하지만 성능 관점에서는 객체 전체를 교체하든 속성만 수정하든 어차피 JSON 컬럼 전체가 UPDATE된다. 관계형 DB에서 JSON 컬럼의 부분 업데이트는 불가능하기 때문이다.
// 속성만 수정 → settings 전체가 UPDATE됨
user.settings.theme = 'dark';
// 전체 교체 → 마찬가지로 settings 전체가 UPDATE됨
user.settings = { ...user.settings, theme: 'dark' };
JSON 객체가 매우 크다면(수십 KB 이상) 이 점이 성능에 영향을 줄 수 있다. 그런 경우에는 별도 테이블로 분리하는 것을 고려해야 한다.
실전 활용 패턴
패턴 1: 사용자 설정 (유연한 스키마)
가장 흔한 활용이다. 설정 항목이 자주 추가/변경되는 경우, 매번 마이그레이션을 만드는 대신 JSON 컬럼 하나로 관리한다.
interface AppSettings {
theme: 'light' | 'dark';
fontSize: number;
sidebar: {
collapsed: boolean;
width: number;
};
shortcuts: Record<string, string>;
}
@Entity()
export class UserPreference {
@ManyToOne(() => User)
user!: User;
@Property({ type: 'json' })
settings: AppSettings = {
theme: 'light',
fontSize: 14,
sidebar: { collapsed: false, width: 240 },
shortcuts: {},
};
}
새 설정 항목을 추가할 때 DB 마이그레이션이 필요 없다. TypeScript 인터페이스만 업데이트하면 된다. 다만 기존 레코드에는 새 필드가 없으므로, 코드에서 기본값 처리를 해줘야 한다.
// 기본값 병합 유틸리티
function withDefaults(saved: Partial<AppSettings>): AppSettings {
return {
theme: 'light',
fontSize: 14,
sidebar: { collapsed: false, width: 240 },
shortcuts: {},
...saved,
sidebar: {
collapsed: false,
width: 240,
...saved.sidebar,
},
};
}
패턴 2: 다국어 텍스트
고정된 컬럼 대신 JSON으로 다국어 텍스트를 저장하면 지원 언어를 자유롭게 확장할 수 있다.
type LocalizedText = Record<string, string>;
@Entity()
export class Product {
@Property({ type: 'json' })
name!: LocalizedText;
// { ko: '키보드', en: 'Keyboard', ja: 'キーボード' }
@Property({ type: 'json' })
description!: LocalizedText;
}
// 현재 로케일에 맞는 텍스트 조회
const product = await em.findOneOrFail(Product, id);
const name = product.name[currentLocale] ?? product.name['ko'];
패턴 3: 이벤트/감사 로그의 payload
이벤트 종류마다 payload 구조가 다른 경우, 타입별로 테이블을 만드는 대신 JSON 컬럼으로 통합한다.
interface AuditPayload {
[key: string]: unknown;
}
@Entity()
@Index({ properties: 'payload.targetId' })
export class AuditLog {
@PrimaryKey()
id!: number;
@Enum(() => AuditAction)
action!: AuditAction;
@Property({ type: 'json' })
payload!: AuditPayload;
@Property()
createdAt: Date = new Date();
}
// 사용 예
await em.persistAndFlush(
em.create(AuditLog, {
action: AuditAction.USER_UPDATED,
payload: {
targetId: user.id,
changes: { theme: { from: 'light', to: 'dark' } },
},
}),
);
JSON 컬럼을 쓰면 안 되는 경우
JSON이 만능은 아니다. 다음 경우에는 정규화된 테이블이 낫다.
자주 JOIN이 필요한 관계 데이터. JSON 안의 ID 배열로 관계를 표현하면 FK 제약 조건이 없어서 데이터 무결성을 보장할 수 없고, JOIN도 비효율적이다.
대부분의 쿼리가 JSON 내부 필드를 기준으로 필터/정렬하는 경우. B-tree 인덱스로 커버할 수 있는 범위가 제한적이라 복잡한 쿼리 성능이 떨어진다. WHERE, ORDER BY, GROUP BY에 JSON 필드가 자주 등장하면 정규 컬럼으로 빼는 게 낫다.
값의 크기가 매우 큰 경우. 앞서 말한 것처럼 JSON 컬럼은 부분 업데이트가 안 된다. 속성 하나를 바꿔도 전체 JSON이 다시 쓰여진다. 수십 KB 이상이면 성능 이슈가 생길 수 있다.
스키마 검증이 중요한 경우. JSON은 DB 레벨에서 스키마 검증을 하지 않는다. CHECK 제약 조건으로 부분적으로 검증할 수는 있지만 한계가 있다. 데이터 무결성이 핵심이라면 정규 컬럼을 쓰자.
정리
| 기능 | 방법 |
|---|---|
| JSON 컬럼 정의 | @Property({ type: 'json' }) |
| 타입 안전성 | TypeScript 인터페이스 지정 |
| 내부 속성 쿼리 | 객체 구조 그대로 사용 (자동 캐스팅) |
| 필드 인덱스 | @Index({ properties: 'field.path' }) |
| GIN 인덱스 | 마이그레이션에서 직접 SQL |
| 변경 감지 | 자동 (deep comparison) |
JSON 컬럼은 "구조가 유동적이고, 주로 통째로 읽고 쓰는 데이터"에 적합하다. 설정값, 메타데이터, 다국어 텍스트, 이벤트 payload 같은 경우가 대표적이다. 하지만 관계 데이터나 자주 쿼리/정렬하는 데이터에는 정규화가 여전히 정답이다. 도구의 장단점을 알고 상황에 맞게 선택하는 게 핵심이다.