Quick Reference
-- 배열 컬럼 선언 및 삽입
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text,
tags text[],
scores integer[]
);
INSERT INTO articles (title, tags, scores)
VALUES ('PostgreSQL 배열', ARRAY['db', 'sql', 'postgres'], ARRAY[90, 85, 92]);
-- 포함 검색 + GIN 인덱스
CREATE INDEX idx_articles_tags ON articles USING GIN (tags);
SELECT id, title FROM articles
WHERE tags @> ARRAY['postgres']; -- 'postgres' 태그를 포함하는 행
-- 요소 접근 (1-based)
SELECT tags[1] FROM articles; -- 'db'배열은 함께 저장하고 읽는 작은 값 목록에 맞고, 요소가 독립적인 속성·제약·집계 대상이 되면 조인 테이블로 분리합니다. 포함·겹침 검색이 반복되면 기본 array GIN의 @>, <@, &&, = 연산자를 검토합니다. = ANY(array)는 읽기 쉽지만 이 GIN 경로의 대상이 아니므로, 필요한 연산자와 실제 plan을 함께 확인합니다.
문법
배열 타입은 정규화를 포기하는 대신 조회 단순성을 얻는 트레이드오프다
PostgreSQL 배열은 별도의 조인 테이블 없이 1:N 관계를 단일 컬럼에 저장한다. 태그, 권한 목록, 좌표 시퀀스처럼 요소 수가 적고 개별 요소를 독립적으로 조회·갱신할 필요가 없는 경우에 적합하다. 반대로 요소 자체에 속성이 생기거나, 요소를 기준으로 복잡한 집계가 필요하다면 정규화된 별도 테이블이 낫다.
-- 배열 선언 방법
CREATE TABLE products (
id bigserial PRIMARY KEY,
name text,
tags text[], -- 1차원 배열
matrix integer[][], -- 2차원 배열
dims float[3] -- 고정 길이 (선언만 제한, 실제 강제 안 됨)
);
-- 리터럴 문법
INSERT INTO products (name, tags)
VALUES
('Widget', '{red, blue, green}'), -- 중괄호 리터럴
('Gadget', ARRAY['small', 'portable']); -- ARRAY 생성자
-- 요소 접근: 1-based 인덱스
SELECT tags[1], tags[2] FROM products;
-- 슬라이싱 (시작:끝 포함)
SELECT tags[1:2] FROM products; -- {red,blue}
-- 배열 길이
SELECT array_length(tags, 1) FROM products; -- 1차원 길이
-- 배열 이어붙이기
UPDATE products SET tags = tags || ARRAY['sale'] WHERE id = 1;
-- 요소 제거 (array_remove)
UPDATE products SET tags = array_remove(tags, 'blue') WHERE id = 1;배열과 별도 조인 테이블은 해결하는 문제가 다르다
배열은 요소 수가 적고 한 컬럼 안에서 같이 다뤄도 될 때 편하고, 별도 조인 테이블은 요소를 독립적으로 조회·집계·제약 관리해야 할 때 더 적합합니다. "태그를 그냥 몇 개 들고 있으면 되는가"와 "태그 자체가 별도 엔터티인가"를 먼저 구분하면 선택이 쉬워집니다.
-- 배열: 간단한 태그 목록
tags text[]
-- 별도 테이블: 태그 자체를 독립적으로 관리
article_tags(article_id, tag_id)ANY / ALL / @> / && 연산자로 배열 조건 표현하기
배열에 특정 값이 포함되는지 검사하는 방법은 여러 가지이며, 의미와 GIN operator class 지원 범위가 다르다.
-- = ANY(array): 배열 안에 해당 값이 있는지 (GIN 인덱스 비사용)
SELECT * FROM products WHERE 'red' = ANY(tags);
-- @> (포함, contains): 왼쪽 배열이 오른쪽 배열의 모든 요소를 포함 (GIN 사용 가능)
SELECT * FROM products WHERE tags @> ARRAY['red', 'blue'];
-- <@ (피포함, contained by): 왼쪽 배열이 오른쪽 배열에 완전히 포함됨
SELECT * FROM products WHERE tags <@ ARRAY['red', 'blue', 'green'];
-- && (겹침, overlap): 공통 요소가 하나라도 있으면 true (GIN 사용 가능)
SELECT * FROM products WHERE tags && ARRAY['red', 'yellow'];
-- ALL: 배열의 모든 요소가 조건을 만족
SELECT * FROM products WHERE 5 > ALL(scores); -- 모든 점수가 5 미만
-- 배열 위치 검색
SELECT array_position(ARRAY['a','b','c'], 'b'); -- 2
-- 배열 포함 여부를 bool로 반환
SELECT array_position(tags, 'red') IS NOT NULL AS has_red
FROM products;기본 array GIN operator class는 @>, <@, &&, = 연산자의 index 후보가 됩니다. = ANY(array)는 같은 의미를 만들 수 있어도 이 GIN 연산자 경로가 아니므로, 포함 검색이 빈번하면 의도가 맞는 @> 또는 && 형태와 실행 계획을 확인합니다.
-- GIN 인덱스 생성
CREATE INDEX idx_products_tags ON products USING GIN (tags);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM products WHERE tags @> ARRAY['red'];
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM products WHERE 'red' = ANY(tags);unnest로 배열을 행으로 전개하면 집계·조인이 가능해진다
unnest 함수는 배열의 각 요소를 별도의 행으로 펼친다. 이를 통해 배열 요소를 기준으로 GROUP BY, COUNT, JOIN 등 집합 연산을 적용할 수 있다.
-- 기본 unnest
SELECT unnest(ARRAY['a', 'b', 'c']);
-- a
-- b
-- c
-- 배열 컬럼 전개: 태그별 게시글 수 집계
SELECT t.tag, count(*) AS article_count
FROM articles AS a
CROSS JOIN LATERAL unnest(a.tags) AS t(tag)
GROUP BY t.tag
ORDER BY article_count DESC, t.tag;
-- WITH ORDINALITY: 배열 인덱스(위치) 함께 반환
SELECT elem, idx
FROM unnest(ARRAY['x', 'y', 'z']) WITH ORDINALITY AS t(elem, idx);
-- x | 1
-- y | 2
-- z | 3
-- 여러 배열 동시 전개 (같은 위치 요소가 같은 행)
SELECT unnest(ARRAY[1,2,3]) AS id,
unnest(ARRAY['a','b','c']) AS label;
-- 1 | a
-- 2 | b
-- 3 | c
-- 배열 → 집계 역방향: array_agg로 다시 배열로 묶기
SELECT author_id, array_agg(tag ORDER BY tag) AS all_tags
FROM articles AS a
CROSS JOIN LATERAL unnest(a.tags) AS t(tag)
GROUP BY a.author_id;
-- 중복 제거 후 배열 재구성
SELECT array(
SELECT DISTINCT unnest(tags) FROM articles WHERE author_id = 1
ORDER BY 1
) AS unique_tags;배열과 조인 테이블은 읽기 단순성과 관계 표현력의 교환이다
배열은 한 행 안에 값을 같이 들고 다닐 수 있어 조회는 간단하지만, 개별 요소에 속성·제약·정렬 기준이 생기면 금방 한계가 드러납니다. 태그가 단순 문자열 목록이면 배열이 편하고, 태그 자체가 별도 엔터티라면 조인 테이블이 더 맞습니다.
-- 간단한 값 목록
tags text[]
-- 태그를 독립 엔터티로 관리
article_tags(article_id, tag_id)GIN 인덱스가 배열 포함 검색(@>, &&)을 빠르게 만드는 원리
GIN(Generalized Inverted Index)은 배열의 각 요소를 인덱스 키로 저장하고, 해당 요소를 포함하는 행의 집합을 값으로 관리합니다. @> 조건은 요소별 후보 행을 좁힐 수 있지만, planner는 선택도·통계·반환 행 수에 따라 다른 계획을 고를 수 있습니다. 성능은 고정 수치가 아니라 실제 EXPLAIN (ANALYZE, BUFFERS)로 검증합니다.
-- 먼저 현재 데이터의 계획을 확인한다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM articles WHERE tags @> ARRAY['postgres'];
-- GIN 인덱스 생성
CREATE INDEX idx_articles_tags ON articles USING GIN (tags);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM articles WHERE tags @> ARRAY['postgres'];
-- GiST 배열 연산자 지원은 built-in 기본값이 아니라
-- element type과 extension/operator class에 따라 달라진다.
-- 인덱스 크기 확인
SELECT pg_size_pretty(pg_relation_size('idx_articles_tags')) AS index_size;
-- 부분 인덱스: 특정 조건 행만 인덱싱 (크기 절감)
CREATE INDEX idx_active_tags ON articles USING GIN (tags)
WHERE status = 'published';선택 기준
| 상황 | 적합한 선택 |
|---|---|
| 배열에 특정 값이 포함되는지 검사한다 | @> (GIN 인덱스 활용 가능) |
| 두 배열이 겹치는지 검사한다 | && (GIN 인덱스 활용 가능) |
| 배열 안에 값이 있는지 간단히 검사한다 | = ANY(arr) (인덱스 미사용) |
| 배열 요소를 행으로 펼쳐 집계해야 한다 | unnest() |
| 요소 위치(인덱스)가 필요하다 | unnest() WITH ORDINALITY |
| 배열 요소를 다시 배열로 모아야 한다 | array_agg() |
| 요소 중 NULL을 찾아야 한다 | array_position(arr, NULL) |
| 대용량 배열 검색 성능이 필요하다 | GIN 인덱스 |
주의할 점
배열 내 NULL은 = ANY(arr)로 찾을 수 없다. NULL = NULL 비교 결과가 TRUE가 아니라 NULL이고, WHERE는 이 결과를 통과시키지 않기 때문이다. array_position이나 bool_or를 사용해야 한다.
-- ❌ NULL 포함 여부를 = ANY()로 검사 — WHERE에서는 행이 반환되지 않음
SELECT NULL = ANY(ARRAY[1, NULL, 3]); -- NULL (false로 평가)
SELECT * FROM products
WHERE NULL = ANY(tags); -- 비교 결과가 NULL이므로 WHERE에서 제외됨
-- ✅ array_position: NULL 위치 검색 가능
SELECT array_position(ARRAY[1, NULL, 3], NULL); -- 2
-- ✅ 배열에 NULL이 포함된 행 필터링
SELECT id FROM products
WHERE array_position(tags, NULL) IS NOT NULL;
-- ✅ bool_or + unnest로 NULL 존재 확인
SELECT id
FROM products
WHERE (
SELECT bool_or(elem IS NULL)
FROM unnest(tags) AS elem
);SELECT * FROM products WHERE 'red' = ANY(tags);이 문법은 읽기에는 간단하지만 GIN 인덱스를 활용하지 못합니다. 포함 검색이 자주 필요하면 tags @> ARRAY['red']처럼 인덱스를 타는 연산자를 우선 고려하는 편이 낫습니다.
UPDATE products
SET tags[2] = 'sale'
WHERE id = 1;배열 요소를 자리 기반으로 수정하는 패턴은 구조가 조금만 바뀌어도 의미가 쉽게 어긋납니다. 요소 순서가 고정된 데이터가 아니라면 "2번째 태그" 같은 규칙은 오래 버티기 어렵습니다.
참고 링크
2 sources