콘텐츠 대표 이미지 - PostgreSQL JSONB 컬럼을 활용한 반정형 데이터 처리

PostgreSQL JSONB 컬럼을 활용한 반정형 데이터 처리

유연한 스키마로 현대적 데이터 관리하기 🚀
PostgreSQL 관계형 DB 정형 데이터 JSONB 유연한 스키마 반정형 데이터 하이브리드 최강 조합! 빠른 쿼리 인덱싱 확장성 JSON + Binary = JSONB 💪

🎯 왜 JSONB가 핫한가요?

요즘 개발하다 보면 데이터 구조가 계속 바뀌잖아요 ㅋㅋㅋ 처음엔 사용자 정보에 이름이랑 이메일만 있다가, 갑자기 프로필 사진 추가하고, SNS 링크도 넣고, 취미 목록도 넣고... 이럴 때마다 테이블 스키마 수정하면 진짜 빡치거든요 😤

그래서 등장한 게 바로 JSONB예요! PostgreSQL이 제공하는 이 기능은 관계형 DB의 장점은 그대로 가져가면서, NoSQL처럼 유연한 스키마를 사용할 수 있게 해줍니다.

JSON이랑 JSONB가 뭐가 다르냐고요? JSON은 그냥 텍스트로 저장되는데, JSONB는 바이너리 형태로 저장돼서 훨씬 빠르고 인덱싱도 가능해요. 진짜 개꿀 🍯

💡 핵심 포인트
JSONB = JSON + Binary 형태로 저장
→ 빠른 검색, 인덱싱 가능, 중복 키 자동 제거
→ 디스크 공간은 조금 더 차지하지만 성능은 압도적!

📊 반정형 데이터가 뭔데?

데이터 형태를 크게 3가지로 나눌 수 있어요:

데이터 유형 특징 예시
정형 데이터 명확한 스키마, 고정된 구조 관계형 DB 테이블
반정형 데이터 유연한 구조, 메타데이터 포함 JSON, XML, 로그 파일
비정형 데이터 구조 없음 이미지, 동영상, 텍스트

반정형 데이터는 완전히 자유로운 건 아니지만, 그렇다고 딱딱하게 정해진 것도 아닌 애매한(?) 중간 형태예요 ㅋㅋ
근데 이게 실무에서 진짜 많이 쓰입니다. API 응답 데이터, 사용자 설정 정보, 로그 데이터 같은 거요!

🔧 JSONB 컬럼 생성하기

자, 이제 실전으로 들어가볼까요? 먼저 테이블을 만들어봅시다.
예를 들어 사용자 프로필 정보를 저장하는 테이블을 만든다고 가정해볼게요:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL,
    profile JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

여기서 profile 컬럼이 JSONB 타입이에요. 이 안에 뭐든지 넣을 수 있습니다 🎉

데이터를 넣어볼까요?

INSERT INTO users (username, email, profile) VALUES 
(
    'devmaster',
    'dev@example.com',
    '{
        "age": 28,
        "bio": "풀스택 개발자입니다",
        "skills": ["Python", "JavaScript", "PostgreSQL"],
        "social": {
            "github": "github.com/devmaster",
            "blog": "devblog.com"
        },
        "preferences": {
            "theme": "dark",
            "notifications": true
        }
    }'
);

보세요! 중첩된 객체도 들어가고, 배열도 들어가고... 완전 자유롭죠? 😎

🎓 꿀팁!
JSONB에 데이터 넣을 때 작은따옴표로 전체를 감싸고, 내부는 큰따옴표를 사용해야 해요. JSON 표준 형식이거든요!

🔍 JSONB 데이터 조회하기

이제 저장한 데이터를 꺼내볼 차례예요. PostgreSQL은 JSONB 조회를 위한 다양한 연산자를 제공합니다:

1. 기본 조회 연산자

-- 전체 JSONB 데이터 가져오기
SELECT profile FROM users WHERE username = 'devmaster';

-- 특정 키의 값 가져오기 (-> 연산자, 결과는 JSONB)
SELECT profile -> 'age' FROM users;

-- 특정 키의 값을 텍스트로 가져오기 (->> 연산자)
SELECT profile ->> 'bio' FROM users;

-- 중첩된 객체 접근
SELECT profile -> 'social' ->> 'github' FROM users;

-- 배열 요소 접근 (인덱스는 0부터 시작)
SELECT profile -> 'skills' -> 0 FROM users;

연산자가 좀 헷갈리죠? ㅋㅋ 간단하게 정리하면:
-> : JSONB 타입으로 반환 (추가 연산 가능)
->> : 텍스트 타입으로 반환 (최종 값)

2. 경로 기반 조회

깊이 중첩된 데이터는 #>와 #>> 연산자를 사용하면 편해요:

-- 경로를 배열로 지정 (JSONB 반환)
SELECT profile #> '{social, github}' FROM users;

-- 경로를 배열로 지정 (텍스트 반환)
SELECT profile #>> '{social, github}' FROM users;

-- 배열 요소도 경로에 포함 가능
SELECT profile #>> '{skills, 0}' FROM users;

🎯 조건부 검색 - 진짜 파워풀한 부분!

JSONB의 진가는 바로 조건부 검색에서 나타나요. WHERE 절에서 JSONB 내부 값으로 필터링할 수 있거든요!

1. 값으로 검색하기

-- age가 25 이상인 사용자 찾기
SELECT username, profile ->> 'age' as age 
FROM users 
WHERE (profile ->> 'age')::int >= 25;

-- 특정 스킬을 가진 사용자 찾기 (@> 포함 연산자)
SELECT username 
FROM users 
WHERE profile -> 'skills' @> '"Python"';

-- dark 테마를 사용하는 사용자
SELECT username 
FROM users 
WHERE profile #>> '{preferences, theme}' = 'dark';

여기서 ::int는 타입 캐스팅이에요. JSONB에서 꺼낸 값은 기본적으로 텍스트나 JSONB 타입이라서, 숫자 비교를 하려면 형변환이 필요합니다 🔄

2. 존재 여부 확인

-- 'bio' 키가 존재하는 사용자
SELECT username 
FROM users 
WHERE profile ? 'bio';

-- 여러 키 중 하나라도 존재 (?| 연산자)
SELECT username 
FROM users 
WHERE profile ?| ARRAY['bio', 'description'];

-- 모든 키가 존재 (?& 연산자)
SELECT username 
FROM users 
WHERE profile ?& ARRAY['age', 'bio', 'skills'];

3. 포함 관계 검색

이게 진짜 개꿀인데요 ㅋㅋㅋ @> 연산자로 특정 구조를 포함하는지 확인할 수 있어요:

-- 특정 social 정보를 가진 사용자
SELECT username 
FROM users 
WHERE profile @> '{"social": {"github": "github.com/devmaster"}}';

-- 알림을 켠 사용자
SELECT username 
FROM users 
WHERE profile @> '{"preferences": {"notifications": true}}';

-- Python과 JavaScript 둘 다 할 줄 아는 사용자
SELECT username 
FROM users 
WHERE profile -> 'skills' @> '["Python", "JavaScript"]';

반대로 <@ 연산자는 "포함되는지" 확인해요. 방향만 반대!

⚠️ 주의사항
JSONB 검색은 강력하지만, 인덱스 없이 사용하면 성능이 떨어질 수 있어요. 나중에 인덱싱 부분에서 자세히 다룰게요!

✏️ JSONB 데이터 수정하기

데이터를 넣었으면 수정도 할 줄 알아야겠죠? JSONB는 수정도 엄청 유연하게 할 수 있어요!

1. 전체 교체

-- profile 전체를 새로운 JSON으로 교체
UPDATE users 
SET profile = '{"age": 29, "bio": "시니어 개발자"}'
WHERE username = 'devmaster';

2. 특정 키 값만 수정 (|| 연산자)

-- age만 업데이트 (나머지는 유지)
UPDATE users 
SET profile = profile || '{"age": 30}'
WHERE username = 'devmaster';

-- 여러 필드 동시 업데이트
UPDATE users 
SET profile = profile || '{"age": 30, "bio": "풀스택 시니어"}'
WHERE username = 'devmaster';

|| 연산자는 두 JSONB를 병합하는데, 같은 키가 있으면 오른쪽 값으로 덮어씌워요. 완전 편함 👍

3. 중첩된 값 수정 (jsonb_set 함수)

-- social.github 값만 변경
UPDATE users 
SET profile = jsonb_set(
    profile, 
    '{social, github}', 
    '"github.com/newusername"'
)
WHERE username = 'devmaster';

-- preferences.theme 변경
UPDATE users 
SET profile = jsonb_set(
    profile, 
    '{preferences, theme}', 
    '"light"'
)
WHERE username = 'devmaster';

jsonb_set 함수는 3개의 인자를 받아요:
1. 대상 JSONB
2. 경로 (배열 형태)
3. 새로운 값 (JSONB 형태)

4. 배열에 요소 추가

-- skills 배열에 새 스킬 추가
UPDATE users 
SET profile = jsonb_set(
    profile,
    '{skills}',
    (profile -> 'skills') || '"React"'
)
WHERE username = 'devmaster';

-- 또는 jsonb_insert 사용 (특정 위치에 삽입)
UPDATE users 
SET profile = jsonb_insert(
    profile,
    '{skills, 0}',
    '"TypeScript"'
)
WHERE username = 'devmaster';

5. 키 삭제 (- 연산자)

-- age 키 삭제
UPDATE users 
SET profile = profile - 'age'
WHERE username = 'devmaster';

-- 여러 키 동시 삭제
UPDATE users 
SET profile = profile - '{age, bio}'
WHERE username = 'devmaster';

-- 중첩된 키 삭제 (#- 연산자)
UPDATE users 
SET profile = profile #- '{social, github}'
WHERE username = 'devmaster';
🎨 실무 팁
JSONB 수정할 때는 트랜잭션 안에서 하는 게 안전해요. 특히 복잡한 중첩 구조를 다룰 때는 롤백 가능하도록 해두는 게 좋습니다!

⚡ JSONB 인덱싱 - 성능 끌어올리기

자, 여기가 진짜 중요한 부분이에요! JSONB가 아무리 좋아도 인덱스 없이 쓰면 데이터 많아질 때 느려지거든요 😅

1. GIN 인덱스 (Generalized Inverted Index)

JSONB에는 주로 GIN 인덱스를 사용해요. 이게 뭐냐면, JSON 내부의 모든 키-값 쌍을 인덱싱하는 거예요:

-- 기본 GIN 인덱스 생성
CREATE INDEX idx_users_profile ON users USING GIN (profile);

-- 이제 @>, ?, ?&, ?| 연산자를 사용한 쿼리가 빨라져요!
SELECT * FROM users WHERE profile @> '{"age": 28}';

GIN 인덱스는 포함 관계 검색(@>)이나 존재 여부 확인(?)에 최적화되어 있어요. 진짜 빠름 🚀

2. 특정 경로에 대한 인덱스

전체 JSONB를 인덱싱하는 게 부담스럽다면, 자주 검색하는 특정 경로만 인덱싱할 수 있어요:

-- age 필드에 대한 B-tree 인덱스
CREATE INDEX idx_users_age ON users ((profile ->> 'age'));

-- 이제 age로 검색하는 쿼리가 빨라져요
SELECT * FROM users WHERE profile ->> 'age' = '28';

-- 숫자 비교를 위한 인덱스 (타입 캐스팅 포함)
CREATE INDEX idx_users_age_int ON users (((profile ->> 'age')::int));

-- theme 필드 인덱스
CREATE INDEX idx_users_theme ON users ((profile #>> '{preferences, theme}'));

3. jsonb_path_ops를 사용한 최적화

더 작고 빠른 인덱스가 필요하다면 jsonb_path_ops를 사용할 수 있어요:

CREATE INDEX idx_users_profile_ops ON users USING GIN (profile jsonb_path_ops);

이 방식은 인덱스 크기가 더 작고 @> 연산이 더 빨라요. 하지만 ?, ?&, ?| 연산자는 지원 안 해요. 트레이드오프가 있는 거죠!

인덱스 타입 장점 단점
GIN (기본) 모든 연산자 지원, 유연함 인덱스 크기 큼
GIN (path_ops) 작고 빠름, @> 최적화 일부 연산자 미지원
B-tree (특정 경로) 정확한 매칭에 빠름 하나의 경로만 커버
💰 성능 팁!
인덱스는 공짜가 아니에요. INSERT/UPDATE 시 오버헤드가 있으니, 정말 자주 검색하는 필드에만 인덱스를 걸어야 해요. 실제 쿼리 패턴을 분석해서 결정하세요!

🛠️ 유용한 JSONB 함수들

PostgreSQL은 JSONB를 다루기 위한 다양한 함수를 제공해요. 실무에서 자주 쓰이는 것들을 정리해볼게요!

1. jsonb_each / jsonb_each_text

JSONB 객체를 키-값 쌍으로 분해해요:

-- JSONB 형태로 반환
SELECT * FROM jsonb_each('{"a": 1, "b": 2, "c": 3}'::jsonb);
-- 결과: (a, 1), (b, 2), (c, 3)

-- 텍스트 형태로 반환
SELECT * FROM jsonb_each_text('{"name": "홍길동", "age": "30"}'::jsonb);

2. jsonb_object_keys

모든 키를 추출해요:

SELECT jsonb_object_keys(profile) FROM users;
-- 결과: age, bio, skills, social, preferences (각각 별도 행으로)

3. jsonb_array_elements

배열을 개별 요소로 분해해요:

-- 모든 사용자의 스킬을 개별 행으로
SELECT username, jsonb_array_elements(profile -> 'skills') as skill
FROM users;

이거 진짜 유용해요! 배열 안의 각 요소를 별도 행으로 만들어서 집계나 필터링하기 좋거든요 ㅋㅋ

4. jsonb_build_object / jsonb_build_array

동적으로 JSONB를 생성해요:

-- 객체 생성
SELECT jsonb_build_object(
    'username', username,
    'email', email,
    'age', profile ->> 'age'
) FROM users;

-- 배열 생성
SELECT jsonb_build_array(
    username,
    email,
    profile ->> 'age'
) FROM users;

5. jsonb_strip_nulls

null 값을 가진 키를 제거해요:

SELECT jsonb_strip_nulls('{"a": 1, "b": null, "c": 3}'::jsonb);
-- 결과: {"a": 1, "c": 3}

6. jsonb_pretty

JSONB를 예쁘게 포맷팅해서 출력해요 (디버깅용으로 굿!):

SELECT jsonb_pretty(profile) FROM users;
-- 들여쓰기된 JSON 형태로 출력

7. jsonb_typeof

값의 타입을 확인해요:

SELECT jsonb_typeof(profile -> 'age') FROM users;
-- 결과: number, string, boolean, array, object, null 중 하나

🎓 실전 활용 사례

이론은 충분히 봤으니, 이제 실제로 어떻게 활용하는지 볼까요? 재능넷 같은 플랫폼에서 사용할 수 있는 예제들이에요!

사례 1: 사용자 설정 관리

사용자마다 다른 설정을 저장해야 하는데, 설정 항목이 계속 추가될 수 있는 경우:

CREATE TABLE user_settings (
    user_id INT PRIMARY KEY,
    settings JSONB DEFAULT '{}'::jsonb,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 설정 저장
INSERT INTO user_settings (user_id, settings) VALUES 
(1, '{
    "notifications": {
        "email": true,
        "push": false,
        "sms": true
    },
    "privacy": {
        "profile_visible": true,
        "show_email": false
    },
    "display": {
        "theme": "dark",
        "language": "ko",
        "timezone": "Asia/Seoul"
    }
}');

-- 특정 설정만 업데이트
UPDATE user_settings 
SET settings = jsonb_set(settings, '{display, theme}', '"light"')
WHERE user_id = 1;

-- 이메일 알림을 켠 사용자 찾기
SELECT user_id 
FROM user_settings 
WHERE settings #>> '{notifications, email}' = 'true';

사례 2: 상품 속성 관리

카테고리마다 다른 속성을 가진 상품들을 관리할 때:

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    category VARCHAR(50),
    attributes JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 전자제품
INSERT INTO products (name, category, attributes) VALUES 
('노트북', '전자제품', '{
    "brand": "삼성",
    "model": "갤럭시북",
    "specs": {
        "cpu": "Intel i7",
        "ram": "16GB",
        "storage": "512GB SSD"
    },
    "warranty": "2년"
}');

-- 의류
INSERT INTO products (name, category, attributes) VALUES 
('티셔츠', '의류', '{
    "brand": "나이키",
    "size": ["S", "M", "L", "XL"],
    "color": ["black", "white", "navy"],
    "material": "면 100%",
    "care": "세탁기 사용 가능"
}');

-- 16GB 램을 가진 제품 찾기
SELECT name, attributes -> 'specs' ->> 'ram' as ram
FROM products 
WHERE attributes #>> '{specs, ram}' = '16GB';

-- 특정 사이즈가 있는 의류 찾기
SELECT name 
FROM products 
WHERE category = '의류' 
AND attributes -> 'size' @> '"L"';

사례 3: 이벤트 로깅

다양한 형태의 이벤트를 기록할 때 JSONB가 진짜 유용해요:

CREATE TABLE event_logs (
    id SERIAL PRIMARY KEY,
    user_id INT,
    event_type VARCHAR(50),
    event_data JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_event_logs_data ON event_logs USING GIN (event_data);
CREATE INDEX idx_event_logs_type ON event_logs (event_type);

-- 로그인 이벤트
INSERT INTO event_logs (user_id, event_type, event_data) VALUES 
(1, 'login', '{
    "ip": "192.168.1.100",
    "device": "iPhone 13",
    "browser": "Safari",
    "location": "Seoul, Korea"
}');

-- 구매 이벤트
INSERT INTO event_logs (user_id, event_type, event_data) VALUES 
(1, 'purchase', '{
    "product_id": 123,
    "amount": 50000,
    "payment_method": "card",
    "card_type": "신용카드"
}');

-- 특정 IP에서의 로그인 시도 찾기
SELECT user_id, created_at 
FROM event_logs 
WHERE event_type = 'login' 
AND event_data ->> 'ip' = '192.168.1.100'
ORDER BY created_at DESC;

-- 최근 24시간 구매 통계
SELECT 
    COUNT(*) as purchase_count,
    SUM((event_data ->> 'amount')::int) as total_amount
FROM event_logs 
WHERE event_type = 'purchase' 
AND created_at > NOW() - INTERVAL '24 hours';

사례 4: API 응답 캐싱

외부 API 응답을 캐싱할 때도 JSONB가 좋아요:

CREATE TABLE api_cache (
    cache_key VARCHAR(255) PRIMARY KEY,
    response_data JSONB,
    expires_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 캐시 저장
INSERT INTO api_cache (cache_key, response_data, expires_at) VALUES 
('weather_seoul_20240101', '{
    "temperature": 5,
    "humidity": 60,
    "condition": "맑음",
    "forecast": [
        {"time": "09:00", "temp": 3},
        {"time": "12:00", "temp": 7},
        {"time": "15:00", "temp": 8}
    ]
}', NOW() + INTERVAL '1 hour');

-- 캐시 조회 (만료되지 않은 것만)
SELECT response_data 
FROM api_cache 
WHERE cache_key = 'weather_seoul_20240101' 
AND expires_at > NOW();

-- 만료된 캐시 삭제
DELETE FROM api_cache WHERE expires_at < NOW();

⚠️ JSONB 사용 시 주의사항

JSONB가 좋긴 한데, 무작정 쓰면 안 돼요! 몇 가지 주의할 점들이 있어요:

1. 과도한 사용 금지

모든 걸 JSONB에 때려넣으면 안 됩니다 ㅋㅋㅋ
자주 검색하고 조인해야 하는 데이터는 일반 컬럼으로 빼는 게 좋아요:

❌ 나쁜 예:
사용자 이메일, 이름 같은 핵심 정보를 JSONB에 넣기

✅ 좋은 예:
핵심 정보는 일반 컬럼, 부가 정보만 JSONB 사용

2. 데이터 무결성 관리

JSONB는 스키마가 자유로운 만큼, 데이터 검증이 어려워요. 애플리케이션 레벨에서 검증 로직을 꼭 넣어야 해요:

-- CHECK 제약조건으로 최소한의 검증 가능
ALTER TABLE users 
ADD CONSTRAINT check_profile_has_age 
CHECK (profile ? 'age');

-- 또는 트리거로 복잡한 검증
CREATE OR REPLACE FUNCTION validate_profile()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.profile ->> 'age' IS NOT NULL THEN
        IF (NEW.profile ->> 'age')::int < 0 OR (NEW.profile ->> 'age')::int > 150 THEN
            RAISE EXCEPTION 'Invalid age value';
        END IF;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER validate_profile_trigger
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION validate_profile();

3. 인덱스 전략

인덱스 없이 JSONB 검색하면 풀 테이블 스캔이 발생해요. 꼭 필요한 곳에 인덱스를 걸어야 합니다!

-- 쿼리 성능 확인
EXPLAIN ANALYZE 
SELECT * FROM users WHERE profile @> '{"age": 28}';

-- 인덱스 효과 확인
CREATE INDEX idx_users_profile ON users USING GIN (profile);
EXPLAIN ANALYZE 
SELECT * FROM users WHERE profile @> '{"age": 28}';

4. 크기 제한

JSONB도 결국 저장 공간을 차지해요. 너무 큰 JSON을 넣으면 성능 문제가 생길 수 있어요:
- 단일 JSONB 값은 1GB까지 가능하지만, 실무에서는 수십~수백 KB 정도가 적당
- 너무 크면 별도 테이블로 분리하거나 파일 저장 고려

5. 타입 캐스팅 주의

JSONB에서 꺼낸 값은 기본적으로 텍스트예요. 비교 연산 시 타입 변환을 잊지 마세요:

-- ❌ 잘못된 비교 (문자열 비교)
WHERE profile ->> 'age' > '25'  -- '3'이 '25'보다 크다고 나올 수 있음!

-- ✅ 올바른 비교 (숫자 비교)
WHERE (profile ->> 'age')::int > 25

🚀 성능 최적화 팁

JSONB를 실무에서 제대로 활용하려면 성능 최적화가 필수예요! 몇 가지 꿀팁을 공유할게요:

1. 부분 인덱스 활용

모든 데이터를 인덱싱할 필요 없어요. 자주 검색하는 조건에만 부분 인덱스를 걸면 효율적이에요:

-- 활성 사용자의 프로필만 인덱싱
CREATE INDEX idx_active_users_profile 
ON users USING GIN (profile) 
WHERE status = 'active';

-- 특정 카테고리 상품만 인덱싱
CREATE INDEX idx_electronics_attributes 
ON products USING GIN (attributes) 
WHERE category = '전자제품';

2. VACUUM과 ANALYZE

JSONB 데이터가 자주 변경되면 테이블이 비대해질 수 있어요. 정기적으로 유지보수를 해줘야 해요:

-- 테이블 정리
VACUUM ANALYZE users;

-- 통계 정보 업데이트 (쿼리 플래너가 더 나은 실행 계획 수립)
ANALYZE users;

3. 적절한 컬럼 분리

자주 변경되는 데이터와 그렇지 않은 데이터를 분리하면 성능이 좋아져요:

-- ❌ 나쁜 설계
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    data JSONB  -- 모든 정보를 여기에
);

-- ✅ 좋은 설계
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50),  -- 자주 검색
    email VARCHAR(100),    -- 자주 검색
    basic_info JSONB,      -- 거의 안 바뀜
    preferences JSONB,     -- 자주 바뀜
    metadata JSONB         -- 가끔 바뀜
);

4. 배치 작업 최적화

대량의 JSONB 데이터를 처리할 때는 배치로 묶어서 처리하세요:

-- 한 번에 하나씩 업데이트 (느림)
DO $$
DECLARE
    user_record RECORD;
BEGIN
    FOR user_record IN SELECT id FROM users LOOP
        UPDATE users 
        SET profile = profile || '{"updated": true}'
        WHERE id = user_record.id;
    END LOOP;
END $$;

-- 한 번에 업데이트 (빠름)
UPDATE users 
SET profile = profile || '{"updated": true}';

5. 쿼리 결과 캐싱

복잡한 JSONB 쿼리 결과는 애플리케이션 레벨에서 캐싱하는 게 좋아요. Redis 같은 걸 활용하면 됩니다!

🔄 JSON vs JSONB 비교

PostgreSQL에는 JSON과 JSONB 두 가지 타입이 있어요. 뭘 써야 할까요?

특징 JSON JSONB
저장 방식 텍스트 그대로 바이너리 형태
입력 속도 빠름 느림 (파싱 필요)
조회 속도 느림 빠름
인덱싱 불가능 가능
공백 보존 보존됨 제거됨
키 순서 보존됨 정렬됨
중복 키 허용 마지막 값만 유지
디스크 사용량 작음 큼

결론: 99%의 경우 JSONB를 쓰세요!
JSON은 원본 형태를 정확히 보존해야 하는 특수한 경우에만 사용하면 돼요.

🎯 마이그레이션 전략

기존 시스템에 JSONB를 도입하려면 어떻게 해야 할까요? 단계별로 알려드릴게요!

1단계: 분석

먼저 어떤 데이터가 JSONB에 적합한지 파악하세요:
- 스키마가 자주 변경되는 데이터
- 선택적 필드가 많은 데이터
- 계층 구조를 가진 데이터
- 외부 API 응답 데이터

2단계: 점진적 도입

한 번에 모든 걸 바꾸지 말고, 작은 부분부터 시작하세요:

-- 기존 테이블에 JSONB 컬럼 추가
ALTER TABLE users ADD COLUMN extra_info JSONB DEFAULT '{}'::jsonb;

-- 기존 데이터를 JSONB로 마이그레이션
UPDATE users 
SET extra_info = jsonb_build_object(
    'old_field1', old_field1,
    'old_field2', old_field2
);

-- 검증 후 기존 컬럼 삭제
-- ALTER TABLE users DROP COLUMN old_field1, DROP COLUMN old_field2;

3단계: 인덱스 구축

데이터 마이그레이션 후 필요한 인덱스를 생성하세요:

-- 동시성 인덱스 생성 (서비스 중단 없이)
CREATE INDEX CONCURRENTLY idx_users_extra_info 
ON users USING GIN (extra_info);

4단계: 모니터링

성능을 지속적으로 모니터링하세요:

-- 느린 쿼리 찾기
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE query LIKE '%extra_info%'
ORDER BY mean_exec_time DESC
LIMIT 10;

-- 인덱스 사용률 확인
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
WHERE tablename = 'users';

💼 실무에서의 베스트 프랙티스

제가 실제로 프로젝트에서 사용하면서 터득한 노하우들이에요 ㅋㅋ

1. 명확한 네이밍 컨벤션

JSONB 내부 키 이름을 일관되게 유지하세요:

-- ✅ 좋은 예: snake_case 일관성
{
    "user_name": "홍길동",
    "birth_date": "1990-01-01",
    "phone_number": "010-1234-5678"
}

-- ❌ 나쁜 예: 혼재된 스타일
{
    "userName": "홍길동",
    "birth_date": "1990-01-01",
    "PhoneNumber": "010-1234-5678"
}

2. 버전 관리

JSONB 구조가 변경될 때를 대비해 버전 정보를 포함하세요:

INSERT INTO users (username, profile) VALUES 
('user1', '{
    "version": "1.0",
    "data": {
        "name": "홍길동",
        "age": 30
    }
}');

-- 나중에 구조 변경 시
UPDATE users 
SET profile = jsonb_set(
    profile,
    '{version}',
    '"2.0"'
)
WHERE profile ->> 'version' = '1.0';

3. 기본값 설정

JSONB 컬럼에 기본값을 설정해서 NULL 처리를 줄이세요:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50),
    settings JSONB DEFAULT '{"theme": "light", "language": "ko"}'::jsonb
);

4. 문서화

JSONB 구조를 문서화하세요. 주석이나 별도 문서로 관리하면 좋아요:

COMMENT ON COLUMN users.profile IS 
'사용자 프로필 정보 (JSONB)
구조:
{
    "age": number,
    "bio": string,
    "skills": string[],
    "social": {
        "github": string,
        "blog": string
    }
}';

5. 에러 핸들링

JSONB 조작 시 에러 처리를 꼼꼼히 하세요:

-- 안전한 타입 캐스팅
SELECT 
    username,
    CASE 
        WHEN profile ? 'age' AND profile ->> 'age' ~ '^\d+$' 
        THEN (profile ->> 'age')::int 
        ELSE NULL 
    END as age
FROM users;
🌟 재능넷에서 활용하기
재능넷 같은 플랫폼에서는 다양한 카테고리의 재능 정보를 관리해야 하잖아요? 각 카테고리마다 필요한 정보가 다를 때 JSONB가 정말 유용해요!

예를 들어 '디자인' 재능은 포트폴리오 링크가 필요하고, '프로그래밍' 재능은 기술 스택 정보가 필요하고, '외국어' 재능은 자격증 정보가 필요한데, 이런 걸 JSONB로 유연하게 관리할 수 있죠 😊

🔮 고급 활용 기법

좀 더 깊이 들어가볼까요? 고급 기법들을 소개할게요!

1. 재귀적 쿼리와 JSONB

계층 구조 데이터를 다룰 때 유용해요:

-- 조직도 데이터
CREATE TABLE organizations (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    structure JSONB
);

INSERT INTO organizations (name, structure) VALUES 
('회사A', '{
    "ceo": {
        "name": "김대표",
        "departments": [
            {
                "name": "개발팀",
                "manager": "이팀장",
                "members": ["박개발", "최개발"]
            },
            {
                "name": "디자인팀",
                "manager": "정팀장",
                "members": ["강디자인"]
            }
        ]
    }
}');

-- 모든 부서 이름 추출
SELECT 
    name,
    jsonb_array_elements(structure -> 'ceo' -> 'departments') ->> 'name' as dept_name
FROM organizations;

2. JSONB와 집계 함수

JSONB 데이터를 집계할 수 있어요:

-- 모든 사용자의 스킬을 하나의 배열로 합치기
SELECT jsonb_agg(DISTINCT skill) as all_skills
FROM (
    SELECT jsonb_array_elements(profile -> 'skills') as skill
    FROM users
) subquery;

-- 카테고리별 평균 가격 (JSONB에서 추출)
SELECT 
    category,
    AVG((attributes ->> 'price')::numeric) as avg_price
FROM products
WHERE attributes ? 'price'
GROUP BY category;

3. JSONB와 윈도우 함수

-- 각 사용자의 최근 3개 활동 로그
SELECT 
    user_id,
    event_type,
    event_data,
    ROW_NUMBER() OVER (
        PARTITION BY user_id 
        ORDER BY created_at DESC
    ) as rn
FROM event_logs
WHERE rn <= 3;

4. 동적 피벗

JSONB를 사용해서 동적으로 컬럼을 생성할 수 있어요:

-- JSONB 키를 컬럼으로 변환
SELECT 
    username,
    (profile ->> 'age')::int as age,
    profile ->> 'bio' as bio,
    profile -> 'skills' as skills
FROM users;

-- 또는 jsonb_to_record 사용
SELECT * FROM jsonb_to_record(
    '{"name": "홍길동", "age": 30, "city": "서울"}'::jsonb
) AS x(name text, age int, city text);

5. Full-Text Search와 JSONB

JSONB 내부 텍스트를 전문 검색할 수 있어요:

-- JSONB 값을 텍스트로 변환해서 검색
CREATE INDEX idx_users_profile_text ON users 
USING GIN (to_tsvector('korean', profile::text));

-- 검색
SELECT username, profile 
FROM users 
WHERE to_tsvector('korean', profile::text) @@ to_tsquery('korean', '개발자');

🐛 트러블슈팅

실무에서 자주 만나는 문제들과 해결 방법이에요!

문제 1: 인덱스를 걸었는데도 느려요

원인: 쿼리가 인덱스를 사용하지 않고 있을 수 있어요.

-- 실행 계획 확인
EXPLAIN ANALYZE 
SELECT * FROM users WHERE profile @> '{"age": 28}';

-- Seq Scan이 나오면 인덱스를 안 쓰는 거예요
-- 해결: 통계 정보 업데이트
ANALYZE users;

-- 또는 쿼리 힌트 사용 (PostgreSQL 12+)
/*+ IndexScan(users idx_users_profile) */
SELECT * FROM users WHERE profile @> '{"age": 28}';

문제 2: JSONB 업데이트가 너무 느려요

원인: 큰 JSONB를 통째로 업데이트하고 있을 수 있어요.

-- ❌ 느린 방법: 전체 JSONB 재작성
UPDATE users 
SET profile = '{"age": 30, "bio": "...", "skills": [...], ...}'
WHERE id = 1;

-- ✅ 빠른 방법: 필요한 부분만 업데이트
UPDATE users 
SET profile = jsonb_set(profile, '{age}', '30')
WHERE id = 1;

문제 3: 타입 에러가 계속 나요

원인: JSONB 값의 타입이 예상과 다를 수 있어요.

-- 안전한 타입 체크
SELECT 
    username,
    CASE 
        WHEN jsonb_typeof(profile -> 'age') = 'number' 
        THEN (profile ->> 'age')::int 
        ELSE NULL 
    END as age
FROM users;

-- 또는 TRY-CATCH 패턴
DO $$
BEGIN
    UPDATE users 
    SET profile = jsonb_set(profile, '{age}', '30')
    WHERE id = 1;
EXCEPTION 
    WHEN OTHERS THEN
        RAISE NOTICE 'Error: %', SQLERRM;
END $$;

문제 4: 메모리 부족 에러

원인: 너무 큰 JSONB를 한 번에 처리하려고 할 때.

-- ❌ 나쁜 예: 모든 데이터를 한 번에
SELECT jsonb_agg(profile) FROM users;

-- ✅ 좋은 예: 페이지네이션
SELECT jsonb_agg(profile) 
FROM (
    SELECT profile FROM users 
    ORDER BY id 
    LIMIT 1000 OFFSET 0
) subquery;
💡 디버깅 팁
JSONB 관련 문제가 생기면 jsonb_pretty() 함수로 데이터를 예쁘게 출력해서 확인하세요. 구조를 한눈에 파악할 수 있어요!

📚 추가 학습 자료

더 깊이 공부하고 싶다면 이런 것들을 참고하세요:

공식 문서:
- PostgreSQL JSONB 공식 문서
- PostgreSQL JSON Functions and Operators

실습 환경:
- Docker로 PostgreSQL 환경 구축
- pgAdmin으로 GUI 관리
- DBeaver 같은 범용 DB 도구

성능 모니터링:
- pg_stat_statements 확장 활용
- EXPLAIN ANALYZE로 쿼리 분석
- pgBadger로 로그 분석

🎬 마무리하며

와... 여기까지 읽으셨다면 진짜 대단하세요! 👏👏👏

PostgreSQL의 JSONB는 정말 강력한 기능이에요. 관계형 DB의 안정성과 NoSQL의 유연성을 동시에 가져갈 수 있거든요. 처음엔 좀 낯설 수 있지만, 익숙해지면 정말 편하고 강력한 도구가 됩니다.

핵심만 다시 정리하면:
✅ JSONB는 바이너리 형태로 저장되어 빠르고 인덱싱 가능
✅ 유연한 스키마로 변화하는 요구사항에 대응 가능
✅ 다양한 연산자와 함수로 강력한 쿼리 작성 가능
✅ 적절한 인덱싱과 최적화로 성능 확보
✅ 하지만 모든 것을 JSONB로 하면 안 됨 - 적재적소에 사용!

실무에서 사용할 때는 항상 성능을 모니터링하고, 필요한 곳에만 JSONB를 사용하세요. 그리고 팀원들과 JSONB 구조에 대한 컨벤션을 정해두는 것도 중요해요!

재능넷 같은 플랫폼을 개발하거나, 다양한 형태의 데이터를 다루는 서비스를 만들 때 JSONB는 정말 유용한 선택지가 될 거예요. 여러분의 프로젝트에도 잘 적용해보시길 바랍니다! 🚀

궁금한 점이 있으면 PostgreSQL 커뮤니티나 스택오버플로우에서 물어보세요. 다들 친절하게 답변해줍니다 ㅋㅋ

그럼 즐거운 코딩 되세요! 화이팅! 💪✨

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.