테이블 상속

테이블 상속 (Inheritance)

데이터 모델을 짜다 보면 "공통 컬럼을 가진 테이블들이 부모-자식 관계를 이루면 편하지 않을까?" 하는 순간이 와요. 예를 들어 '도시' 전체를 조회하면서, 그중 '주도(수도)'만 따로 뽑고 싶은 경우가 딱 그렇죠. PostgreSQL은 이런 상황을 돕는 테이블 상속(table inheritance) 을 제공해요. 다만 이 기능이 어디까지 되고, 어떤 한계가 있는지 정확히 알아둬야 실수하지 않아요.

출처: 공식문서

도시 예시로 이해하기

각 주(state)에는 많은 도시가 있지만 수도(capital)는 하나뿐인 상황을 생각해볼게요. 특정 주의 수도를 빠르게 찾고 싶고, 또 수도든 아니든 도시 데이터를 통합 조회하고 싶어요. 수도용 테이블과 비수도 도시용 테이블을 따로 만들면 되지만, '도시인지 여부와 무관하게 도시 데이터를 조회'하려면 상속이 해결해줘요. capitals 테이블이 cities를 상속하도록 정의해볼게요.

CREATE TABLE cities (
    name            text,
    population      float,
    elevation       int     -- in feet
);

CREATE TABLE capitals (
    state           char(2)
) INHERITS (cities);

이 경우 capitals 테이블은 부모 테이블 cities의 모든 컬럼을 상속받아요. 그리고 수도에는 자신이 속한 주를 나타내는 추가 컬럼 state가 더 있죠.

PostgreSQL에서 테이블은 0개 이상의 다른 테이블에서 상속받을 수 있어요. 조회는 테이블의 모든 행을 참조할 수도, 테이블의 모든 행 더하기 모든 자손 테이블의 행을 참조할 수도 있어요. 후자가 기본 동작이에요. 예를 들어 해발 500피트가 넘는 모든 도시(주도 포함)의 이름을 찾는 쿼리:

SELECT name, elevation
    FROM cities
    WHERE elevation > 500;

PostgreSQL 튜토리얼의 샘플 데이터를 쓰면 결과는:

   name    | elevation
-----------+-----------
 Las Vegas |      2174
 Mariposa  |      1953
 Madison   |       845

반면, 해발 500피트가 넘지만 주도가 아닌 도시만 찾으려면:

SELECT name, elevation
    FROM ONLY cities
    WHERE elevation > 500;

   name    | elevation
-----------+-----------
 Las Vegas |      2174
 Mariposa  |      1953

여기서 ONLY 키워드는 조회가 cities에만 적용되고 상속 계층에서 그 아래 테이블에는 적용되지 않게 해줘요. 앞서 다룬 많은 명령 — SELECT, UPDATE, DELETE — 이 ONLY 키워드를 지원해요.

테이블 이름 뒤에 *를 붙여서 자손 테이블 포함을 명시적으로 지정할 수도 있어요.

SELECT name, elevation
    FROM cities*
    WHERE elevation > 500;

*는 이 동작이 항상 기본값이므로 쓸 필요는 없어요. 다만 기본값을 바꿀 수 있던 이전 릴리스와의 호환성을 위해 이 문법은 여전히 지원돼요.

특정 행이 어느 테이블에서 왔는지 알고 싶을 때가 있어요. 각 테이블에는 tableoid라는 시스템 컬럼이 있어서 원래 테이블을 알려줘요.

SELECT c.tableoid, c.name, c.elevation
FROM cities c
WHERE c.elevation > 500;

결과:

 tableoid |   name    | elevation
----------+-----------+-----------
   139793 | Las Vegas |      2174
   139793 | Mariposa  |      1953
   139798 | Madison   |       845

(이 예시를 재현해보면 다른 숫자 OID를 보게 될 거예요.) pg_class와 조인하면 실제 테이블 이름을 볼 수 있어요.

SELECT p.relname, c.name, c.elevation
FROM cities c, pg_class p
WHERE c.elevation > 500 AND c.tableoid = p.oid;

결과:

 relname  |   name    | elevation
----------+-----------+-----------
 cities   | Las Vegas |      2174
 cities   | Mariposa  |      1953
 capitals | Madison   |       845

같은 효과를 얻는 또 다른 방법은 regclass 별칭 타입을 쓰는 거예요. 이 타입은 테이블 OID를 기호로 출력해줘요.

SELECT c.tableoid::regclass, c.name, c.elevation
FROM cities c
WHERE c.elevation > 500;

상속에서 기억할 동작들

상속은 INSERTCOPY 명령의 데이터를 상속 계층의 다른 테이블로 자동 전파하지 않아요. 예를 들어 다음 INSERT는 실패해요.

INSERT INTO cities (name, population, elevation, state)
VALUES ('Albany', NULL, NULL, 'NY');

데이터가 어떻게든 capitals 테이블로 라우팅되길 기대할 수도 있지만 그런 일은 없어요. INSERT는 항상 지정한 정확히 그 테이블에 삽입하거든요. 규칙(rule)을 사용하면 삽입을 리디렉션할 수 있는 경우도 있지만, 위 경우에는 도움이 되지 않아요. citiesstate 컬럼이 없어서 규칙이 적용되기 전에 명령이 거부되기 때문이죠.

부모 테이블의 모든 CHECK 제약과 NOT NULL 제약은, NO INHERIT 절로 명시적으로 다르게 지정하지 않으면 자식에게 자동 상속돼요. 다른 유형의 제약(unique, primary key, foreign key)은 상속되지 않아요.

테이블은 하나 이상의 부모에서 상속받을 수 있는데, 그러면 부모들이 정의한 컬럼의 합집합을 갖게 돼요. 자식 테이블 정의에서 선언한 컬럼도 그 위에 추가돼요. 같은 컬럼 이름이 여러 부모 테이블, 또는 부모와 자식 정의 양쪽에 나타나면 컬럼들이 '병합(merge)'되어 자식에 그 컬럼 하나만 존재하게 돼요. 병합되려면 컬럼들이 같은 데이터 타입이어야 하고, 아니면 오류가 나요. 상속 가능한 CHECK 제약과 NOT NULL 제약도 비슷하게 병합돼요. 예를 들어 병합된 컬럼은 그 컬럼을 만든 정의 중 어느 하나라도 NOT NULL로 표시돼 있으면 NOT NULL로 표시돼요. CHECK 제약은 이름이 같으면 병합되고, 조건이 다르면 병합이 실패해요.

테이블 상속은 보통 자식 테이블을 만들 때 CREATE TABLEINHERITS 절로 확립돼요. 또는 호환 가능하게 이미 정의된 테이블에 ALTER TABLEINHERIT 변형으로 새 부모 관계를 추가할 수도 있어요. 그러려면 새 자식 테이블이 부모의 컬럼과 같은 이름·타입의 컬럼을 이미 포함해야 해요. 부모와 같은 이름·같은 체크 표현식의 CHECK 제약도 포함해야 해요. 마찬가지로 ALTER TABLENO INHERIT 변형으로 자식의 상속 링크를 제거할 수 있어요. 이렇게 상속 링크를 동적으로 추가·제거하는 것은 상속 관계를 테이블 파티셔닝에 쓸 때 유용해요.

나중에 새 자식이 될 호환 테이블을 만드는 편리한 방법은 CREATE TABLELIKE 절을 쓰는 거예요. 이건 원본 테이블과 같은 컬럼을 가진 새 테이블을 만들어요. 원본 테이블에 CHECK 제약이 정의되어 있다면 LIKEINCLUDING CONSTRAINTS 옵션을 지정해야 해요. 새 자식은 호환으로 간주되려면 부모와 일치하는 제약이 있어야 하거든요.

부모 테이블은 자식이 남아 있는 동안에는 지울 수 없어요. 자식 테이블의 컬럼이나 CHECK 제약도 부모에게서 상속받은 것이라면 지우거나 바꿀 수 없어요. 테이블과 모든 자손을 제거하고 싶다면 CASCADE 옵션으로 부모 테이블을 지우는 게 쉬운 방법이에요.

ALTER TABLE은 컬럼 데이터 정의와 CHECK 제약의 변경을 상속 계층 아래로 전파해요. 다른 테이블이 의존하는 컬럼을 지우는 것은 CASCADE 옵션을 쓸 때만 가능해요. ALTER TABLECREATE TABLE에서 적용되는 중복 컬럼 병합·거부와 같은 규칙을 따라요.

접근 권한과 보안

상속된 조회는 부모 테이블에 대해서만 접근 권한 검사를 수행해요. 예를 들어 cities 테이블에 UPDATE 권한을 주면, cities를 통해 접근할 때 capitals의 행도 갱신할 수 있다는 뜻이에요. 이는 데이터가 (또한) 부모 테이블에 있다는 외관을 보존해줘요. 하지만 추가 권한 부여 없이는 capitals를 직접 갱신할 수 없어요. 비슷하게, 부모 테이블의 행 보안 정책은 상속 조회 중 자식 테이블에서 온 행에도 적용돼요. 자식 테이블의 정책(있으면)은 그 테이블이 조회에 명시적으로 이름이 쓰일 때만 적용되고, 그 경우 부모들의 정책은 무시돼요.

외부 테이블(foreign table)도 일반 테이블처럼 상속 계층의 부모나 자식이 될 수 있어요. 외부 테이블이 상속 계층에 속하면, 외부 테이블이 지원하지 않는 연산은 계층 전체에서도 지원되지 않아요.

상속의 한계 (Caveats)

모든 SQL 명령이 상속 계층에서 동작하는 건 아니에요. 데이터 조회·수정·스키마 수정에 쓰는 명령(예: SELECT, UPDATE, DELETE, 대부분의 ALTER TABLE 변형 — 단 INSERTALTER TABLE ... RENAME은 제외)은 보통 기본적으로 자식 테이블을 포함하고, ONLY 표기로 배제를 지원해요. 데이터베이스 유지·튜닝을 하는 명령(예: REINDEX)은 대부분 개별 물리 테이블에만 동작하고 상속 계층을 재귀하는 것을 지원하지 않아요. 다만 VACUUMANALYZE는 모두 기본적으로 자식 테이블을 포함하고, 배제를 위한 ONLY 표기를 지원해요. 각 명령의 동작은 해당 레퍼런스 문서에 나와 있어요.

상속 기능의 심각한 한계는, 인덱스(unique 제약 포함)와 외래 키 제약이 단일 테이블에만 적용되고 상속 자식에는 적용되지 않는다는 거예요. 이는 외래 키 제약의 참조하는 쪽과 참조되는 쪽 양쪽 모두에 해당돼요. 위 예시의 용어로 말하면:

  • cities.nameUNIQUEPRIMARY KEY로 선언해도 capitalscities의 행을 중복하는 이름을 가진 행이 생기는 걸 막지 못해요. 그리고 그 중복 행들은 기본적으로 cities 조회에 나타나요. 실제로 기본적으로 capitals에는 unique 제약이 전혀 없어서 같은 이름의 행이 여러 개 있을 수 있어요. capitals에 unique 제약을 추가하더라도 cities와의 중복은 막지 못해요.
  • cities.name이 다른 테이블을 REFERENCES 하도록 지정해도 그 제약이 capitals에 자동 전파되지 않아요. 이 경우 capitals에 같은 REFERENCES 제약을 수동으로 추가해 우회할 수 있어요.
  • 다른 테이블의 컬럼이 cities(name)REFERENCES 하도록 지정하면 그 테이블에 도시 이름은 담을 수 있지만 수도 이름은 담을 수 없어요. 이 경우는 좋은 우회 방법이 없어요.

상속 계층에는 구현되지 않은 일부 기능이 선언적 파티셔닝(declarative partitioning) 에는 구현되어 있어요. 레거시 상속으로 파티셔닝을 하는 게 애플리케이션에 유용한지 신중히 판단해야 해요.

더 알아보기 (Learn more)