설치·접속: PostgreSQL 설치와 접속
부제: 게시글 태그를 tags int[] 로 넣을지 article_tag 조인 테이블로 뺄지, 공식 문서 기준으로 판단할 때
공식 배열 문서에는 이런 경고가 박혀 있다.
Arrays are not sets; searching for specific array elements can be a sign of database misdesign.
(배열은 집합이 아니다. 특정 원소를 검색하는 일이 잦다면 설계가 잘못됐다는 신호일 수 있다.)
즉 원소 검색이 잦으면 정규화(별도 행 테이블)를 권한다. 배열은 "한 행에 딸린 순서 있는 값 묶음"을 통째로 다룰 때 좋고, "이 원소를 가진 행 찾기"가 핵심 질의라면 관계형으로 펼치는 게 정석이다. 다만 실측해 보면 배열 + 인덱스로도 원소 검색이 충분히 빠른 구간이 있어서, 규칙을 맹신하기보다 판단 기준을 세우는 게 낫다. 같은 데이터를 두 방식으로 만들어 같은 질의를 돌려 비교해 본다.
배열 방식: intarray + GIN 으로 원소 검색
기본 int[]에도 @>(포함)·&&(겹침)이 있지만, intarray 확장을 얹으면 전용 연산자 query_int('1&(2|3)' 같은 불리언 태그식)와 전용 GIN op class(gin__int_ops)로 태그 검색이 더 강력해진다.
CREATE EXTENSION IF NOT EXISTS intarray;
CREATE TABLE article (id int PRIMARY KEY, tag_ids int[]);
-- 10000행 적재, id<=50 은 tag_ids = {1,2,3}
CREATE INDEX idx_article_tags ON article USING GIN (tag_ids gin__int_ops);
-- 포함 / 겹침 / 불리언 태그식
SELECT count(*) FROM article WHERE tag_ids @> ARRAY[1,2]; -- 1 AND 2
SELECT count(*) FROM article WHERE tag_ids && ARRAY[1,5]; -- 1 OR 5
SELECT count(*) FROM article WHERE tag_ids @@ '1&(2|3)'::query_int; -- 1 AND (2 OR 3)
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id FROM article WHERE tag_ids @@ '1&(2|3)'::query_int;
-- @> ARRAY[1,2]
count
-------
880
-- && ARRAY[1,5]
count
-------
3781
-- @@ '1&(2|3)'::query_int
count
-------
880
-- query_int 도 GIN 인덱스를 탄다
Bitmap Heap Scan on article (actual rows=880 loops=1)
Recheck Cond: (tag_ids @@ '1 & ( 2 | 3 )'::query_int)
Heap Blocks: exact=80
-> Bitmap Index Scan on idx_article_tags (actual rows=880 loops=1)
Index Cond: (tag_ids @@ '1 & ( 2 | 3 )'::query_int)'1&(2|3)'::query_int는 "태그 1을 갖고, 태그 2 또는 3도 가진" 행을 한 번에 표현한다. AND/OR가 섞인 태그 필터를 조인 여러 번으로 짜지 않고 인덱스 한 방으로 끝낸다.
정규화 방식: 조인 테이블에서 같은 질의
같은 데이터를 article_tag(article_id, tag_id)로 펼치고 동일한 "1 AND 2"를 교집합으로 물어본다.
CREATE TABLE article_tag (article_id int, tag_id int);
INSERT INTO article_tag SELECT id, unnest(tag_ids) FROM article;
CREATE INDEX idx_at ON article_tag(tag_id, article_id);
SELECT count(*) FROM (
SELECT article_id FROM article_tag WHERE tag_id=1
INTERSECT
SELECT article_id FROM article_tag WHERE tag_id=2
) t;
count
-------
880결과는 880으로 배열 방식과 정확히 일치한다. 어느 쪽이든 답은 같다. 갈리는 건 성능이 아니라 어떤 연산이 자연스러운가다.
결론: 이 기준으로 고른다
배열(+GIN)이 유리한 경우 — 원소 순서가 의미 있다(재생목록, 단계), 원소를 통째로 읽고 쓰는 게 대부분이다, 태그가 행에 강하게 종속돼 독립 수명이 없다, "이 조합을 가진 행 찾기"가 가끔이다. 조인 테이블이 유리한 경우 — 태그 자체에 속성(이름·색·설명)이 붙는다, 태그별 집계/랭킹이 잦다, 태그에 FK 무결성이 필요하다, 원소 검색이 핵심 워크로드다. 공식 경고의 요지가 바로 마지막 항목이다. 애매하면 정규화가 안전하고, 배열은 "이건 정말 값 묶음이다" 싶을 때 꺼낸다.
이렇게도 쓴다
기본 int[] 만으로도 포함/겹침은 된다(intarray 없이).
SELECT count(*) FROM article WHERE tag_ids @> ARRAY[1,2];
배열을 조인 테이블로 마이그레이션하는 펼치기. (조합: unnest)
INSERT INTO article_tag SELECT id, unnest(tag_ids) FROM article;
조인 테이블을 다시 배열로 되말기. (조합: array_agg)
SELECT article_id, array_agg(tag_id ORDER BY tag_id) FROM article_tag GROUP BY article_id;
태그별 인기 집계는 조인 테이블이 압도적으로 자연스럽다.
SELECT tag_id, count(*) FROM article_tag GROUP BY tag_id ORDER BY 2 DESC LIMIT 5;
intarray 는 NULL 원소를 허용하지 않는다 — 정수 태그 ID 전용임에 주의.
SELECT ARRAY[1,NULL,3]::int[] @@ '1'::query_int; -- intarray 연산은 NULL 있으면 에러'명령어 > DB' 카테고리의 다른 글
| [PostgreSQL] multirange — 불연속 기간을 한 값으로 다룬다 (0) | 2026.07.22 |
|---|---|
| [PostgreSQL] range 반열림 구간 [) — 인접 기간이 안 겹치는 이유 (0) | 2026.07.22 |
| [PostgreSQL] array_position 으로 상태값을 커스텀 순서로 정렬한다 (0) | 2026.07.22 |
| [PostgreSQL] 배열 검색 = ANY / && / @> 와 GIN 인덱스 (0) | 2026.07.22 |
| [PostgreSQL] LATERAL 조인 — 행마다 상관 서브쿼리를 조인처럼 펼친다 (0) | 2026.07.22 |