{#indexes-and-constraints-indexes-and-constraints} # 인덱스와 제약 속성 인덱스는 레이블의 `jsonb` 컬럼인 `properties`에서 키 하나를 읽는 표현식에 걸린 PostgreSQL btree입니다. 이 한 문장이 언제 인덱스가 도움이 되는지에 대해 거의 모든 것을 설명합니다. 플래너가 여러분의 조건을 그 표현식과 맞출 수 있을 때 정확히 도움이 됩니다. 맞출 수 없으면 전혀 도움이 되지 않습니다. 이 페이지는 어떤 조건이 그 자격을 갖추는지 실험으로 확인합니다. 답이 대칭적이지 않고 몇 가지는 뜻밖이므로, 아래의 표들이 이 페이지의 요점입니다. 인덱스가 디스크에서 어떻게 생겼는지는 [그래프가 어떻게 저장되는가](#graph-on-postgresql-what-a-property-index-and-a-constraint-become)가 보여 줍니다. 전체 구문은 [`CREATE PROPERTY INDEX`](../reference/ddl/create-property-index.md)와 [`CREATE CONSTRAINT`](../reference/ddl/create-constraint.md)에 있습니다. {#indexes-and-constraints-a-graph-to-measure-on} ## 측정할 그래프 [예제 데이터셋](../get_started/example-dataset.md)에는 영화가 18편뿐이어서, 플래너가 테이블을 그냥 읽는 것보다 인덱스를 선호하기에는 너무 적습니다. 작업할 더 큰 레이블을 만들고, 페이지 끝에서 지웁니다: ```sql CREATE TABLE seed_film AS SELECT 'Film ' || lpad(g::text, 6, '0') AS title, 1900 + (g % 130) AS year, (ARRAY['Action','Sci-Fi','Drama','Comedy'])[1 + (g % 4)] AS genre FROM generate_series(1, 50000) g; ``` ```cypher CREATE GRAPH idxlab; SET graph_path = idxlab; CREATE VLABEL film; LOAD FROM seed_film AS r CREATE (:film {title: r.title, year: r.year, genre: r.genre}); CREATE PROPERTY INDEX ON film (title); CREATE PROPERTY INDEX ON film (year); ``` ```sql ANALYZE idxlab.film; ``` `ANALYZE`는 인덱스만큼 중요합니다. 플래너가 통계를 갖고 있지 않은 인덱스는 플래너가 쓰기를 거절하는 인덱스입니다. 아래의 모든 실행 계획은 50,000행에서 플래너가 강요 없이 스스로 고른 것입니다. 논지를 만들려고 무언가를 끈 것은 없습니다. {#indexes-and-constraints-what-an-index-can-serve} ## 인덱스가 처리할 수 있는 조건 같음 비교가 다른 모든 것을 견주는 기준입니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title = 'Film 000042' RETURN f.year; ``` ``` QUERY PLAN ------------------------------------------------------------- Index Scan using film_title_idx on film f Index Cond: (properties.'title' = '"Film 000042"'::jsonb) ``` 찾아봐야 할 단어는 `Index Cond`입니다. 그 조건이 인덱스 탐색이 됐다는 뜻입니다. 대신 `Filter`로 나타난 조건은 스캔이 이미 가져온 행에 대해 평가된 것입니다. 그것이 바로 인덱스가 피하려던 일입니다. 패턴 안의 속성 맵은 같은 같음 비교로 컴파일되고 같은 실행 계획을 받습니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film {title: 'Film 000042'}) RETURN f.year; ``` ``` QUERY PLAN ------------------------------------------------------------- Index Scan using film_title_idx on film f Index Cond: (properties.'title' = '"Film 000042"'::jsonb) ``` 범위 조건은 됩니다. btree가 순서를 갖기 때문입니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.year >= 2025 AND f.year < 2030 RETURN f.title; ``` ``` QUERY PLAN ---------------------------------------------------------------------------------------------------- Bitmap Heap Scan on film f Recheck Cond: ((properties.'year' >= '2025'::jsonb) AND (properties.'year' < '2030'::jsonb)) -> Bitmap Index Scan on film_year_idx Index Cond: ((properties.'year' >= '2025'::jsonb) AND (properties.'year' < '2030'::jsonb)) ``` 비교는 jsonb 자체의 순서로 이루어지고, 그 순서는 숫자를 수치로 정렬하므로 `>= 2025`는 뜻하는 대로 동작합니다. 그러나 jsonb는 값보다 *타입*을 먼저 정렬합니다. 문자열 `"2025"`를 담은 속성은 두 숫자 사이에 있지 않습니다. 리터럴로 된 `IN` 목록은 배열 탐색이 됩니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title IN ['Film 000042', 'Film 000043'] RETURN f.year; ``` ``` QUERY PLAN --------------------------------------------------------------------------------------------- Index Scan using film_title_idx on film f Index Cond: (properties.'title' = ANY ('{"\"Film 000042\"","\"Film 000043\""}'::jsonb[])) ``` {#indexes-and-constraints-what-an-index-cannot-serve} ## 인덱스가 처리할 수 없는 조건 `<>`는 인덱스를 쓸 수 없습니다. "이 값 하나가 아닌 것"이 테이블의 대부분이라는 평범한 이유 때문입니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.year <> 2020 RETURN f.title; ``` ``` QUERY PLAN ------------------------------------------------ Seq Scan on film f Filter: (properties.'year' <> '2020'::jsonb) ``` 사람들이 걸리는 것은 문자열 조건입니다. `STARTS WITH`는 접두 범위여야 할 것처럼 보이기 때문입니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title STARTS WITH 'Film 00004' RETURN f.year; ``` ``` QUERY PLAN ------------------------------------------------------------------------- Seq Scan on film f Filter: string_starts_with(properties.'title', '"Film 00004"'::jsonb) ``` 그렇지 않습니다. `STARTS WITH`는 함수 호출인 `string_starts_with`로 컴파일되고, `properties.'title'`에 걸린 btree에는 그 함수에 대한 연산자 클래스 항목이 없습니다. 그래서 인덱스 경로가 거부된 것이 아니라 아예 없습니다. `enable_seqscan`을 꺼도 인덱스 경로가 생기지 않습니다. 계획은 순차 스캔으로 남고 `Disabled: true`로 표시됩니다. `CONTAINS`, `ENDS WITH`, `=~`도 똑같이 동작하며, 각각 자기 함수로 컴파일됩니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title CONTAINS '00042' RETURN f.year; ``` ``` QUERY PLAN ----------------------------------------------------------------- Seq Scan on film f Filter: string_contains(properties.'title', '"00042"'::jsonb) ``` ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title =~ '^Film 00004' RETURN f.year; ``` ``` QUERY PLAN -------------------------------------------------------------------- Seq Scan on film f Filter: string_regex(properties.'title', '"^Film 00004"'::jsonb) ``` `IS NULL`과 `IS NOT NULL`도 승격되지 않은 속성에서는 스캔으로 되돌아갑니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.director IS NULL RETURN f.title; ``` ``` QUERY PLAN ------------------------------------------- Seq Scan on film f Filter: (properties.'director' IS NULL) ``` 그 이유는 알아 둘 만합니다. 승격 뒤에 `IS NULL`이 *다르게* 동작하는 까닭도 함께 알려 주기 때문입니다. `properties`에 `director` 키가 아예 없는 행에서는 `properties.'director'`가 null입니다. 그 키에 걸린 표현식 인덱스는 그런 행마다 null을 저장하므로, 인덱스를 거의 끝까지 읽어야 합니다. 승격된 컬럼에서는 같은 검사가 실제 컬럼에 대한 실제 `IS NULL`이고, btree가 그것에 곧바로 답합니다. {#indexes-and-constraints-the-table} ### 표 `title`에 `film_title_idx`, `year`에 `film_year_idx`를 걸고 `ANALYZE`를 돌린 상태에서 50,000행으로 측정했습니다: | 조건 | 인덱스 사용 | | --- | --- | | `f.title = 'x'` | 예 — `Index Cond` | | 패턴 안의 `(f:film {title: 'x'})` | 예 — `Index Cond` | | `f.year >= 2025 AND f.year < 2030` | 예 — `Bitmap Index Scan` | | `f.title IN ['x', 'y']` | 예 — `= ANY`가 붙은 `Index Cond` | | `f.year <> 2020` | 아니요 — `Seq Scan` | | `f.title STARTS WITH 'x'` | 아니요 — `string_starts_with` 필터 | | `f.title CONTAINS 'x'` | 아니요 — `string_contains` 필터 | | `f.title ENDS WITH 'x'` | 아니요 — `string_ends_with` 필터 | | `f.title =~ '^x'` | 아니요 — `string_regex` 필터 | | `f.director IS NULL` | 아니요 — `Seq Scan` | | `f.title IS NOT NULL` | 아니요 — `Seq Scan` | | `ORDER BY f.title LIMIT n` | 예 — `Index Scan`, 정렬 없음 | Cypher 조건에서 문자열 조건을 구해 주는 인덱스 종류는 없습니다. 그것들은 함수 호출이고, 어떤 연산자 클래스도 그것을 맡지 않습니다. 접두 검색이나 부분 문자열 검색이 필요하다면 [전문 검색](../guides/search/full-text.md)이 그 도구입니다. SQL에서 그 컬럼에 닿는 방법도 있습니다. [아래](#indexes-and-constraints-what-promotion-changes)를 참고하십시오. {#indexes-and-constraints-ordering} ## 정렬 인덱스는 같은 속성에 대한 `ORDER BY`를 처리해 줍니다. 그래서 "속성 기준 상위 N개" 쿼리에는 정렬이 전혀 필요하지 않습니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) RETURN f.title AS t ORDER BY t LIMIT 5; ``` ``` QUERY PLAN ------------------------------------------------- Limit -> Index Scan using film_title_idx on film f ``` 이것이 되는 이유는 정렬 키가 jsonb로 그려진 모습이 아니라 그 값 자체로 정렬되기 때문입니다. 인덱스가 담고 있지 않은 표현식으로 정렬하면 여전히 정렬 작업이 일어납니다. {#indexes-and-constraints-composite-and-partial-indexes} ## 복합 인덱스와 부분 인덱스 속성 인덱스는 여러 키를 받으며, 가장 왼쪽 접두 규칙은 PostgreSQL의 것과 같습니다: ```cypher CREATE PROPERTY INDEX ON film (genre, year); ``` ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.genre = 'Comedy' AND f.year >= 2025 RETURN f.title; ``` ``` QUERY PLAN --------------------------------------------------------------------------------------------------------- Bitmap Heap Scan on film f Recheck Cond: ((properties.'genre' = '"Comedy"'::jsonb) AND (properties.'year' >= '2025'::jsonb)) -> Bitmap Index Scan on film_genre_year_idx Index Cond: ((properties.'genre' = '"Comedy"'::jsonb) AND (properties.'year' >= '2025'::jsonb)) ``` `year`만으로 하는 쿼리는 이것을 쓸 수 없고 `year`에 걸린 단일 키 인덱스로 돌아갑니다. 그러므로 SQL에서 하듯 같음 비교를 앞에 두는 순서로 키를 배치하십시오. 인덱스에 붙인 `WHERE` 절이 그것을 부분 인덱스로 만듭니다. 큰 레이블의 작고 자주 쓰이는 일부에 인덱스를 거는 방법이 이것입니다: ```cypher CREATE PROPERTY INDEX film_comedy_year_idx ON film (year) WHERE genre = 'Comedy'; ``` ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.genre = 'Comedy' AND f.year >= 2025 RETURN f.title; ``` ``` QUERY PLAN ----------------------------------------------------------------------------------------------------- Bitmap Heap Scan on film f Recheck Cond: ((properties.'year' >= '2025'::jsonb) AND (properties.'genre' = '"Comedy"'::jsonb)) -> Bitmap Index Scan on film_comedy_year_idx Index Cond: (properties.'year' >= '2025'::jsonb) ``` `genre` 검사가 인덱스 조건에서 빠졌습니다. 이 인덱스의 모든 항목이 이미 코미디이기 때문이며, 그 검사는 재확인에만 남습니다. 이 인덱스는 `year`에 걸린 전체 인덱스의 4분의 1 크기입니다. 524,288 바이트 대 2,056,192 바이트입니다. 플래너는 다른 두 대안보다 이것을 골랐습니다. `CREATE PROPERTY INDEX`는 `USING btree | hash | gin | brin | gist | spgist`, `CONCURRENTLY`, `IF NOT EXISTS`, `INCLUDE`, 그리고 테이블스페이스도 받습니다. 모두 PostgreSQL에서와 같은 뜻입니다. ```{note} `CREATE PROPERTY INDEX (...)` 안의 모든 맨 이름은 *속성 키*이며, `properties`라는 단어도 그렇습니다. `CREATE PROPERTY INDEX ON film USING gin (properties)`는 `properties`라는 이름의 속성에 인덱스를 거는 것이고, 그런 속성을 가진 영화는 없습니다. `properties` 자체에 인덱스를 걸려면 평범한 SQL을 쓰십시오 — `CREATE INDEX film_bag_idx ON idxlab.film USING gin (properties)` — 그리고 그 인덱스는 `properties @> '{"genre": "Comedy"}'` 같은 SQL 조건만 쓸 수 있다는 점에 유의하십시오. Cypher의 속성 읽기는 `properties.'genre'`로 컴파일되고, `properties`에 걸린 GIN 인덱스는 그것과 맞지 않습니다. ``` {#indexes-and-constraints-what-promotion-changes} ## 승격이 바꾸는 것 속성을 승격하면 실제 타입이 있는 컬럼이 생기고, 그 뒤로는 모든 조건이 `properties`가 아니라 그 컬럼을 대상으로 컴파일됩니다. 왜 그리고 어떻게 하는지는 [속성을 컬럼으로 승격하기](typed-columns.md)가 다룹니다. 여기서 중요한 것은 그것이 위 표의 답을 양방향으로 바꾼다는 점입니다. ```cypher ALTER VLABEL film ADD COLUMN year int GENERATED; ALTER VLABEL film ADD COLUMN title text GENERATED; ``` 이미 갖고 있는 인덱스에 승격이 무슨 일을 하는지부터 봅니다. `properties.'year'`에 걸린 인덱스는 어떤 것과도 맞지 않게 됩니다. 더 이상 어떤 쿼리도 그 표현식으로 컴파일되지 않기 때문입니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.year >= 2025 AND f.year < 2030 RETURN f.title; ``` ``` QUERY PLAN ---------------------------------------------- Seq Scan on film f Filter: ((year >= 2025) AND (year < 2030)) ``` 인덱스는 여전히 거기 있고, 쓰기마다 여전히 유지되며, 이제 아무것도 그것을 쓰지 않습니다. 그러므로 승격은 항상 두 단계입니다. 승격하고, 그다음 컬럼에 인덱스를 다시 만듭니다. 속성 인덱스로 다시 만드는 것은 맨 키에 대해서는 잘 됩니다. 그것이 흔한 경우이고 아래의 경우입니다. 그 키에 대한 *표현식*은 다른 형태가 필요합니다. `CREATE PROPERTY INDEX`는 맨 키는 승격된 컬럼으로 컴파일하지만, 그 키에 대한 표현식은 jsonb `properties`로 컴파일하는데 그것은 쿼리가 읽는 대상이 아닙니다. 그런 인덱스는 컬럼에 대한 SQL로 쓰십시오. `\dGi`가 그것도 여전히 나열해 줍니다: ```sql CREATE INDEX film_lower_idx ON idxlab.film (lower(title)); ``` [표현식 속성 인덱스는 `properties`에 걸린다](#typed-columns-an-expression-property-index-is-built-over-the-bag)를 참고하십시오. [표현식 속성 인덱스는 `properties`에 걸린다](#typed-columns-an-expression-property-index-is-built-over-the-bag)를 참고하십시오. ```cypher DROP PROPERTY INDEX film_year_idx; DROP PROPERTY INDEX film_title_idx; DROP PROPERTY INDEX film_genre_year_idx; DROP PROPERTY INDEX film_comedy_year_idx; CREATE PROPERTY INDEX ON film (year); CREATE PROPERTY INDEX ON film (title); ``` 레이블은 이제 이런 모양입니다. 새 인덱스는 표현식 인덱스가 아니라 보통 컬럼 인덱스입니다: ```psql \d idxlab.film ``` ``` Table "idxlab.film" Column | Type | Collation | Nullable | Default ------------+---------+-----------+----------+----------------------------------------------------------------------------------------------- id | graphid | | not null | graphid(graph_labid('idxlab.film'::cstring), nextval('idxlab.film_id_seq'::regclass)) properties | jsonb | | not null | jsonb_build_object() year | integer | | | generated always as (ag_property_text(properties, 'year'::text, 'n'::"char")::integer) stored title | text | | | generated always as (ag_property_text(properties, 'title'::text, 's'::"char")) stored Indexes: "film_pkey" PRIMARY KEY, btree (id) "film_bag_idx" gin (properties) "film_title_idx" btree (title) "film_year_idx" btree (year) Inherits: idxlab.ag_vertex ``` 같음 비교와 범위 비교가 이제 jsonb가 아니라 네이티브 값을 비교합니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title = 'Film 000042' RETURN f.year; ``` ``` QUERY PLAN --------------------------------------------- Index Scan using film_title_idx on film f Index Cond: (title = 'Film 000042'::text) ``` `IS NULL`이 인덱스로 처리 가능해집니다. 전에는 그렇지 않았습니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title IS NULL RETURN f.year; ``` ``` QUERY PLAN ------------------------------------------- Index Scan using film_title_idx on film f Index Cond: (title IS NULL) ``` `ORDER BY`는 인덱스 스캔에서 인덱스 전용 스캔으로 나아집니다. 쿼리가 원하는 값이 인덱스 안에 있기 때문입니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) RETURN f.title AS t ORDER BY t LIMIT 5; ``` ``` QUERY PLAN ------------------------------------------------------ Limit -> Index Only Scan using film_title_idx on film f ``` 그리고 두 조건은 *나빠집니다*. `IN`은 더 이상 인덱스를 쓰지 않습니다. jsonb 리스트와 비교하려고 컬럼이 다시 jsonb로 감싸이기 때문입니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title IN ['Film 000042', 'Film 000043'] RETURN f.year; ``` ``` QUERY PLAN --------------------------------------------------------------------------------------------- Seq Scan on film f Filter: (cypher_to_jsonb(title) = ANY ('{"\"Film 000042\"","\"Film 000043\""}'::jsonb[])) ``` `STARTS WITH`도 같은 방식으로 감싸이므로, 승격이 이것을 구해 주지도 않습니다: ```cypher EXPLAIN (COSTS OFF) MATCH (f:film) WHERE f.title STARTS WITH 'Film 00004' RETURN f.year; ``` ``` QUERY PLAN ----------------------------------------------------------------------------- Seq Scan on film f Filter: string_starts_with(cypher_to_jsonb(title), '"Film 00004"'::jsonb) ``` 그러나 그 컬럼은 실제 `text` 컬럼이므로 SQL이 실제 `text` 연산자로 그것에 닿을 수 있고, 그것은 *진짜* 접두 범위입니다: ```sql EXPLAIN (COSTS OFF) SELECT count(*) FROM idxlab.film WHERE title LIKE 'Film 00004%'; ``` ``` QUERY PLAN -------------------------------------------------------------------------------------- Aggregate -> Index Only Scan using film_title_idx on film Index Cond: ((title >= 'Film 00004'::text) AND (title < 'Film 00005'::text)) Filter: (title ~~ 'Film 00004%'::text) ``` 자주 쓰는 키에 대한 접두 검색의 실용적인 답이 이것입니다. 속성을 승격하고 접두 조건을 SQL로 쓰되, Cypher 부분이 필요하면 그것을 감싸십시오. [SQL과 Cypher 섞기](combining-sql-and-cypher.md)를 참고하십시오. {#indexes-and-constraints-the-table-after-promotion} ### 승격 뒤의 표 | 조건 | 승격 전(jsonb) | 승격 후(타입 컬럼) | | --- | --- | --- | | `f.title = 'x'` | 예 | 예 | | `f.year >= a AND f.year < b` | 예 | 예 | | `f.title IN ['x', 'y']` | 예 | **아니요** — jsonb로 감싸임 | | `f.year <> 2020` | 아니요 | 아니요 | | `f.title STARTS WITH 'x'` | 아니요 | 아니요 | | `f.title CONTAINS 'x'` | 아니요 | 아니요 | | `f.title =~ '^x'` | 아니요 | 아니요 | | `f.title IS NULL` | 아니요 | **예** | | `ORDER BY f.title LIMIT n` | 예 — 인덱스 스캔 | 예 — 인덱스 전용 스캔 | | SQL `title LIKE 'x%'` | 해당 없음 | 예 — 접두 범위 | {#indexes-and-constraints-across-a-label-hierarchy} ## 레이블 계층에 걸쳐서 인덱스는 하나의 테이블에 속합니다. 부모 레이블에 대한 패턴은 부모와 그 아래의 모든 레이블을 스캔하고, 그 각각에는 자기 인덱스가 필요합니다. 확인할 계열을 하나 만들어 봅니다: ```sql CREATE TABLE seed_work AS SELECT g FROM generate_series(1, 10000) g; ``` ```cypher CREATE VLABEL work; CREATE VLABEL short INHERITS (work); CREATE VLABEL feature INHERITS (work); LOAD FROM seed_work AS r CREATE (:short {code: 'S' || lpad((r.g)::text, 6, '0')}); LOAD FROM seed_work AS r CREATE (:feature {code: 'F' || lpad((r.g)::text, 6, '0')}); CREATE PROPERTY INDEX ON work (code); ``` ```sql ANALYZE idxlab.work; ANALYZE idxlab.short; ANALYZE idxlab.feature; ``` `work`에만 인덱스가 있고, 모든 자식은 전부 읽힙니다: ```cypher EXPLAIN (COSTS OFF) MATCH (w:work) WHERE w.code = 'F000042' RETURN w; ``` ``` QUERY PLAN ---------------------------------------------------------------- Result -> Append -> Seq Scan on work w_1 Filter: (properties.'code' = '"F000042"'::jsonb) -> Seq Scan on short w_2 Filter: (properties.'code' = '"F000042"'::jsonb) -> Seq Scan on feature w_3 Filter: (properties.'code' = '"F000042"'::jsonb) ``` 자식마다 인덱스를 걸면 `Append`의 모든 가지가 인덱스를 얻습니다: ```cypher CREATE PROPERTY INDEX ON short (code); CREATE PROPERTY INDEX ON feature (code); ``` ``` QUERY PLAN -------------------------------------------------------------------- Result -> Append -> Seq Scan on work w_1 Filter: (properties.'code' = '"F000042"'::jsonb) -> Index Scan using short_code_idx on short w_2 Index Cond: (properties.'code' = '"F000042"'::jsonb) -> Index Scan using feature_code_idx on feature w_3 Index Cond: (properties.'code' = '"F000042"'::jsonb) ``` `work` 자신은 순차 스캔을 유지합니다. 자기 행이 없고, 빈 테이블을 읽는 것이 인덱스를 여는 것보다 값싸기 때문입니다. 그것이 옳은 판단입니다. 그리고 이것이 눈여겨봐야 할 모양이기도 합니다. 계열을 검색하는 속성마다, 계층은 레이블당 인덱스 하나의 비용을 영구히 요구합니다. {#indexes-and-constraints-constraints} ## 제약 형태가 두 가지 있고, 그 아래는 PostgreSQL의 두 형태입니다. `ASSERT IS UNIQUE`는 그 키의 표현식에 배제 제약을 만듭니다: ```cypher CREATE VLABEL account; CREATE CONSTRAINT account_email_uq ON account ASSERT email IS UNIQUE; ``` ```cypher CREATE (:account {email: 'a@example.com', age: 30}); CREATE (:account {email: 'a@example.com', age: 31}); ``` ``` ERROR: conflicting key value violates exclusion constraint "account_email_uq" DETAIL: Key ((properties.'email'))=("a@example.com") conflicts with existing key ((properties.'email'))=("a@example.com"). ``` `ASSERT `는 검사 제약을 만듭니다: ```cypher CREATE CONSTRAINT account_age_ck ON account ASSERT age > 0; CREATE (:account {email: 'b@example.com', age: -1}); ``` ``` ERROR: new row for relation "account" violates check constraint "account_age_ck" DETAIL: Failing row contains (7.3, {"age": -1, "email": "b@example.com"}). ``` 둘 다 보통 제약으로 보이고, 고유성 제약은 그 표현식에 걸린 실제 btree가 뒤를 받치므로 그 키를 찾는 인덱스 역할도 겸합니다: ```sql SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'idxlab.account'::regclass AND contype IN ('c', 'x') ORDER BY conname; ``` ``` conname | pg_get_constraintdef ------------------+--------------------------------------------------- account_age_ck | CHECK ((properties.'age' > cypher_to_jsonb(0))) account_email_uq | EXCLUDE USING btree ((properties.'email') WITH =) ``` 두 제약 모두 그 속성이 없는 정점에 대해서는 아무것도 말하지 않습니다. 없는 키는 null로 읽히고, null은 다른 null과 같지 않으며, 알 수 없는 값에 대한 검사 제약은 통과합니다: ```cypher CREATE (:account {email: 'c@example.com'}); CREATE (:account {age: 44}); CREATE (:account {age: 45}); MATCH (a:account) RETURN a.email AS email, a.age AS age ORDER BY email NULLS LAST, age; ``` ``` email | age -----------------+----- "a@example.com" | 30 "c@example.com" | | 44 | 45 ``` 이메일이 아예 없는 계정 둘이 아무 문제 없이 공존합니다. 어떤 키가 고유해야 하는 데다 반드시 있어야 한다면, null이 아님을 확인하는 제약을 하나 더 추가하십시오. `properties`가 그것을 대신 해 주지 않습니다. `CREATE UNIQUE PROPERTY INDEX`가 고유성을 표기하는 또 다른 방법이며, 배제 제약이 아니라 고유 인덱스를 만듭니다: ```cypher CREATE VLABEL member; CREATE UNIQUE PROPERTY INDEX ON member (card); CREATE (:member {card: '0001'}); CREATE (:member {card: '0001'}); ``` ``` ERROR: duplicate key value violates unique constraint "member_card_idx" DETAIL: Key ((properties.'card'))=("0001") already exists. ``` 빠른 고유 조회에는 이쪽을 택하십시오. `pg_constraint`와 덤프에서 직접 정한 이름으로 보고 싶은 규칙이라면 이름 붙은 제약을 택하십시오. 둘 다 자식 레이블에 상속되지 않습니다. 부모에 걸린 제약은 부모 자신의 행만 제약합니다. {#indexes-and-constraints-practical-order-of-work} ## 실제 작업 순서 "왜 이게 느린가" 하는 질문 대부분에 답하는 순서입니다: 1. 관련된 레이블에 `ANALYZE`를 돌리십시오. 간선 레이블도 포함합니다. 분석하지 않은 간선 레이블은 빠진 인덱스보다 비쌉니다. [쿼리 튜닝](#performance-statistics-on-edge-labels)을 참고하십시오. 2. 패턴이 *고정된* 속성에 인덱스를 거십시오. 시작 정점을 골라 내는, 선택도가 높은 같음 비교 하나가 그것입니다. 그 뒤의 모든 것은 이미 존재하는 간선 인덱스를 통한 조인입니다. 3. 그 조건이 `Filter`가 아니라 `Index Cond`로 도착하는지 `EXPLAIN`으로 확인하십시오. 4. 그러고 나서야 속성 승격을 검토하십시오. 승격은 탐색이 아니라 읽기 자체가 비용일 때의 답입니다. {#indexes-and-constraints-clean-up} ## 정리 ```cypher DROP GRAPH idxlab CASCADE; ``` ```sql DROP TABLE seed_film; DROP TABLE seed_work; ``` {#indexes-and-constraints-where-to-go-next} ## 다음에 갈 곳 * [속성을 컬럼으로 승격하기](typed-columns.md) — 위 이야기의 나머지 절반입니다. * [쿼리 튜닝](performance.md) — 실행 계획의 모양, 통계, 그리고 그것들을 바꾸는 설정입니다. * [전문 검색](../guides/search/full-text.md) — 부분 문자열과 접두 일치에 대한, 인덱스로 처리되는 답입니다.