규칙 시스템

규칙 시스템 (Chapter 39. The Rule System)

PostgreSQL에는 다른 데이터베이스에서는 쉽게 볼 수 없는 **규칙 시스템(rule system)**이 있어요. 개념 자체는 단순하지만, 실제로 사용하다 보면 미묘한 지점이 많아요. 이 페이지에서는 규칙 시스템이 무엇이고, 뷰(view)와 머티리얼라이즈드 뷰, 그리고 트리거와는 어떻게 다른지 정리해 드릴게요.

출처: PostgreSQL 공식 문서 — rules

규칙 시스템이란?

이 장에서는 PostgreSQL의 규칙 시스템을 다뤄요. 규칙 시스템은 개념적으로 단순하지만, 실제로 쓰다 보면 참여하는 미묘한 지점이 많아요.

일부 다른 데이터베이스 시스템은 **활성 데이터베이스 규칙(active database rules)**을 정의하는데, 보통 저장 프로시저와 트리거로 구현돼요. PostgreSQL에서도 함수와 트리거로 그런 것들을 구현할 수 있어요.

하지만 PostgreSQL의 규칙 시스템(더 정확히 말하면 질의 재작성 규칙 시스템, query rewrite rule system)은 저장 프로시저나 트리거와 완전히 다릅니다. 규칙 시스템은 규칙을 고려하도록 질의를 수정한 다음, 수정된 질의를 질의 플래너에 넘겨 플래닝과 실행을 맡겨요. 매우 강력해서 질의 언어 프로시저, 뷰, 버전 관리 등 여러 가지에 쓸 수 있어요. 이 규칙 시스템의 이론적 기초와 힘은 [ston90b][ong90]에서도 논의돼요.

이 장의 전체 구성은 이래요.

  • 39.1. 쿼리 트리 (The Query Tree)
  • 39.2. 뷰와 규칙 시스템 (Views and the Rule System)
  • 39.3. 머티리얼라이즈드 뷰 (Materialized Views)
  • 39.4. INSERT, UPDATE, DELETE에 대한 규칙 (Rules on INSERT, UPDATE, and DELETE)
  • 39.5. 규칙과 권한 (Rules and Privileges)
  • 39.6. 규칙과 명령 상태 (Rules and Command Status)
  • 39.7. 규칙 대 트리거 (Rules Versus Triggers)

39.2. 뷰와 규칙 시스템 (Views and the Rule System)

PostgreSQL에서 뷰는 규칙 시스템으로 구현돼요. 뷰는 기본적으로 실제 저장 공간이 없는 빈 테이블에 ON SELECT DO INSTEAD 규칙을 붙인 거예요. 관례적으로 그 규칙의 이름은 _RETURN이에요. 그래서 아래와 같은 뷰는:

CREATE VIEW myview AS SELECT * FROM mytab;

거의 이렇게 쓴 것과 같아요.

CREATE TABLE myview (same column list as mytab);
CREATE RULE "_RETURN" AS ON SELECT TO myview DO INSTEAD
    SELECT * FROM mytab;

실제로는 이렇게 쓸 수 없어요. 테이블에는 ON SELECT 규칙을 달 수 없거든요.

뷰는 다른 종류의 DO INSTEAD 규칙도 가질 수 있어서, 실제 저장소가 없음에도 뷰에 INSERT, UPDATE, DELETE 명령을 수행할 수 있게 해요.

SELECT 규칙이 동작하는 방식

ON SELECT 규칙은 주어진 명령이 INSERT, UPDATE, DELETE라도 모든 질의에 마지막 단계로 적용돼요. 그리고 다른 명령 유형의 규칙과는 의미가 다른데, 새 query tree를 만드는 대신 기존 query tree를 그 자리에서 수정하죠.

현재 ON SELECT 규칙에는 단 하나의 액션만 있을 수 있고, 그것은 INSTEAD인 무조건부 SELECT 액션이어야 해요. 이 제한은 규칙을 일반 사용자에게 열어도 안전하게 만들기 위해 필요했고, ON SELECT 규칙이 뷰처럼 동작하도록 제약해요.

이 장의 예시는 계산을 수행하는 두 개의 조인 뷰와, 그것을 다시 사용하는 좀 더 많은 뷰들이에요. 이 장에서 쓰는 실제 테이블은 신발 가게 데이터를 나타내요.

CREATE TABLE shoe_data (
    shoename   text,          -- primary key
    sh_avail   integer,       -- available number of pairs
    slcolor    text,          -- preferred shoelace color
    slminlen   real,          -- minimum shoelace length
    slmaxlen   real,          -- maximum shoelace length
    slunit     text           -- length unit
);

CREATE TABLE shoelace_data (
    sl_name    text,          -- primary key
    sl_avail   integer,       -- available number of pairs
    sl_color   text,          -- shoelace color
    sl_len     real,          -- shoelace length
    sl_unit    text           -- length unit
);

CREATE TABLE unit (
    un_name    text,          -- primary key
    un_fact    real           -- factor to transform to cm
);

뷰는 이렇게 만들어져요.

CREATE VIEW shoe AS
    SELECT sh.shoename,
           sh.sh_avail,
           sh.slcolor,
           sh.slminlen,
           sh.slminlen * un.un_fact AS slminlen_cm,
           sh.slmaxlen,
           sh.slmaxlen * un.un_fact AS slmaxlen_cm,
           sh.slunit
      FROM shoe_data sh, unit un
     WHERE sh.slunit = un.un_name;

CREATE VIEW shoelace AS
    SELECT s.sl_name,
           s.sl_avail,
           s.sl_color,
           s.sl_len,
           s.sl_unit,
           s.sl_len * u.un_fact AS sl_len_cm
      FROM shoelace_data s, unit u
     WHERE s.sl_unit = u.un_name;

shoelace 뷰에 대한 CREATE VIEW 명령은 shoelace 릴레이션과, 질의의 range table에서 shoelace 릴레이션이 참조될 때마다 적용돼야 하는 재작성 규칙이 있음을 알려주는 pg_rewrite 항목을 만들어요.

뷰를 채우고 간단한 질의를 실행하면:

SELECT * FROM shoelace;

 sl_name   | sl_avail | sl_color | sl_len | sl_unit | sl_len_cm
-----------+----------+----------+--------+---------+-----------
 sl1       |        5 | black    |     80 | cm      |        80
 sl2       |        6 | black    |    100 | cm      |       100
 sl7       |        7 | brown    |     60 | cm      |        60
 sl3       |        0 | black    |     35 | inch    |      88.9
 sl4       |        8 | black    |     40 | inch    |     101.6
 sl8       |        1 | brown    |     40 | inch    |     101.6
 sl5       |        4 | brown    |      1 | m       |       100
 sl6       |        0 | brown    |    0.9 | m       |        90
(8 rows)

파서가 SELECT * FROM shoelace를 해석해 query tree를 만들고, 규칙 시스템은 range table을 돌며 어떤 릴레이션에 규칙이 있는지 확인해요. shoelace_RETURN 규칙을 찾으면, 뷰를 확장하려고 규칙의 액션 query tree를 담은 서브쿼리 range-table 항목을 만들어 원래 뷰를 참조하던 항목을 그걸로 치환해요. 결과로 재작성된 query tree는 거의 직접 타이핑한 것과 같아져요.

한 가지 차이는, 서브쿼리의 range table에 shoelace oldshoelace new라는 두 항목이 추가로 있다는 거예요. 이 항목들은 질의에 직접 참여하지 않아요. 규칙 시스템이 원래 뷰 참조에 있던 접근 권한 검사 정보를 저장하는 데 쓰죠. 그래서 재작성된 질의에 뷰가 직접 사용되지 않더라도 실행기가 사용자가 뷰에 접근할 적절한 권한을 가졌는지 여전히 검사해요.

규칙 시스템은 최상위 질의의 나머지 range-table 항목도 확인하고, 추가된 서브쿼리의 range-table 항목도 재귀적으로 확인해 뷰를 참조하는지 살펴봐요. (oldnew는 확장하지 않아요. 그랬다간 무한 재귀가 되거든요.) 뷰가 중첩된 예시에서는 shoeshoelace 규칙이 서브쿼리의 range table에 치환돼 3단계 최종 query tree를 만들어요.

이것이 비효율적으로 보일 수 있지만, 플래너가 서브쿼리를 "pull up"해서 단일 레벨 query tree로 접어 준 다음, 마치 손으로 직접 쓴 것처럼 조인을 계획해요. 즉 query tree를 접는 것은 재작성 시스템이 신경 쓸 필요 없는 최적화예요.

뷰의 힘 (The Power of Views)

위 예시는 규칙 시스템이 뷰 정의를 원래 query tree에 통합하는 방식을 보여줘요. 두 번째 예시에서 한 뷰에 대한 단순한 SELECT가 4개 테이블의 조인(다른 이름으로 두 번 사용된 unit 포함)인 최종 query tree를 만들었죠.

규칙 시스템으로 뷰를 구현하는 이점은, 플래너가 어떤 테이블을 스캔해야 하는지, 테이블 간 관계, 뷰의 제한 조건, 원래 질의의 조건을 하나의 query tree에서 모두 갖게 된다는 거예요. 원래 질의가 이미 뷰에 대한 조인일 때도 마찬가지예요. 플래너는 질의를 실행할 최상의 경로를 결정해야 하는데, 정보가 많을수록 그 결정이 더 좋아져요. PostgreSQL에서 구현된 규칙 시스템은 그 시점까지 질의에 대해 알 수 있는 모든 정보가 플래너에게 전달되도록 보장해요.

뷰 갱신하기 (Updating a View)

뷰가 INSERT, UPDATE, DELETE, 또는 MERGE의 대상 릴레이션으로 지정되면 어떻게 될까요? 위에서 한 치환을 하면 결과 릴레이션이 서브쿼리 range-table 항목을 가리키는 query tree가 되는데, 그건 동작하지 않아요. PostgreSQL이 뷰의 갱신을 지원하는 방법은 여러 가지가 있어요. 사용자 경험상 복잡도 순으로: 뷰 아래의 기본 테이블을 자동으로 치환하거나, 사용자 정의 트리거를 실행하거나, 사용자 정의 규칙에 따라 질의를 재작성하는 방식이에요.

서브쿼리가 단일 base 릴레이션에서 선택하고 충분히 단순하다면, 재작성기가 서브쿼리를 자동으로 기본 테이블로 치환해서 INSERT, UPDATE, DELETE, MERGE를 기본 테이블에 적절히 적용할 수 있어요. 이렇게 "충분히 단순한" 뷰를 **자동으로 갱신 가능한 뷰(automatically updatable view)**라고 불러요. 어떤 종류의 뷰가 자동으로 갱신될 수 있는지에 대한 자세한 정보는 CREATE VIEW를 참고하세요.

또는 그 연산을 뷰에 대한 사용자 제공 INSTEAD OF 트리거가 처리할 수 있어요. 이 경우 재작성은 조금 다르게 동작해요. INSERT에 대해서 재작성기는 뷰에 대해 아무것도 하지 않고 그대로 결과 릴레이션으로 남겨둬요. UPDATE, DELETE, MERGE에 대해서는 명령이 갱신·삭제·병합하려는 "old" 행을 만들어내기 위해 뷰 질의를 확장해야 해요. 그래서 뷰는 정상적으로 확장되되, 결과 릴레이션 역할을 하는 확장되지 않은 추가 range-table 항목이 질의에 더해져요.

여기서 생기는 문제는 뷰에서 갱신할 행을 어떻게 식별하느냐예요. 결과 릴레이션이 테이블일 때는 갱신할 행의 물리적 위치를 식별하기 위해 특별한 CTID 항목이 target list에 추가돼요. 하지만 결과 릴레이션이 뷰면 CTID가 없어서 그 방법이 안 돼요(뷰의 행은 실제 물리적 위치가 없으니까요). 대신 UPDATE, DELETE, MERGE 연산에서는 뷰의 모든 컬럼을 포함하도록 확장되는 특별한 wholerow 항목이 target list에 추가돼요. 실행기는 이 값을 사용해 INSTEAD OF 트리거에 "old" 행을 공급해요. 무엇을 갱신할지는 old와 new 행 값을 바탕으로 트리거가 알아서 결정해요.

또 다른 가능성은 사용자가 뷰의 INSERT, UPDATE, DELETE 명령에 대한 대체 액션을 지정하는 INSTEAD 규칙을 정의하는 거예요. 이런 규칙은 명령을, 보통 뷰가 아니라 하나 이상의 테이블을 갱신하는 명령으로 재작성해요. 그게 39.4절의 주제예요. 참고로 이것은 MERGE에서는 동작하지 않아요. MERGE는 현재 대상 릴레이션에 SELECT 규칙 외의 규칙을 지원하지 않거든요.

규칙은 먼저 평가되어 원래 질의를 플래닝·실행 전에 재작성한다는 점을 기억하세요. 그래서 뷰에 INSTEAD OF 트리거와 INSERT/UPDATE/DELETE 규칙이 모두 있어도, 규칙이 먼저 평가되고 그 결과에 따라 트리거가 전혀 사용되지 않을 수도 있어요. 자동 재작성은 항상 마지막에 시도되므로, 뷰에 규칙이나 트리거가 있으면 자동으로 갱신 가능한 뷰의 기본 동작을 덮어써요. 뷰에 INSTEAD 규칙도 INSTEAD OF 트리거도 없고 재작성기가 기본 릴레이션에 대한 갱신으로 자동 재작성할 수 없다면, 실행기는 뷰를 그 자체로 갱신할 수 없으므로 오류가 발생해요.

39.3. 머티리얼라이즈드 뷰 (Materialized Views)

PostgreSQL의 머티리얼라이즈드 뷰는 뷰처럼 규칙 시스템을 사용하되, 결과를 테이블 형태로 영속시켜요. 이 둘의 주요 차이는:

CREATE MATERIALIZED VIEW mymatview AS SELECT * FROM mytab;

와:

CREATE TABLE mymatview AS SELECT * FROM mytab;

의 차이에서 드러나요. 머티리얼라이즈드 뷰는 이후에 직접 갱신할 수 없고, 머티리얼라이즈드 뷰를 만드는 데 쓴 질의가 뷰의 질의가 저장되는 것과 똑같은 방식으로 저장되어서, 다음 명령으로 새 데이터를 생성할 수 있어요.

REFRESH MATERIALIZED VIEW mymatview;

시스템 카탈로그에서 머티리얼라이즈드 뷰에 대한 정보는 테이블이나 뷰와 완전히 같아요. 그래서 파서 입장에서 머티리얼라이즈드 뷰는 테이블이나 뷰처럼 그저 릴레이션이에요. 질의에서 머티리얼라이즈드 뷰가 참조되면 데이터는 테이블처럼 뷰에서 직접 반환되고, 규칙은 머티리얼라이즈드 뷰를 채우는 데만 사용돼요.

머티리얼라이즈드 뷰에 저장된 데이터에 접근하는 것은 기본 테이블에 직접 접근하거나 뷰를 통하는 것보다 훨씬 빠른 경우가 많지만, 데이터가 항상 최신은 아니에요. 그래도 때로는 최신 데이터가 필요 없을 때가 있죠. 판매를 기록하는 테이블을 생각해 볼게요.

CREATE TABLE invoice (
    invoice_no    integer        PRIMARY KEY,
    seller_no     integer,       -- ID of salesperson
    invoice_date  date,          -- date of sale
    invoice_amt   numeric(13,2)  -- amount of sale
);

사람들이 과거 판매 데이터를 빠르게 그래프로 그리길 원한다면, 요약해서 보고 현재 날짜의 불완전한 데이터는 신경 쓰지 않을 수 있어요.

CREATE MATERIALIZED VIEW sales_summary AS
  SELECT
      seller_no,
      invoice_date,
      sum(invoice_amt)::numeric(13,2) as sales_amt
    FROM invoice
    WHERE invoice_date < CURRENT_DATE
    GROUP BY
      seller_no,
      invoice_date;

CREATE UNIQUE INDEX sales_summary_seller
  ON sales_summary (seller_no, invoice_date);

이 머티리얼라이즈드 뷰는 영업사원용 대시보드의 그래프를 표시하는 데 유용할 수 있어요. 매일 밤 통계를 갱신하는 작업을 이 SQL 문장으로 예약할 수 있죠.

REFRESH MATERIALIZED VIEW sales_summary;

머티리얼라이즈드 뷰의 또 다른 용도는 **외부 데이터 래퍼(foreign data wrapper)**로 원격 시스템에서 가져온 데이터에 더 빠르게 접근하는 거예요. file_fdw를 쓰는 간단한 예시가 문서에 타이밍과 함께 있는데, 로컬 시스템의 캐시를 쓰기 때문에 실제 원격 접근보다는 성능 차이가 작게 보일 수 있어요. 덤으로 머티리얼라이즈드 뷰에는 인덱스를 붙일 수 있다는 점도 활용하고 있어요. file_fdw는 인덱스를 지원하지 않거든요.

CREATE EXTENSION file_fdw;
CREATE SERVER local_file FOREIGN DATA WRAPPER file_fdw;
CREATE FOREIGN TABLE words (word text NOT NULL)
  SERVER local_file
  OPTIONS (filename '/usr/share/dict/words');
CREATE MATERIALIZED VIEW wrd AS SELECT * FROM words;
CREATE UNIQUE INDEX wrd_word ON wrd (word);
CREATE EXTENSION pg_trgm;
CREATE INDEX wrd_trgm ON wrd USING gist (word gist_trgm_ops);
VACUUM ANALYZE wrd;

file_fdw로 직접 철자 검사를 하면 EXPLAIN ANALYZE 기준으로 실행 시간이 188ms 정도 걸리지만, 머티리얼라이즈드 뷰를 쓰면 같은 검색이 약 0.1ms 수준으로 훨씬 빨라져요. 또 fuzzy 검색(word <-> 'caterpiler')에서도 머티리얼라이즈드 뷰와 pg_trgm 인덱스를 쓰면 현저히 빨라요. 원격 데이터를 주기적으로 로컬로 갱신하는 것을 감수할 수 있다면 성능 이점이 상당해요.

문서의 예시 실행 시간 (확인 필요 — 실제 환경에 따라 크게 다를 수 있어요):

방식 첫 검색 (실행 시간) fuzzy 검색 (실행 시간)
file_fdw 직접 약 188 ms 약 1431 ms
머티리얼라이즈드 뷰 + 인덱스 약 0.1 ms 약 198 ms

39.4. INSERT, UPDATE, DELETE에 대한 규칙

뷰의 INSERT, UPDATE, DELETE 명령에 대한 대체 액션을 지정하는 INSTEAD 규칙에 대해 다뤄요. 이런 규칙은 명령을, 보통 뷰가 아니라 하나 이상의 테이블을 갱신하는 명령으로 재작성해요. 좀 더 복잡한 카탈로그(예: computersoftware 테이블)를 이용해 규칙이 정의·적용되는 과정을 단계별로 보여주는 내용이에요 (확인 필요 — 자세한 예시는 rules-update 참고).

INSERT에 대한 규칙은 DO INSTEAD가 아닌 DO ALSO/조건부일 때 추가 명령을 실행할 수 있고, 카탈로그에 따라 실제 동작이 달라져요. 구체적인 시나리오별 동작은 원문의 해당 절을 참고하는 걸 권장해요.

39.5. 규칙과 권한 (Rules and Privileges)

규칙 시스템은 권한(privilege)과 관련해 흥미로운 점이 있어요. 규칙은 질의를 재작성해서 원래 사용자가 직접 수행하지 못할 수 있는 연산을 수행하게 만들 수 있기 때문이에요. 권한 검사는 규칙 재작성 전에 수행되는지, 후에 수행되는지에 따라 보안에 미치는 영향이 달라져요.구체적인 권한 처리 규칙은 원문의 rules-privileges 절을 참고하는 게 정확해요 (확인 필요).

39.6. 규칙과 명령 상태 (Rules and Command Status)

규칙이 명령을 재작성하면, 호출자에게 보고되는 **명령 상태(command status)**가 달라질 수 있어요. 예를 들어 규칙이 실제로 다른 명령을 실행하면 반환되는 명령 태그가 달라질 수 있죠. 자세한 내용은 rules-status를 참고하세요 (확인 필요).

39.7. 규칙 대 트리거 (Rules Versus Triggers)

트리거로 할 수 있는 많은 일은 PostgreSQL 규칙 시스템으로도 구현할 수 있어요. 규칙으로는 구현할 수 없는 것도 있는데, 바로 일부 종류의 제약(constraint), 특히 외래 키예요. 어떤 컬럼의 값이 다른 테이블에 나타나지 않으면 명령을 NOTHING으로 재작성하는 조건부 규칙을 둘 수는 있어요. 하지만 그러면 데이터가 조용히 버려지는데 그건 좋은 생각이 아니에요. 유효한 값 검사가 필요하고, 유효하지 않은 값이면 오류 메시지를 생성해야 한다면 트리거로 해야 해요.

이 장에서는 뷰를 갱신하는 데 규칙을 쓰는 데 집중했어요. 이 장의 모든 갱신 규칙 예시는 뷰에 대한 INSTEAD OF 트리거로도 구현할 수 있어요. 그런 트리거를 쓰는 게 규칙을 쓰는 것보다 종종 더 쉬워요. 특히 갱신을 수행하는 데 복잡한 로직이 필요할 때 그렇죠.

둘 다로 구현할 수 있는 것들에서 뭐가 더 나은지는 데이터베이스 사용 방식에 달려 있어요. 트리거는 영향받는 각 행마다 한 번씩 발화돼요. 규칙은 질의를 수정하거나 추가 질의를 생성해요. 그래서 한 문장이 많은 행에 영향을 미친다면, 규칙이 하나의 추가 명령만 발행하는 것이 행마다 호출돼 뭘 해야 할지 다시 판단해야 하는 트리거보다 빠를 가능성이 높아요. 다만 트리거 방식은 개념적으로 훨씬 단순하고 초보자가 제대로 구현하기 더 쉬워요.

문서의 예시는 computersoftware라는 두 테이블을 다뤄요. 두 테이블 모두 수천 개의 행이 있고 hostname에 고유 인덱스가 있어요. 규칙이나 트리거는 삭제된 컴퓨터를 참조하는 software 행을 삭제하는 제약을 구현해야 해요. 트리거는 이 명령을 쓰겠죠:

DELETE FROM software WHERE hostname = $1;

트리거는 computer에서 삭제되는 각 행마다 호출되므로 이 명령의 계획을 준비·저장하고 hostname 값을 파라미터로 넘길 수 있어요. 규칙은 이렇게 쓰여요.

CREATE RULE computer_del AS ON DELETE TO computer
    DO DELETE FROM software WHERE hostname = OLD.hostname;

단일 행 삭제(WHERE hostname = 'mypc.local.net')에서는 트리거와 규칙 사이 속도 차이가 크지 않아요. 둘 다 인덱스 스캔을 쓰니까요.

하지만 hostnameold로 시작하는 2000대의 컴퓨터를 모두 삭제하는 경우를 보면 다르죠. 트리거는 삭제해야 할 2000대의 old 컴퓨터마다 한 번씩 호출되어, computer에 대한 인덱스 스캔 1회와 software에 대한 인덱스 스캔 2000회가 발생해요. 규칙 구현은 인덱스를 쓰는 명령 두 개로 처리해요. SPI 매니저를 통한 트리거의 2000번 명령 실행은, 인덱스 블록이 곧 캐시에 들어온다 해도 시간이 걸려요.

결론적으로, 규칙의 액션이 크고 잘못 한정된 조인, 즉 플래너가 실패하는 상황을 만들어내지 않는 한 규칙이 트리거보다 크게 느릴 일은 없어요.

더 알아보기 (Learn more)