명령어/DB

[PostgreSQL] "배열은 집합이 아니다" — 언제 배열, 언제 조인 테이블

jykim23 2026. 7. 22. 23:18
반응형

설치·접속: 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 있으면 에러
반응형