Postgres 18에서 오셨나요? Postgres 17에서 바꿔야 할 것들

Relyt One은 PostgreSQL 17에서 실행됩니다. 스키마나 습관이 Postgres 18에서 왔다면, 17에서 무엇이 실패하는지, 대신 무엇을 써야 하는지, 18에서 만든 덤프를 어떻게 불러오는지 소개합니다. 모든 코드는 두 버전에서 모두 실행했습니다.

Zhan Lu읽는 시간 7분
이 페이지의 내용

Relyt One은 PostgreSQL 17에서 실행됩니다. Postgres 18은 2025년 9월에 출시되었고, 그래서 많은 새 프로젝트, 튜토리얼, AI가 생성한 스키마가 이제 18에만 있는 기능에 기대고 있습니다. uuidv7(), 가상 생성 열, RETURNING의 OLD와 NEW, 시간적 기본 키가 그렇습니다. 17에서는 이들 모두가 오류입니다. 이 글에서는 무엇이 실패하는지 나열하고, 각각에 대해 오늘 17에서 실행되는 대체 방법을 제시하며, 18에서 만든 덤프를 불러오는 과정을 차례로 살펴봅니다. 모든 코드는 PostgreSQL 17.5와 18.6에서 실행했으며, 보여 드리는 출력은 실제로 얻은 결과입니다.

특별한 내용은 아닙니다. 대부분의 스키마는 이 기능들을 전혀 쓰지 않으며, 쓰는 스키마도 보통 한두 개만 씁니다. 핵심은 마이그레이션이 중간에 실패한 뒤가 아니라, 실패하기 전에 이들을 찾아내는 것입니다.

Postgres 18의 네 가지 기능과 Postgres 17에서 대신 쓸 것: uuidv7()는 SQL 함수로, 가상 생성 열은 STORED 또는 뷰로, RETURNING old/new는 FOR UPDATE를 쓰는 CTE로, WITHOUT OVERLAPS는 btree_gist를 쓰는 배제 제약 조건으로 바뀝니다

먼저, 연결한 서버를 확인하세요

SELECT version();
SHOW server_version_num;   -- 170005는 17.5를 뜻합니다. 17로 시작하면 모두 Postgres 17입니다

코드가 런타임에 버전에 따라 분기해야 한다면, version() 문자열이 아니라 server_version_num을 숫자로 비교하세요(>= 180000).

1. uuidv7()

Postgres 18은 시간 순서대로 정렬되는 UUID를 생성하는 uuidv7()를 추가했습니다. 새 행이 인덱스 여기저기가 아니라 끝에 들어가므로, 무작위인 버전 4 UUID보다 더 나은 기본 키가 됩니다. 17에서는 이렇습니다.

ERROR:  function uuidv7() does not exist

같은 형식을 SQL로 생성할 수 있습니다. 버전 7 UUID는 밀리초 단위의 48비트 Unix 타임스탬프 뒤에 무작위 비트가 이어지고, 버전 비트와 변형(variant) 비트가 설정된 형태입니다. gen_random_uuid()(Postgres 13부터 내장, 확장 불필요)가 이미 무작위 비트와 변형 비트를 제공하므로, 함수는 앞의 6바이트를 타임스탬프로 덮어쓰고 비트 두 개를 뒤집어 버전을 4에서 7로 바꿉니다.

CREATE FUNCTION public.uuidv7() RETURNS uuid
LANGUAGE sql VOLATILE
AS $$
  -- 밀리초 단위의 48비트 Unix 시간, 그다음 v4 UUID의 무작위 비트 위에 버전 7
  SELECT encode(
    set_bit(set_bit(
      overlay(uuid_send(gen_random_uuid())
              PLACING substring(int8send(floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) FROM 3)
              FROM 1 FOR 6),
      52, 1), 53, 1),
    'hex')::uuid;
$$;

CREATE TABLE events (
  id   uuid PRIMARY KEY DEFAULT public.uuidv7(),
  body text
);

Postgres 15 이상에서는 기본적으로 데이터베이스 소유자만 public에 객체를 만들 수 있으므로, 이 코드는 소유자로 실행하세요(또는 직접 소유한 스키마에 함수를 두세요). 생성된 값 10,000개를 확인했습니다. 모두 버전 7이고 RFC 9562 변형이었으며, 포함된 타임스탬프는 서버 시계와 일치했습니다. now()가 아니라 clock_timestamp()를 쓰므로, 같은 트랜잭션에서 나중에 삽입한 행도 더 늦은 타임스탬프를 받습니다.

실질적인 차이가 하나 있습니다. 18의 내장 함수는 밀리초 미만 부분도 채우며, 테스트에서 18의 문장 하나로 생성한 값 1,000개는 엄격하게 증가하는 순서로 나왔습니다. SQL 버전은 밀리초 사이에서는 값을 순서대로 정렬하지만 한 밀리초 안에서는 그렇지 않습니다. 같은 밀리초에 생성된 값은 무작위 순서입니다. 인덱스 지역성에는 상관없습니다. 코드가 한 세션의 ID가 엄격하게 증가한다는 것에 의존한다면, 순서에는 대신 시퀀스를 쓰세요.

관련된 함정이 하나 있습니다. uuid_extract_timestamp()는 17에도 있지만, 17에서는 버전 1 UUID만 이해하고 버전 7에는 NULL을 반환합니다. 버전 7 지원은 Postgres 18에서 추가되었습니다. 17에서는 타임스탬프를 직접 추출하세요.

CREATE FUNCTION public.uuidv7_timestamp(u uuid) RETURNS timestamptz
LANGUAGE sql IMMUTABLE STRICT
AS $$
  SELECT to_timestamp(('x' || substr(replace(u::text, '-', ''), 1, 12))::bit(48)::bigint / 1000.0);
$$;

SELECT public.uuidv7_timestamp('019535d9-3df7-79fb-b466-fa907fa17f9e');
-- 2025-02-24 02:46:24.503+00, 18의 uuid_extract_timestamp()가 반환하는 값과 같습니다

내장 함수와 달리 이 헬퍼는 버전을 확인하지 않습니다. 버전 4 UUID를 주면 NULL 대신 의미 없는 시간을 반환합니다. 버전 7임을 아는 ID에만 호출하세요.

2. 가상 생성 열

18에서 생성 열은 따로 지정하지 않으면 읽을 때 계산됩니다. VIRTUAL이 새 기본값이고 STORED가 예전 동작입니다. 17에는 저장 생성 열만 있고, 키워드가 반드시 필요합니다. 다음 두 문장은 17에서 모두 실패합니다.

CREATE TABLE product (price_cents int, price numeric GENERATED ALWAYS AS (price_cents / 100.0) VIRTUAL);
-- ERROR:  syntax error at or near "VIRTUAL"

CREATE TABLE product (price_cents int, price numeric GENERATED ALWAYS AS (price_cents / 100.0));
-- ERROR:  syntax error at or near ")"

두 번째가 보기보다 중요합니다. 18에서는 유효한 문장이고 가상 열을 뜻하므로, 18용으로 작성된 스키마에는 키워드가 아예 없는 경우가 많습니다. 대체 방법은 두 가지입니다.

-- 방법 A: 저장합니다. 같은 열, 같은 쿼리. 디스크와 쓰기 시간이 듭니다.
CREATE TABLE product (
  price_cents int,
  price       numeric GENERATED ALWAYS AS (price_cents / 100.0) STORED
);

-- 방법 B: 테이블은 가볍게 두고 뷰에서 계산합니다.
CREATE TABLE product_base (price_cents int);
CREATE VIEW product AS
  SELECT *, price_cents / 100.0 AS price FROM product_base;

이처럼 단순한 뷰는 자동으로 갱신 가능하므로, INSERT INTO product (price_cents) VALUES (1999)는 뷰를 통해서도 그대로 동작합니다. 17에서 시도해 보았습니다. 식이 크거나 테이블에 쓰기가 많은 경우가 아니라면 A를 고르세요.

3. RETURNING의 OLD와 NEW

18에서는 RETURNING이 변경 전과 후의 행을 가리킬 수 있습니다.

UPDATE acct SET balance = balance - 30 WHERE id = 1
RETURNING old.balance AS before, new.balance AS after;

17에서는 missing FROM-clause entry for table "old"로 실패합니다. 이전 값을 얻으려면 CTE에서 행을 읽고 잠근 다음, UPDATE에서 그것과 조인하세요.

WITH old AS (
  SELECT id, balance FROM acct WHERE id = 1 FOR UPDATE
)
UPDATE acct a SET balance = a.balance - 30
FROM old
WHERE a.id = old.id
RETURNING old.balance AS before, a.balance AS after;
 before | after
--------+-------
    100 |    70

이것을 올바르게 만드는 것은 FOR UPDATE입니다. 갱신이 행을 읽기 전에 행을 잠그므로, 동시에 일어난 변경이 그 사이에 끼어들어 before를 거짓으로 만들 수 없습니다. 이것이 없으면 동시성 상황에서, 갱신 시점에 한 번도 행의 상태였던 적이 없는 "before" 값을 반환할 수 있습니다. DELETE에서는 평범한 RETURNING *가 이미 이전 행을 반환합니다. INSERT … ON CONFLICT DO UPDATE에서는 이전 값을 얻는 깔끔한 17용 대안이 없습니다. 필요하다면 FOR UPDATE로 먼저 조회하거나, 감사 트리거를 두세요. 행이 아직 없을 때는 먼저 조회해도 도움이 되지 않습니다. 잠글 것이 없으므로 동시에 일어난 삽입이 여전히 그 사이에 끼어들 수 있습니다.

4. 시간적 제약 조건: WITHOUT OVERLAPS

18은 "같은 회의실에 대해 시간 범위가 겹치는 두 행은 없다"를 기본 키로 강제할 수 있습니다.

-- 18에서도 room이 평범한 int이므로 btree_gist가 필요합니다
CREATE TABLE booking (room int, during tstzrange, PRIMARY KEY (room, during WITHOUT OVERLAPS));
-- 17에서는: ERROR:  syntax error at or near "WITHOUT"

이 보장 자체는 배제 제약 조건으로 오래전부터 쓸 수 있었습니다. 평범한 등호 열(room)과 범위를 GiST 인덱스 하나에 섞으려면 btree_gist 확장이 필요합니다. 이 확장은 신뢰(trusted)로 표시되어 있으므로, 데이터베이스에 CREATE 권한이 있는 역할이라면(소유자에게는 있습니다) 슈퍼유저 권한 없이 설치할 수 있습니다.

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE booking (
  room   int       NOT NULL,
  during tstzrange NOT NULL CHECK (NOT isempty(during)),
  EXCLUDE USING gist (room WITH =, during WITH &&)
);

INSERT INTO booking VALUES (101, '[2026-11-10 09:00Z, 2026-11-10 10:00Z)');
INSERT INTO booking VALUES (101, '[2026-11-10 10:00Z, 2026-11-10 11:00Z)');  -- 성공: 맞닿을 뿐 겹치지 않음
INSERT INTO booking VALUES (101, '[2026-11-10 09:30Z, 2026-11-10 10:30Z)');
-- ERROR:  conflicting key value violates exclusion constraint "booking_room_during_excl"

CHECK가 중요합니다. 18은 WITHOUT OVERLAPS 키에서 빈 범위를 거부하지만, 빈 범위는 어떤 것과도 겹치지 않으므로 이 검사가 없으면 배제 제약 조건은 빈 범위를 몇 개든 받아들입니다. 데이터베이스에서 확장을 쓸 수 있는지는 SELECT * FROM pg_available_extensions WHERE name = 'btree_gist';로 확인하세요. 테스트한 Relyt One 컴퓨트 이미지에 버전 1.7로 포함되어 있음을 확인했습니다. 17이 할 수 없는 것은 이 기능의 나머지 절반인 시간적 외래 키(FOREIGN KEY (…, PERIOD …))입니다. 선언적인 대안은 없습니다. 트리거나 애플리케이션에서 강제하고, 테스트하세요. 범위의 경계에서 틀리기 쉽기 때문입니다.

5. 바꿀 필요가 없는 것

18의 주요 기능 중 몇 가지는 SQL이 아니라 성능이나 운영을 바꿉니다. 스키마와 쿼리에 이들이 등장하지 않으므로 다시 작성할 것이 없습니다.

  • 비동기 I/O는 18에서 순차 스캔, 비트맵 힙 스캔, vacuum을 빠르게 합니다. 17에서도 같은 쿼리가 동작하며 동기 I/O를 씁니다.
  • 스킵 스캔은 쿼리가 선두 열로 필터링하지 않아도 18이 다중 열 B-tree 인덱스를 쓸 수 있게 합니다. 17에서 쿼리가 (tenant_id, created_at)의 두 번째 열로만 필터링한다면 그 인덱스를 쓰지 않을 수 있습니다. EXPLAIN으로 실행 계획을 보고, 중요하다면 필터링하는 열이 앞에 오는 인덱스를 추가하세요.
  • OAuth 클라이언트 인증은 18에서 새로 생겼습니다. Relyt One에는 콘솔의 연결 문자열을 sslmode=require와 함께 써서, TLS 위에서 역할의 비밀번호로 연결합니다.
  • 18의 MD5 비밀번호 지원 중단은 17 스키마에 아무 영향이 없습니다.

Postgres 18에서 만든 덤프 불러오기

pg_dump 문서는 분명하게 말합니다. 출력은 더 새로운 서버에 불러올 수 있도록 되어 있으며, 더 오래된 메이저 버전에 불러오는 것은 "보장되지 않으며" "덤프 파일을 수동으로 편집해야 할 수 있습니다". 18에서 17로 가는 것이 바로 그 경우입니다. 다음은 실제로 사용한 절차와 그 과정에서 깨진 것들입니다.

불러올 때는 17.6 이상의 psql을 쓰세요. 현재 pg_dump로 만든 덤프는 \restrict 줄로 시작합니다. 보안 수정(CVE-2025-8714)을 위해 17.6에서 추가된 psql 메타 명령이며, 모든 Postgres 18 릴리스에 포함되어 있습니다. psql 17.5는 이를 invalid command \restrict로 거부합니다. ON_ERROR_STOP=1이면 불러오기가 그 자리에서 중단되고, 그렇지 않으면 psql은 계속 진행하면서 \restrict가 제공하려는 보호를 조용히 건너뜁니다. 18.x 클라이언트나 17.6 이상을 쓰세요. psql 18은 17 서버에 문제없이 연결합니다.

스키마와 데이터를 따로 덤프하세요. 대상의 역할이 다를 것이므로 소유자와 권한은 빼고 덤프합니다.

pg_dump "$SOURCE_URL" --schema-only --no-owner --no-privileges --quote-all-identifiers > schema.sql
pg_dump "$SOURCE_URL" --data-only   --no-owner --no-privileges --quote-all-identifiers > data.sql

--quote-all-identifiers는 pg_dump 문서가 버전 간 불러오기에 권장하는 옵션입니다. 한 버전에서는 예약어이고 다른 버전에서는 아닌 단어 때문에 생기는 예상치 못한 문제를 피할 수 있습니다. 전체 스키마 다음에 데이터를 불러오면, 불러오는 동안 인덱스, 외래 키, 트리거가 이미 존재합니다. 단일 덤프보다 느리고, 직접 만든 트리거가 모든 행마다 실행됩니다. 큰 데이터베이스라면 대신 --section=pre-data, data, post-data로 나누고 pre-data 파일만 편집하세요.

무엇이든 불러오기 전에 18에만 있는 구문을 찾으세요.

grep -nE 'uuidv7\(|uuidv4\(|WITHOUT OVERLAPS|PERIOD ' schema.sql
grep -nE 'GENERATED ALWAYS AS \(' schema.sql | grep -v STORED     # 가상 열
grep -niE 'RETURNING +(old|new)\.|RETURNING +WITH *\(' schema.sql  # 함수와 프로시저 안

테스트 스키마에서는 이것으로 두 줄을 찾았습니다.

56:    id uuid DEFAULT uuidv7() NOT NULL,
58:    price numeric GENERATED ALWAYS AS (((cents)::numeric / 100.0))

58번 줄에 주목하세요. pg_dump 18은 가상 열을 키워드 없이 쓰므로, VIRTUAL로 검색하면 놓칩니다. 뒤에 STORED가 없는 GENERATED ALWAYS AS (…)는 NOT NULL이 뒤따르는 경우를 포함해 모두 가상 열입니다. STORED를 추가하세요.

대체한 함수를 스키마로 한정하세요. 덤프는 맨 위에서 search_path를 비우고, 18에서 uuidv7()는 내장 함수이므로 덤프는 이를 한정하지 않고 호출합니다. 불러오는 동안 직접 만든 public.uuidv7()를 찾지 못합니다.

ERROR:  function uuidv7() does not exist

기본값을 public.uuidv7()로 다시 쓰세요. 그런 다음 대체 함수를 먼저 만들고, 첫 번째 오류에서 멈추는 트랜잭션 하나로 모두 불러오세요.

psql "$TARGET_URL" -v ON_ERROR_STOP=1 --single-transaction \
  -f uuidv7.sql -f schema.sql -f data.sql

$TARGET_URL은 ?sslmode=require로 끝나는 Relyt One 연결 문자열입니다. 이 두 가지를 편집하자, 테스트 데이터베이스가 17에 깔끔하게 불러와졌습니다. IDENTITY 열은 올바른 값부터 이어졌고, 생성 열은 불러올 때 다시 계산되었으며, 새 행은 대체 함수로부터 버전 7 UUID를 받았습니다. 생성 열의 값은 데이터 덤프에 아예 들어 있지 않으므로, 열을 가상에서 저장으로 바꿔도 데이터를 바꿀 필요가 없습니다.

옮기기 전 체크리스트

  • 대상에서 SHOW server_version_num을 실행해 어떤 메이저 버전인지 확인합니다
  • psql 17.6 이상 또는 18.x와 -v ON_ERROR_STOP=1로 불러옵니다
  • 스키마 덤프에서 uuidv7(, uuidv4(, STORED 없는 GENERATED ALWAYS AS, WITHOUT OVERLAPS, PERIOD, 대소문자와 관계없이 RETURNING old. / new.를 grep으로 찾습니다
  • 스키마보다 먼저 대체 함수를 만들고, 스키마로 한정합니다(public.uuidv7())
  • WITHOUT OVERLAPS를 배제 제약 조건으로 바꾸고, 시간적 외래 키는 트리거로 옮깁니다
  • 애플리케이션 코드도 검색합니다. ORM과 쿼리 빌더는 RETURNING을 생성하며, 저장소의 스키마 파일이 18을 대상으로 할 수도 있습니다
  • 프로덕션을 옮기기 전에 브랜치에서 테스트 스위트를 실행합니다

마지막 단계는 Relyt One에서 저렴합니다. 브랜치를 만들고, 거기에 불러오고, 그 연결 문자열로 테스트를 실행하고, 문제가 있으면 버리면 됩니다.

브랜치에서 시도해 보기

태그Postgresmigration

작성자

Zhan Lu

다음 데이터베이스는 브랜치 하나면 됩니다.

1분 안에 무료 프로젝트를 만드세요. 신용카드도, 설정도 필요 없습니다.

블로그로 돌아가기