당신의 Diesel 마이그레이션이 시한폭탄일지도 모릅니다

발행: (2025년 12월 16일 오후 11:03 GMT+9)
15 min read
원문: Dev.to

I’m happy to translate the article for you, but I’ll need the full text of the post (the markdown content) in order to do so. Could you please paste the article’s content here? Once I have it, I’ll provide a Korean translation while keeping the source link, formatting, code blocks, URLs, and technical terms exactly as they appear.

Postgres 잠금 문제

Handshake은 정기적인 하루 여러 차례 릴리스 사이클 중에 일상적인 마이그레이션처럼 보이는 작업을 배포했습니다:

ALTER TABLE table_name
  ADD CONSTRAINT fk_user_id
  FOREIGN KEY (user_id) REFERENCES users(id);

60초 후, 사이트 전체가 다운되었습니다.

문제는? 외래키 제약조건을 추가하려면 참조되는 테이블에 ACCESS EXCLUSIVE 잠금이 필요합니다. PostgreSQL은 선착순으로 잠금을 부여합니다. users 테이블에서 실행 중이던 오래된 쿼리가 ACCESS SHARE 잠금을 보유하고 있었기 때문에 마이그레이션은 그 잠금이 해제되기를 기다리며 대기열에 쌓였습니다. ACCESS EXCLUSIVE 잠금은 모든 잠금과 충돌하므로, 마이그레이션 이후에 들어온 일반 SELECT 쿼리들도 모두 그 뒤에 대기하게 되었습니다. 잠금 대기열이 커지면서 사이트는 응답을 멈추었고, 사이트를 복구하기 위해 마이그레이션을 중단해야 했습니다.

GoCardless도 비슷한 문제를 겪었습니다. 그들은 이름이 바뀐 테이블에 외래키 제약조건을 다시 만들고 있었습니다. 테이블 자체는 비어 있었기 때문에 안전해 보였지만, 제약조건을 추가하려면 부모 테이블에 잠금이 필요했으며 해당 테이블은 많이 사용되고 있었습니다. 결과적으로 API 타임아웃이 약 15초 동안 발생했습니다.

두 사건 모두 전혀 이상하지 않은 데이터베이스 마이그레이션에서 발생했으며, 스테이징에서는 정상적으로 동작했지만 프로덕션에서는 심각한 문제를 드러냈습니다.

Source:

ACCESS EXCLUSIVE 잠금을 잡는 일반적인 작업

PostgreSQL은 데이터 일관성을 유지하기 위해 다양한 잠금 수준을 사용합니다. 가장 제한적인 잠금이 ACCESS EXCLUSIVE입니다. 이 잠금을 보유하고 있으면 다른 어떤 작업도 해당 테이블에 접근할 수 없습니다SELECT도, INSERT도, 아무것도 못합니다.

다음과 같은 흔한 마이그레이션 작업들은 ACCESS EXCLUSIVE 잠금을 잡습니다:

인덱스 생성

CREATE INDEX idx_users_email ON users(email);

이 작업은 SHARE 잠금을 잡으며, 인덱스가 생성되는 동안 모든 쓰기(INSERT, UPDATE, DELETE)를 차단합니다. 수백만 행이 있는 테이블에서는 몇 분이 걸릴 수 있습니다.

NOT NULL 추가

ALTER TABLE users ALTER COLUMN email SET NOT NULL;

PostgreSQL은 모든 행이 NULL이 아닌 값을 가지고 있는지 확인해야 하므로, 그 전체 과정 동안 ACCESS EXCLUSIVE 잠금을 유지합니다.

컬럼 타입 변경

ALTER TABLE products ALTER COLUMN price TYPE NUMERIC(10,2);

이 작업은 전체 테이블을 다시 쓰게 만들며—각 행을 새 타입으로 변환하면서—ACCESS EXCLUSIVE 잠금을 잡습니다.

잠금 큐가 상황을 악화시킨다

PostgreSQL의 잠금 큐는 선착순으로 처리됩니다. 마이그레이션이 잠금을 기다리고 있으면, 그 뒤에 오는 모든 쿼리도 그 뒤에 대기열에 쌓입니다, 원래 잠금 보유자와 충돌하지 않더라도 말이죠.

전형적인 시나리오:

  1. 오래 실행되는 쿼리가 users 테이블에 ACCESS SHARE 잠금을 잡고 있다.
  2. 마이그레이션이 ACCESS EXCLUSIVE 잠금을 획득하려고 시도하고 대기열에 들어간다.
  3. 새로운 SELECT가 들어오는데, 보통은 ACCESS SHARE 잠금만 필요하므로 문제가 없지만, 마이그레이션이 기다리고 있기 때문에 이 SELECT도 대기열 뒤쪽에 배치된다.
  4. 더 많은 SELECT가 들어와서 역시 대기열에 쌓인다.
  5. 애플리케이션에서 타임아웃이 발생하기 시작한다.

마이그레이션조차 아직 시작되지 않았는데, 이미 서비스가 중단된 상황입니다.

왜 이것을 잡기 어려운가

이러한 마이그레이션은 개발 환경에서는 (수백 개의 행) 정상적으로 보이며 스테이징에서는 (수만 개의 행) 즉시 실행됩니다. 하지만 프로덕션(수백만 개의 행)에서는 테이블을 몇 분 동안 잠궈 트래픽을 차단합니다. 실제 데이터와 실제 트래픽에 대해 실행될 때까지 문제를 발견하지 못합니다.

Enter diesel‑guard

diesel-guard은 위험한 작업을 찾아내기 위해 Diesel 마이그레이션 파일을 스캔하는 정적 분석 도구입니다.

Install

cargo install diesel-guard

Run

diesel-guard check migrations/

Example Output

❌ Unsafe migration detected in migrations/2024_01_01_add_fk/up.sql

❌ ADD COLUMN with DEFAULT

Problem:
  Adding column 'status' with DEFAULT on table 'orders' requires a full
  table rewrite on PostgreSQL < 11, which acquires an ACCESS EXCLUSIVE
  lock. On large tables, this can take significant time and block all
  operations.

Safe alternative:
  1. Add the column without a default:
     ALTER TABLE orders ADD COLUMN status TEXT;

  2. Backfill data in batches (outside migration):
     UPDATE orders SET status = 'pending' WHERE status IS NULL;

  3. Add default for new rows only:
     ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'pending';

  Note: For Postgres 11+, this is safe if the default is a constant.

The tool tells you:

  • What’s dangerous about the operation
  • Which lock it takes
  • Step‑by‑step safe fix with SQL

일반 작업에 대한 안전한 대안

인덱스 생성

대신에

CREATE INDEX idx_orders_created_at ON orders(created_at);

다음과 같이 수행

CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at);

CONCURRENTLY는 인덱스가 구축되는 동안에도 쓰기가 계속될 수 있게 합니다. CREATE INDEX CONCURRENTLY는 트랜잭션 내부에서 실행될 수 없으므로 metadata.toml 파일을 추가합니다:

# migrations/2024_01_01_add_order_index/metadata.toml
run_in_transaction = false

NOT NULL 추가

대신에

ALTER TABLE users ALTER COLUMN email SET NOT NULL;

다음과 같이 수행

-- Add a CHECK constraint without validating existing rows
ALTER TABLE users
  ADD CONSTRAINT users_email_not_null_check
  CHECK (email IS NOT NULL) NOT VALID;

-- Validate separately (lighter lock)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null_check;

-- Finally, drop the CHECK and add the NOT NULL constraint if desired
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

초기 CHECK … NOT VALID는 가벼운 잠금만 획득하고, 이후 VALIDATE CONSTRAINT는 작은 배치로 실행하여 긴 독점 잠금을 피할 수 있습니다.

NOT NULL 제약조건 추가

-- 체크 제약조건 추가 (검증했으므로 빠름)
ALTER TABLE users ADD CONSTRAINT users_email_not_null_check CHECK (email IS NOT NULL);

-- NOT NULL 추가 (검증했으므로 빠름)
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- 정리
ALTER TABLE users DROP CONSTRAINT users_email_not_null_check;

VALIDATE 단계는 SHARE UPDATE EXCLUSIVE를 사용하므로 읽기와 쓰기가 계속 가능합니다.

UNIQUE 제약조건 추가

다음 대신:

ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);

다음과 같이 하세요:

-- Build index concurrently
CREATE UNIQUE INDEX CONCURRENTLY users_email_idx ON users(email);

-- Add constraint using existing index (instant)
ALTER TABLE users
  ADD CONSTRAINT users_email_key
  UNIQUE USING INDEX users_email_idx;

외래 키 추가

대신:

ALTER TABLE posts
  ADD CONSTRAINT posts_user_id_fkey
  FOREIGN KEY (user_id) REFERENCES users(id);

다음과 같이 수행하세요:

-- 기존 행을 검증하지 않고 제약 조건 추가
ALTER TABLE posts
  ADD CONSTRAINT posts_user_id_fkey
  FOREIGN KEY (user_id) REFERENCES users(id)
  NOT VALID;

-- 별도로 검증 (잠금이 가벼움)
ALTER TABLE posts VALIDATE CONSTRAINT posts_user_id_fkey;

NOT VALID는 기존 행을 스캔하지 않음을 의미합니다. VALIDATE는 별도로 수행되며 더 가벼운 잠금을 사용합니다.

타임아웃 설정

Handshake와 GoCardless가 배운 한 가지: 마이그레이션에서 lock_timeout을 설정하세요.

-- At the top of your migration
SET lock_timeout = '2s';

ALTER TABLE users ADD COLUMN email TEXT;

마이그레이션이 2초 이내에 락을 얻지 못하면 무한히 대기하는 대신 실패합니다. 애플리케이션은 계속 실행되며, 트래픽이 적은 시간에 다시 시도할 수 있습니다.

안전하다고 확신할 때

때때로 마이그레이션이 안전하다고 알 수 있습니다 (작은 테이블, 유지보수 창 등):

-- safety-assured:start
-- Safe because: table has 50 rows, deploying during maintenance window
ALTER TABLE countries ADD COLUMN flag_emoji TEXT DEFAULT '🏳️';
-- safety-assured:end

diesel-guard는 이러한 블록 안의 모든 것을 건너뜁니다.

구성

프로젝트에 diesel-guard.toml 파일을 넣으세요:

# 이 날짜 이후의 마이그레이션 검사 건너뛰기
start_after = "2024_01_01_000000"

# down.sql도 검사하기
check_down = true

# 필요에 따라 특정 검사 비활성화
disable_checks = ["CreateExtensionCheck"]

CI/CD 통합

Add to GitHub Actions:

name: Check Migrations
on: [pull_request]

jobs:
  check-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ayarotsky/diesel-guard@v0.3.0

이제 위험한 마이그레이션이 PR 검토 단계에서 잡히고, 프로덕션에서는 발생하지 않습니다.

무엇을 확인하는가

diesel-guard는 현재 18가지 서로 다른 문제를 감지합니다:

  • DEFAULT가 있는 ADD COLUMN
  • CONCURRENTLY 없이 CREATE INDEX
  • ADD NOT NULL
  • ADD UNIQUE 제약조건
  • NOT VALID 없이 ADD FOREIGN KEY
  • 기존 테이블에 ADD PRIMARY KEY
  • ADD SERIAL 컬럼
  • ALTER COLUMN TYPE
  • CREATE EXTENSION
  • DROP COLUMN
  • CONCURRENTLY 없이 DROP INDEX
  • DROP PRIMARY KEY
  • RENAME COLUMN
  • RENAME TABLE
  • 짧은 정수형 기본키 (SMALLINT/INT)
  • TRUNCATE TABLE
  • 이름 없는 제약조건
  • JSON 대신 JSONB 사용
  • 넓은 인덱스 (4개 이상 컬럼)

추가 예정입니다. 목표는 가장 위험한 PostgreSQL 작업을 포괄하는 40개의 검사를 구현하는 것입니다.

왜 이것이 Rust에 중요한가

Rust 생태계는 훌륭한 도구들을 가지고 있습니다. clippy는 코드에 린트를 적용합니다. cargo audit는 보안 문제를 잡아냅니다. 하지만 데이터베이스 마이그레이션을 위한 도구는 없었습니다.

마이그레이션으로 인한 프로덕션 사고를 너무 많이 보았습니다. 해결책은 뒤돌아보면 보통 명확하지만, 다운타임을 일으킬 때서야 알게 됩니다.

diesel-guard는 해결책을 개발 단계로 앞당깁니다.

이걸 사용해야 할까요?

아마도 “내 테이블은 작다”라고 생각하고 있을 겁니다.

테이블은 성장합니다. users 테이블이 오늘은 100행이지만 내년에는 백만 행이 될 수도 있습니다. 지금은 즉시 실행되는 마이그레이션이 나중에는 몇 분이 걸릴 수 있습니다.

시작부터 안전한 마이그레이션을 구축하는 것이 사고 발생 시 이를 수정하는 것보다 훨씬 쉽습니다. 그리고 diesel-guard는 실행하는 데 몇 초밖에 걸리지 않습니다.

마무리

데이터베이스 마이그레이션은 까다로울 수 있습니다. 겉보기에는 완전히 안전해 보이는 작업도 심각한 프로덕션 문제를 일으킬 수 있습니다. 스테이징 환경과 프로덕션 환경에서의 동작 차이는 클 수 있으며, 종종 너무 늦은 후에야 문제를 알게 됩니다.

정적 분석은 이러한 문제를 조기에 포착할 수 있습니다. diesel-guard를 사용하든 자체 검사를 만들든, 마이그레이션이 프로덕션에 도달하기 전에 검토해 주는 것이 가치 있습니다. 패턴은 잘 문서화되어 있으며, 이를 적용할 도구만 있으면 됩니다.

데이터베이스 마이그레이션 워크플로에 안전성을 구축하는 것은 큰 보탬이 됩니다.

Back to Blog

관련 글

더 보기 »

Postgres 18이 이제 사용 가능

Postgres 18이 이제 PlanetScale에서 제공됩니다. 오늘부터 새 데이터베이스를 만들면 기본 버전이 18.1이 됩니다. 이전 버전을 선택할 수도 있습니다.