4.5 Schema-only Branch와 민감 데이터
production 데이터를 그대로 복사한 branch는 개발과 테스트에 편리합니다. 하지만 그 안에 고객의 이름, 연락처, 결제 정보가 들어 있다는 사실은 변하지 않습니다. 많은 조직에서는 규정상 그 데이터를 개발자 노트북과 CI 러너로 전송할 수 없습니다. schema-only branch는 이 문제에 대한 Neon의 답입니다. 테이블, index, 제약, 함수는 그대로 가져오되 행은 하나도 가져오지 않습니다.
일반 branch와 다른 점
일반 branch는 부모 timeline의 특정 LSN을 ancestor로 참조하는 자식 timeline입니다. 데이터를 복사하지 않고 부모의 layer를 읽습니다. schema-only branch는 이 방식으로 만들 수 없습니다. 부모의 page에는 행 데이터가 들어 있으므로 page를 공유하면 데이터도 따라옵니다.
그래서 schema-only branch는 부모가 없는 독립 root branch로 만듭니다. 원본 branch의 schema를 덤프한 뒤 새 timeline에 적용하는 방식입니다. 이 구조에는 몇 가지 차이가 있습니다.
| 항목 | 일반 branch | schema-only branch |
|---|---|---|
| 부모 관계 | 있음 | 없음 (root) |
| 데이터 | 부모와 공유 | 없음 |
| reset from parent | 가능 | 불가 |
| 한도 | branch 수 | root branch 수 |
| 크기 상한 | 플랜 storage 정책 | 유료 플랜은 branch별 상한, Free는 프로젝트 공유 |
root branch 한도는 Free 3개, Launch 5개, Scale 25개입니다. 프로젝트의 첫 root branch(main 또는 production)가 하나를 차지합니다. 따라서 Free 플랜에서는 schema-only branch가 최대 두 개까지 만들 수 있습니다.
크기 상한의 적용 방식은 플랜마다 다릅니다. Launch는 schema-only branch 하나당 3 GB, Scale은 20 GB입니다. Free의 0.5 GB는 branch마다 주어지는 값이 아닙니다. 프로젝트의 모든 branch가 나눠 쓰는 storage 한도입니다. 따라서 Free에서는 production 데이터가 차지한 용량을 뺀 나머지를 schema-only branch에 사용할 수 있습니다.
생성
neon branches create --name dev/schema-only --parent production --schema-only
--parent는 schema를 가져올 원본을 지정합니다. 생성된 branch의 parent_id는 비어 있습니다. API에서는 init_source를 사용합니다.
curl -s -X POST "https://console.neon.tech/api/v2/projects/$PROJECT_ID/branches" \
-H "Authorization: Bearer $NEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoints": [{"type": "read_write"}],
"branch": {
"parent_id": "'"$PRODUCTION_BRANCH_ID"'",
"name": "dev/schema-only",
"init_source": "schema-only"
}
}'
Console에서 만들면 자동 삭제 기본값이 1일로 설정됩니다. 오래 사용할 branch라면 만료 설정을 해제하거나 기간을 늘립니다.
생성한 뒤 확인합니다.
psql "$(neon connection-string dev/schema-only --database-name app)" <<'SQL'
\dt
SELECT count(*) FROM customers;
SQL
테이블 목록은 production과 같고 행 수는 0입니다.
데이터 채우기
빈 schema에 테스트용 데이터를 넣는 방법은 네 가지입니다.
첫째, 합성 데이터를 생성합니다. generate_series와 난수 함수로 유사한 분포를 만듭니다. 개인정보가 처음부터 없으므로 규제 검토가 가장 단순합니다.
-- id 를 직접 넣어야 orders 의 customer_id 를 계산할 수 있는 예시입니다.
INSERT INTO customers (id, name, email, created_at)
SELECT g,
'customer_' || g,
'customer_' || g || '@example.com',
now() - (random() * interval '730 days')
FROM generate_series(1, 100000) AS g;
-- id 는 sequence 에 맡깁니다.
INSERT INTO orders (customer_id, amount, status, created_at)
SELECT (random() * 99999 + 1)::int,
round((random() * 500)::numeric, 2),
(ARRAY['paid', 'shipped', 'refunded'])[1 + (random() * 2)::int],
now() - (random() * interval '365 days')
FROM generate_series(1, 1000000);
-- 직접 넣은 id 때문에 뒤처진 sequence 를 현재 최대값으로 맞춥니다.
SELECT setval(pg_get_serial_sequence('customers', 'id'), max(id)) FROM customers;
ANALYZE customers;
ANALYZE orders;
마지막에 ANALYZE를 실행해 planner 통계를 생성합니다. 통계 없이 실행 계획을 비교하면 production과 다른 계획이 나옵니다.
setval을 생략하면 sequence가 1에 머뭅니다. 그러면 application의 첫 INSERT가 중복 키 오류로 실패합니다. schema만 복제한 branch에는 sequence 값이 따라오지 않습니다. 따라서 명시적 id로 데이터를 넣었다면 이 명령이 필요합니다. 컬럼이 GENERATED ALWAYS AS IDENTITY로 선언되어 있으면 값을 직접 넣는 INSERT가 거부됩니다. 이 경우 OVERRIDING SYSTEM VALUE를 붙입니다. 또는 id 열을 빼고 넣은 뒤 RETURNING으로 받은 값을 참조에 사용합니다.
둘째, production에서 익명화한 덤프를 넣습니다. 실제 분포를 유지하면서 식별 정보만 바꿉니다. 익명화 도구로 처리한 덤프를 schema-only branch에 복원합니다.
pg_dump "$PROD_URL" --data-only --table=customers --table=orders \
| anonymizer --config rules.yaml \
| psql "$(neon connection-string dev/schema-only --database-name app)"
여기서 anonymizer는 팀이 선택한 도구를 나타냅니다. PostgreSQL 확장으로 동작하는 도구도 있고 덤프 스트림을 변환하는 도구도 있습니다. 어느 방식을 선택하든 익명화 규칙이 모든 민감 컬럼을 다루는지 검토해야 합니다. 컬럼 하나를 누락하면 branch가 다시 민감 데이터를 보유하게 됩니다.
셋째, application의 seed 스크립트를 실행합니다. 테스트 코드가 기대하는 고정 데이터를 넣을 때 사용합니다. 크기가 작아 성능 검증에는 적합하지 않지만 기능 테스트에는 충분합니다.
넷째, Neon이 제공하는 익명화 branch API를 사용합니다. 일반 branch 생성 endpoint의 init_source에는 익명화 값이 없습니다. 대신 POST /projects/{project_id}/branch_anonymized가 별도로 있습니다. masking rule로 가릴 컬럼과 처리 방법을 지정합니다. create-branch-action에도 같은 규칙을 전달하는 masking_rules 입력이 있습니다. 규칙 형식과 지원 범위는 API 레퍼런스가 정본이므로 도입 전에 해당 페이지를 확인합니다. 이 방식에서는 Neon 안에서 익명화를 수행합니다. 따라서 원본 데이터가 개발자 환경을 거치지 않아 둘째 방법보다 신뢰 경계가 단순합니다.
개발 흐름에 넣기
schema-only branch 하나를 "정제된 기준 branch"로 두고 개발자와 CI가 그 branch에서 일반 branch를 만드는 구성을 자주 사용합니다.
dev/base는 root branch이므로 production의 변경 사항을 reset으로 반영할 수 없습니다. production schema가 바뀌면 같은 migration을 dev/base에도 적용합니다. 또는 dev/base를 새로 만든 뒤 데이터를 다시 채웁니다. 갱신 주기를 정하지 않으면 개발 branch의 schema가 production과 조금씩 어긋납니다. neon branches schema-diff production dev/base를 주기적으로 실행해 차이를 감시합니다.
dev/alice처럼 dev/base에서 만든 자식 branch는 일반 branch입니다. 따라서 reset --parent로 dev/base 상태로 되돌립니다.
규제 관점에서 확인할 것
개인정보 관련 규정은 대체로 데이터를 목적 밖으로 복제하는 행위 자체를 제한합니다. schema-only branch에는 복제되는 데이터가 없어 검토가 단순합니다. 하지만 다음 사항은 별도로 확인해야 합니다.
- 익명화 덤프를 쓰는 경우 익명화 전 데이터가 어디를 거치는지. 개발자 노트북에서
pg_dump를 실행하면 원본이 그 노트북을 거칩니다. 익명화는 production과 같은 신뢰 경계 안에서 끝내고 결과만 외부로 내보냅니다. - schema 자체에 민감 정보가 있는지. 컬럼 주석, 기본값,
CHECK제약의 상수, 함수 본문에 실제 값이 들어 있는 경우가 있습니다. - Neon Auth를 쓰는 프로젝트의
neon_authschema. 사용자와 세션 정보가 데이터로 들어 있으므로 schema-only branch에는 따라오지 않지만, 일반 branch에는 따라옵니다.
이 노트는 특정 규정의 해석을 제공하지 않습니다. 조직의 개인정보 담당자에게 확인합니다.
흔한 실패
--schema-only로 만들었는데 root branch 한도 오류가 발생하면 기존 root branch를 확인합니다. 이전에 만든 schema-only branch가 남아 있는 경우가 많습니다.
neon branches list --output json | jq -r '.[] | select(.parent_id == null) | .name'
schema-only branch에 데이터를 채운 뒤 크기 상한을 넘으면 쓰기 작업이 거부됩니다. 크기에는 index도 포함됩니다. 따라서 합성 데이터가 100만 행 정도일 때 상한에 도달하기도 합니다. Free 플랜에서는 이 0.5 GB를 production branch와 나눠 쓰므로 여유가 더 적습니다.
연습 문제
- schema-only branch를 만들고
\d+출력이 원본과 같은지, 시퀀스의 현재 값은 어떻게 되는지 확인합니다. - 위 합성 데이터 SQL을 실행하고
pg_total_relation_size로 크기를 확인합니다. - production에 컬럼을 하나 추가한 뒤
schema-diff로 schema-only branch와의 차이가 감지되는지 확인합니다.