Preview 환경에 프로덕션 데이터를 안전하게 제공하는 방법
Neon 마스킹 브랜치 vs 커스텀 익명화 스크립트, 두 달간 병행한 실전 비교
"스테이징에서는 잘 되는데 프로덕션에서 터졌어요." 개발자라면 한 번쯤은 들어봤을 이 문장의 원인 중 상당수는 테스트 데이터가 현실을 반영하지 못해서 생긴다. 10건짜리 시드 데이터로는 N+1 쿼리 문제도, 특수문자가 포함된 사용자 이름이 깨지는 버그도 잡기 어렵다. 그렇다고 프로덕션 데이터를 그대로 복사하자니 개인정보 보호라는 거대한 벽이 가로막는다.
솔직히 저도 초기 스타트업에서 일할 때 "어차피 내부에서만 쓰니까"라며 프로덕션 덤프를 그대로 스테이징에 올린 적이 있다. 돌이켜보면 아찔한 일이었고, GDPR이나 SOC 2 감사가 일상이 된 지금은 상상도 할 수 없는 일이 됐다. 그래서 두 가지 방법을 직접 써보고 비교했다. 프로덕션 데이터의 형태와 관계는 유지하면서 민감 정보만 안전하게 치환하여 Preview 환경에 제공하는 파이프라인, Neon 마스킹 브랜치와 커스텀 익명화 스크립트다. 이 글을 읽고 나면 30분 안에 PR별 마스킹 DB를 자동 생성하는 파이프라인의 프로토타입을 구축할 수 있을 것이다.
핵심 개념
두 접근법의 핵심 차이를 먼저 한 줄로 정리하면 이렇다: Neon 마스킹 브랜치는 "인프라가 마스킹을 해주는" 방식이고, 커스텀 스크립트는 "내가 마스킹을 하는" 방식이다. 이 차이가 운영 부담, 유연성, 비용 구조 전반에 영향을 미친다.
데이터 마스킹이 왜 필요한가
Preview 환경의 데이터 마스킹이란, 프로덕션 DB의 스키마와 데이터를 개발·스테이징 환경으로 복제하되 PII(개인식별정보)를 안전하게 변환하는 파이프라인을 말한다. 이걸 왜 알아야 하냐면, 단순히 데이터를 지우는 것과는 완전히 다른 문제이기 때문이다. 이메일 컬럼을 단순히 test@test.com으로 일괄 치환해버리면 unique 제약 조건이 깨지고, 사용자 ID와 주문 내역의 조인이 엉뚱한 결과를 내놓게 된다. 외래키 관계와 데이터 타입을 온전히 유지하면서 민감 정보만 치환하는 것이 핵심이다.
참고로, 컴플라이언스 위반의 대가는 결코 가볍지 않다. 2023년 Meta는 GDPR 위반으로 12억 유로(약 1.7조 원)의 벌금을 부과받았고, 규모가 작은 기업이라고 예외는 아니다. 비프로덕션 환경의 데이터 마스킹 여부는 SOC 2 감사에서도 점검 항목에 포함되어 있다.
주요 마스킹 기법 한눈에 보기
| 기법 | 설명 | 적합한 상황 |
|---|---|---|
| Substitution(치환) | 실제 값을 Faker 등으로 생성한 가짜 값으로 대체 | 이름, 이메일 등 텍스트 필드 |
| Pseudonymization(가명화) | 해시 기반 매핑으로 동일 입력 → 동일 출력 보장 | 여러 테이블에 걸친 조인 관계 유지 시 |
| Partial Scrambling(부분 마스킹) | 일부만 가림 (user@email.com → u***@email.com) |
포맷 확인이 필요한 디버깅 |
| Noise Addition(노이즈 추가) | 수치/날짜에 랜덤 편차 추가 | 매출, 나이 등 통계 데이터 |
| Generalization(일반화) | 정밀도를 낮춤 (생년월일 → 출생연도) | 분석용 데이터셋 |
가명화 vs 익명화: 가명화(Pseudonymization)는 매핑 키가 있으면 원본을 복원할 수 있는 반면, 익명화(Anonymization)는 비가역적이다. GDPR에서는 진정한 익명화 데이터만 규제 대상에서 제외되므로, 비프로덕션 환경에서는 비가역적 방식을 쓰는 것이 안전하다.
Neon 마스킹 브랜치: Git처럼 데이터베이스를 브랜칭하다
Neon은 copy-on-write 스토리지 아키텍처를 기반으로 데이터베이스 브랜칭 기능을 제공한다. 2025년 11월부터는 브랜치 생성 시 "Anonymized data" 옵션을 선택할 수 있게 되었는데, 이걸 처음 봤을 때 꽤 혁신적이라고 느꼈다. 프로덕션 브랜치에서 새 브랜치를 따는 순간, 내부적으로 postgresql_anonymizer 확장이 마스킹 규칙을 적용하고, 원본은 전혀 건드리지 않는 완전히 독립된 환경이 만들어진다.
저도 처음엔 "데이터베이스를 브랜치한다"는 개념이 와닿지 않았는데, Git 브랜치를 떠올리면 된다. main에서 feature/login을 따듯이, 프로덕션 DB에서 preview/feature-login을 따는 것이다. copy-on-write 덕분에 변경된 페이지만 추가 저장되어 스토리지 비용도 효율적이다. 실제로 50만 행 규모의 users 테이블 기준으로 Neon 익명화 브랜치 생성은 약 8초 만에 완료됐다.
커스텀 익명화 스크립트: 전통적이지만 강력한 방식
pg_dump(PostgreSQL의 백업 도구) → 변환 스크립트 → pg_restore(복원 도구)로 이어지는 전통적 파이프라인이다. Python이나 Node.js로 직접 작성한 스크립트에서 Faker 라이브러리로 PII를 치환하거나, postgresql_anonymizer 같은 오픈소스 도구를 활용한다. Neon이 아닌 다른 PostgreSQL 호스팅을 쓰고 있거나, MySQL/MongoDB 등 다른 DB를 사용하는 팀에서는 여전히 이 방식이 주력이다. 같은 50만 행 테이블 기준으로 덤프-변환-복원 전체 사이클에 약 12분이 걸렸으니, 속도 차이는 확실히 체감된다.
실전 적용
예시 1: Neon + Vercel — PR마다 자동으로 마스킹된 DB 생성하기
가장 깔끔한 시나리오다. PR을 열면 자동으로 익명화된 DB 브랜치가 생성되고, Vercel Preview 배포에 연결된다.
먼저 Neon Console이나 SQL로 프로덕션 브랜치에 마스킹 규칙을 정의한다. 아래 SECURITY LABEL FOR anon 구문은 Neon 고유 문법이 아니라 postgresql_anonymizer의 표준 구문이다. Neon이 내부적으로 이 확장을 사용하기 때문에 동일한 문법이 적용된다.
-- 프로덕션 브랜치에서 마스킹 규칙 선언 (postgresql_anonymizer 표준 구문)
SECURITY LABEL FOR anon ON COLUMN users.email
IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN users.name
IS 'MASKED WITH FUNCTION anon.fake_first_name()';
SECURITY LABEL FOR anon ON COLUMN users.phone
IS 'MASKED WITH FUNCTION anon.partial(phone, 3, $$***$$, 0)';
SECURITY LABEL FOR anon ON COLUMN orders.shipping_address
IS 'MASKED WITH FUNCTION anon.fake_address()';그다음 GitHub Actions에서 Neon CLI로 익명화 브랜치를 생성한다. PR 브랜치명에 슬래시(/)가 포함될 수 있으므로(feature/login 같은 경우) 대시로 치환하는 처리를 넣어줬다.
# .github/workflows/preview.yml
name: Create Preview with Masked DB
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
create-preview-db:
runs-on: ubuntu-latest
steps:
- name: Install Neon CLI
run: npm i -g neonctl
- name: Create Anonymized Branch
id: create-branch
run: |
SAFE_BRANCH=$(echo "${GITHUB_HEAD_REF}" | sed 's/\//-/g')
BRANCH_NAME="preview-${SAFE_BRANCH}"
neonctl branches create \
--project-id ${{ secrets.NEON_PROJECT_ID }} \
--name "$BRANCH_NAME" \
--parent main \
--api-key ${{ secrets.NEON_API_KEY }}
CONNECTION_STRING=$(neonctl connection-string "$BRANCH_NAME" \
--project-id ${{ secrets.NEON_PROJECT_ID }} \
--api-key ${{ secrets.NEON_API_KEY }})
echo "database_url=$CONNECTION_STRING" >> "$GITHUB_OUTPUT"
- name: Set Vercel Environment Variable
run: |
vercel env add DATABASE_URL preview \
--token ${{ secrets.VERCEL_TOKEN }} \
<<< "${{ steps.create-branch.outputs.database_url }}"PR이 머지되거나 닫히면 브랜치를 정리하는 워크플로우도 추가한다.
# .github/workflows/cleanup-preview.yml
name: Cleanup Preview DB
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete Anonymized Branch
run: |
npm i -g neonctl
SAFE_BRANCH=$(echo "${GITHUB_HEAD_REF}" | sed 's/\//-/g')
neonctl branches delete "preview-${SAFE_BRANCH}" \
--project-id ${{ secrets.NEON_PROJECT_ID }} \
--api-key ${{ secrets.NEON_API_KEY }}이 구성의 가장 큰 장점은 각 PR이 완전히 격리된 DB를 가진다는 것이다. 동료가 테스트하다 데이터를 망가뜨려도 내 Preview 환경에는 영향이 없고, 스키마 마이그레이션을 테스트할 때도 버전 충돌이 일어나지 않는다. 비용 측면에서는 Neon의 과금 모델이 컴퓨트 시간 + 스토리지 기반이라, copy-on-write로 변경분만 추가 과금되어 브랜치 10개를 띄워도 스토리지 비용은 원본의 1.1배 수준에 그쳤다.
한 줄 평가: Neon + PostgreSQL 조합이라면 이 방식이 압도적으로 편리하다. 설정 30분이면 PR별 마스킹 DB 자동화가 완성된다.
예시 2: GitHub Actions + 커스텀 스크립트로 파이프라인 구축하기
Neon을 사용하지 않는 팀이라면 직접 파이프라인을 구축해야 한다. 아래는 Python + Faker를 활용한 실전 예시인데, 초안에서 몇 가지 문제가 있었던 부분을 개선했다.
주의할 점이 세 가지 있다. 첫째, 테이블·컬럼명을 f-string으로 직접 SQL에 삽입하면 SQL 인젝션 위험이 있으므로 psycopg2.sql 모듈의 Identifier를 사용한다. 둘째, DB 접속 정보는 반드시 환경변수에서 읽는다. 셋째, row-by-row UPDATE는 대량 데이터에서 치명적으로 느리므로(100만 행 기준 수 시간 소요 가능), 이 점을 인지하고 있어야 한다.
# scripts/anonymize.py
import os
import subprocess
import psycopg2
from psycopg2 import sql
from faker import Faker
import hashlib
fake = Faker('ko_KR')
MASKING_RULES = {
'users': {
'email': lambda val: fake.email(),
'name': lambda val: fake.name(),
'phone': lambda val: fake.phone_number(),
'address': lambda val: fake.address(),
},
'orders': {
'shipping_address': lambda val: fake.address(),
'recipient_name': lambda val: fake.name(),
},
'payments': {
'card_last_four': lambda val: fake.credit_card_number()[-4:],
},
}
PSEUDONYMIZE_COLUMNS = {
('users', 'external_id'): lambda val: hashlib.sha256(
f"salt-2026-{val}".encode()
).hexdigest()[:16],
}
def anonymize_table(conn, table: str, columns: dict):
"""row-by-row 방식 — 10만 행 이하 테이블에 적합"""
cur = conn.cursor()
table_id = sql.Identifier(table)
for col, transform in columns.items():
col_id = sql.Identifier(col)
query = sql.SQL("SELECT id, {} FROM {}").format(col_id, table_id)
cur.execute(query)
rows = cur.fetchall()
update_query = sql.SQL("UPDATE {} SET {} = %s WHERE id = %s").format(
table_id, col_id
)
for row_id, original_value in rows:
if original_value is None:
continue
masked = transform(original_value)
cur.execute(update_query, (masked, row_id))
conn.commit()
cur.close()
def main():
prod_url = os.environ['PROD_DATABASE_URL']
staging_url = os.environ['STAGING_DATABASE_URL']
subprocess.run([
'pg_dump', '--no-owner', '--no-privileges',
'-Fc', '-f', '/tmp/prod_dump.sql',
prod_url
], check=True)
subprocess.run([
'pg_restore', '--clean', '--if-exists',
'--no-owner', '-d', staging_url,
'/tmp/prod_dump.sql'
], check=True)
conn = psycopg2.connect(staging_url)
for table, columns in MASKING_RULES.items():
print(f"Anonymizing {table}...")
anonymize_table(conn, table, columns)
for (table, col), transform in PSEUDONYMIZE_COLUMNS.items():
print(f"Pseudonymizing {table}.{col}...")
anonymize_table(conn, table, {col: transform})
conn.close()
print("Anonymization complete.")
if __name__ == '__main__':
main()성능 경고: 위 코드는 row-by-row UPDATE 방식으로, 100만 행 테이블에서 수 시간이 걸릴 수 있다. 대규모 테이블에서는 batch UPDATE(
WHERE id BETWEEN ... AND ...로 범위 지정), 또는 COPY 기반 접근(덤프 파일을 스트리밍하면서 변환 후 COPY로 적재)이 훨씬 효율적이다.
Faker unique 제한:
fake.unique.email()은 대량 데이터에서UniquenessException을 발생시킬 수 있다. unique가 필요한 경우fake.unique.clear()를 주기적으로 호출하거나, 해시 기반 가명화로 고유성을 보장하는 방식이 더 안전하다. 위 예시에서는 이 문제를 피하기 위해 일반fake.email()을 사용했다.
이걸 GitHub Actions에서 실행한다.
# .github/workflows/sync-staging-data.yml
name: Sync Masked Production Data
on:
schedule:
- cron: '0 3 * * 1' # 매주 월요일 새벽 3시
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install psycopg2-binary faker
- name: Run anonymization
env:
PROD_DATABASE_URL: ${{ secrets.PROD_READONLY_DATABASE_URL }}
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
run: python scripts/anonymize.py이 방식은 마스킹 로직을 완전히 통제할 수 있다는 점에서 강력하지만, 솔직히 유지보수가 만만치 않다. 새 테이블이 추가되거나 컬럼명이 바뀔 때마다 스크립트를 업데이트해야 하고, 까먹으면 PII가 그대로 넘어간다. 저도 한번은 user_profiles라는 새 테이블을 마스킹 규칙에 추가하는 걸 깜빡해서, 코드 리뷰에서 겨우 잡힌 적이 있다. 비용 측면에서는 CI 러너 시간(GitHub Actions 무료 티어 월 2,000분)과 스테이징 DB 인스턴스 비용(RDS db.t3.micro 기준 월 ~$15)이 발생한다.
한 줄 평가: 자유도는 최고지만, 운영 부담과 성능 한계를 감안하면 중소규모 테이블에 적합하다.
postgresql_anonymizer로 개선하기
커스텀 스크립트의 유지보수 부담을 줄이고 싶다면, postgresql_anonymizer를 활용해 SQL DDL 수준에서 마스킹 규칙을 선언하는 방법이 있다. 이러면 스키마와 마스킹 규칙이 함께 버전 관리되는 깔끔한 구조가 된다.
-- 확장 설치 및 초기화
CREATE EXTENSION IF NOT EXISTS anon CASCADE;
SELECT anon.init();
-- 마스킹 규칙 선언 (마이그레이션 파일에 포함 가능)
SECURITY LABEL FOR anon ON COLUMN users.email
IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN users.name
IS 'MASKED WITH FUNCTION anon.fake_first_name() || '' '' || anon.fake_last_name()';
SECURITY LABEL FOR anon ON COLUMN users.phone
IS 'MASKED WITH FUNCTION anon.partial(phone, 3, $$***$$, 0)';
SECURITY LABEL FOR anon ON COLUMN users.birth_date
IS 'MASKED WITH FUNCTION anon.generalize_daterange(birth_date, ''year'')';
SECURITY LABEL FOR anon ON COLUMN payments.card_number
IS 'MASKED WITH FUNCTION anon.random_string(16)';
-- 익명화된 덤프 생성 (CLI에서 실행)
-- pg_dump_anon --host=prod-host --dbname=myapp > anonymized_dump.sqlPython 스크립트 대비 장점은 명확하다. 마스킹 규칙이 SQL 마이그레이션 파일 안에 살아 있으니, 스키마 변경과 마스킹 규칙 업데이트가 자연스럽게 같은 PR에서 이루어진다. pg_dump_anon 명령 하나로 익명화된 덤프가 바로 나오니 별도 스크립트를 유지할 필요도 없다.
postgresql_anonymizer 2.0 (2025년 1월): Rust(PGRX 프레임워크)로 완전히 재작성되어 성능이 크게 향상되었다. 특히 대용량 테이블에서의 마스킹 속도가 이전 버전 대비 눈에 띄게 빨라졌으니, 아직 구버전을 쓰고 있다면 업그레이드를 고려해볼 만하다.
실무에서 가장 흔한 실수
두 달 정도 양쪽을 병행하면서 겪었거나 주변에서 목격한, 가장 빈번한 실수 세 가지를 정리해봤다. 이 부분이 사실 이 글에서 가장 실무적으로 가치 있는 내용이라고 생각한다.
-
새 테이블/컬럼 추가 시 마스킹 규칙 업데이트를 잊는 것 — 스키마 마이그레이션과 마스킹 규칙을 같은 PR에서 관리하지 않으면 반드시 한 번은 빠뜨리게 된다. CI에서 "마스킹 규칙이 정의되지 않은 PII 후보 컬럼"을 탐지하는 자동 체크를 넣어두면 사고를 미리 막을 수 있다. postgresql_anonymizer의 DDL 방식을 쓰면 이 문제가 자연스럽게 완화된다.
-
간접 식별자를 간과하는 것 — 이름·이메일 같은 직접 식별자는 누구나 마스킹하지만, IP 주소, 디바이스 핑거프린트, 행동 시퀀스(특정 시간에 특정 순서로 조회한 로그) 같은 간접 식별자는 놓치기 쉽다. 이들의 조합만으로도 개인을 특정할 수 있다는 점을 꼭 기억해두면 좋다.
-
Pseudonymization 없이 단순 랜덤 치환만 적용하는 것 —
users테이블의 이메일은fake1@test.com으로 바꾸고audit_logs의 이메일은fake2@test.com으로 바뀌면, 같은 사용자의 활동을 추적하는 기능 테스트가 불가능해진다. 여러 테이블에 걸친 동일 값은 반드시 일관된 매핑을 적용하는 것이 좋다.
Referential Integrity(참조 무결성): 외래키로 연결된 테이블 간의 데이터 일관성을 말한다. 예를 들어
orders.user_id가users.id를 참조할 때, 마스킹 과정에서users.id가 바뀌면 주문 데이터가 미아가 된다. Neon 브랜치는 이를 자동으로 보존하지만, 커스텀 스크립트에서는 직접 신경 써야 한다.
장단점 분석
두 달 정도 양쪽을 병행해본 입장에서 정리하면 이렇다.
종합 비교
| 항목 | Neon 마스킹 브랜치 | 커스텀 익명화 스크립트 |
|---|---|---|
| 초기 설정 시간 | 30분 내외 | 반나절~하루 |
| 운영 부담 | 극히 낮음 (브랜치 생성 한 번이면 끝) | 높음 (스키마 변경마다 스크립트 업데이트) |
| 데이터 신선도 | 항상 최신 (프로덕션에서 바로 브랜치) | 동기화 주기에 의존 (보통 일/주 단위) |
| 50만 행 처리 시간 | ~8초 | ~12분 (row-by-row 기준) |
| PR별 격리 | 네이티브 지원 | 추가 인프라 필요 |
| DB 호환성 | PostgreSQL(Neon) 전용 | 어떤 DB든 가능 |
| 커스터마이징 | postgresql_anonymizer 함수 범위 내 | 무제한 (도메인 특화 로직 자유) |
| 벤더 종속 | Neon에 의존 | 없음 |
| 월 비용 (소규모) | Neon Free 티어 + 브랜치당 스토리지 소량 | CI 러너 시간 + 스테이징 DB (~$15/월) |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Neon 벤더 종속 | Neon 서비스에 의존하게 됨 | 마스킹 규칙을 표준 SQL(SECURITY LABEL)로 정의하면 이식성 확보 |
| Neon 마스킹 옵션 제한 | 풀 postgresql_anonymizer 대비 기능이 제한적 | 2026년 1월 업데이트로 API/SQL 커스텀 규칙 정의가 가능해짐 |
| 커스텀 스크립트 유지보수 | 스키마 변경 시 스크립트 업데이트 누락 → PII 유출 위험 | CI에서 컬럼 목록과 마스킹 규칙의 커버리지를 자동 검증하는 체크 추가 |
| 커스텀 스크립트 처리 시간 | 대규모 DB에서 덤프-변환-복원이 수십 분~수 시간 소요 | 서브셋 추출 또는 증분 동기화, batch UPDATE 도입 |
| 커스텀 스크립트 외래키 깨짐 | 일관된 해시 처리를 수동으로 보장해야 함 | Pseudonymization 기법 적용 (동일 입력 → 동일 출력) |
마치며
프로덕션 데이터를 안전하게 Preview 환경에 제공하는 것은 컴플라이언스와 개발 생산성 모두를 위한 필수 인프라다. 결론을 명쾌하게 말하면, PostgreSQL + Neon 조합이라면 마스킹 브랜치로 시작하는 것이 압도적으로 효율적이고, 멀티DB 환경이거나 Neon이 아닌 호스팅을 쓴다면 커스텀 스크립트가 유일한 선택지다. 두 방식 모두 결국은 "마스킹 규칙을 코드로 관리하고, CI/CD에서 자동으로 실행한다"는 같은 원칙에 도달한다.
지금 바로 시작해볼 수 있는 3단계:
- 현재 프로덕션 DB에서 PII 컬럼을 식별하고 목록화하기 — 아래 쿼리로 PII 후보 컬럼을 빠르게 찾을 수 있다.
SELECT table_name, column_name
FROM information_schema.columns
WHERE column_name ~* '(email|phone|name|address|birth|ssn|ip_addr|device)'
AND table_schema = 'public'
ORDER BY table_name, column_name;-
작은 테이블 하나로 프로토타입 만들어보기 — Neon을 쓰고 있다면
SECURITY LABEL FOR anon으로users테이블에 마스킹 규칙을 걸고 익명화 브랜치를 생성해볼 수 있다. Neon이 아니라면postgresql_anonymizer를 로컬에 설치하거나, Python + Faker로 위 예시 코드를users테이블 하나에 먼저 적용해보는 것도 좋다. -
CI/CD에 통합하고 커버리지 체크 추가하기 — 프로토타입이 잘 동작하면 GitHub Actions 워크플로우로 옮기고, 새 PII 컬럼이 추가될 때 마스킹 규칙 누락을 탐지하는 자동 체크를 넣어두면 안심하고 운영할 수 있는 파이프라인이 완성된다.
다음 글: 마스킹된 Preview 데이터베이스 위에서 E2E 테스트를 돌리는 방법 — Playwright + 마스킹 DB로 실제 사용자 시나리오를 재현하는 테스트 파이프라인 구축기
참고 자료
- Create Environments with Masked Production Data Using Neon Branches | Neon Blog
- Branching With or Without PII: The Future of Environments | Neon Blog
- How to Handle PII in Staging Databases Without Losing Realistic Data | Neon Blog
- Data Anonymization | Neon Docs
- Data Anonymization API Reference | Neon Docs
- The anon Extension | Neon Docs
- A Database for Every Preview Environment Using Neon, GitHub Actions, and Vercel | Neon Blog
- Automate Branching with GitHub Actions | Neon Docs
- PostgreSQL Anonymizer Documentation
- PostgreSQL Anonymizer 2.0: Better, Faster, Safer | PostgreSQL News
- Masking Functions | PostgreSQL Anonymizer
- Data Masking Best Practices: In-Place vs In-Flight | Synthesized
- What is Data Masking? | AWS
- Neon vs Supabase vs Xata: Postgres Branching Compared | Xata Blog