탈퇴 뒤 같은 이메일 재가입은 살아 있는 행만 부분 유니크로 막는다. 테이블 유니크는 죽은 행까지 잡고, 이메일과 삭제 시각 조합은 널을 서로 다른 값으로 본다. 포스트그레, 드리즐, 프리즈마, 슈퍼베이스, 백엔드, API, Next.js, 개발자 기준으로 유니크 칸만 보고 페이지네이션, 타임존 글과 자리를 섞지 않는다. 2026년 8월 공식 문서.
탈퇴한 이메일이 다시 가입을 누르면, 유니크가 산 행과 죽은 행을 같이 막습니다. 기본 유니크는 탈퇴 뒤에도 같은 주소를 거절합니다. 부분 유니크는 삭제 시각이 비어 있는 행만 묶고, 널을 같은 값으로 보는 옵션은 빈 칸이 하나여야 할 때 씁니다. 저는 파트너십 계정에서 탈퇴 뒤 재가입이 23505로 막힌 날을 직접 봤다. 목록이 느린 칸은 페이지네이션 글, 시각 저장은 타임스탬프 글입니다. 여기는 살아 있는 행만 유니크인 칸만 봅니다.
숫자는 2026년 8월 포스트그레 18 유니크, 부분 인덱스 문서와 드리즐, 프리즈마 인덱스 문서 기준입니다.
이메일 유니크만 걸고 탈퇴하면 재가입이 막히는 자리
행을 지우지 않고 삭제 시각만 넣으면, 테이블 유니크는 그 이메일을 계속 차지합니다. 같은 주소로 새 행을 넣을 수 없습니다.
관리 화면에서 회원을 숨기려고 deleted_at만 채운 적이 있습니다. 로그인 쿼리는 널인 행만 봤고, 화면은 비었습니다. 같은 사람이 다시 가입을 누르면 앱은 새 행을 넣으려 하고, 포스트그레는 유니크 위반 23505를 냅니다. 문서도 유니크는 테이블 전체 행을 본다고 적습니다. 일부 행만 묶는 제한은 유니크 제약으로 못 쓰고, 부분 유니크 인덱스로 겁니다.
저는 12개 사이트 파트너십 계정에서 이 순서를 밟았습니다. 문의 메일이 키인데, 탈퇴한 주소가 인덱스에 남아 있으면 재신청이 막힙니다. ORM 고르는 칸은 드리즐 글입니다. 행 권한은 슈퍼베이스 알엘에스 글입니다. 오늘은 살아 있는 이메일만 겹치지 않게 하는 칸입니다.
칸
막는 범위
탈퇴 뒤 같은 이메일
잘 맞는 자리
테이블 유니크
모든 행
거절. 23505
진짜로 지우는 회원
유니크 (이메일, 삭제시각)
두 칸 조합
산 행이 둘 생길 수 있음. 널은 서로 다름
소프트 삭제에 쓰지 않음
부분 유니크
조건에 맞는 행만
산 행만 거절. 죽은 행은 비움
탈퇴 뒤 재가입, 숨긴 슬러그
널 동일 유니크
널도 같은 값
빈 칸이 두 줄이면 거절
기본 테넌트처럼 빈 값이 하나여야 할 때
죽은 튜플이 쌓여 느려진 날과 헷갈리면 오토배큠 글입니다. 소프트 삭제는 행을 남겨 두므로 그 칸과 겹칩니다. 여기는 유니크 조건만 봅니다.
조합 유니크는 소프트 삭제를 못 막습니다 | 이메일과 삭제 시각을 같이 유니크로 두면, 산 행의 삭제 시각이 둘 다 널입니다. 기본은 널을 서로 다른 값으로 봐서, 같은 이메일의 산 행이 두 줄 들어갑니다.
삭제 시각이 비어 있는 행만 부분 유니크로 묶는 순서
조건에 맞는 행만 유니크에 넣습니다. 삭제 시각이 널인 이메일만 겹치지 않으면, 탈퇴한 주소는 다시 쓸 수 있습니다.
포스트그레 18 부분 인덱스 문서 예 11.3이 이 패턴입니다. 성공한 시험만 과목과 대상이 하나이게 묶고, 실패한 줄은 여러 개 둡니다. 소프트 삭제는 성공을 산 행으로 바꾸면 같습니다. 조건은 deleted_at IS NULL입니다. = NULL은 한 줄도 안 맞습니다.
인덱스는 비트리만 유니크를 선언할 수 있습니다. 유니크 제약이나 기본 키를 만들면 같은 비트리가 자동으로 생깁니다. 일부 행만 묶을 때는 제약을 쓰지 않고, 부분 유니크 인덱스를 직접 만듭니다. 문서가 그 문장을 제약 절에 따로 적습니다.
살아 있는 이메일만 유니크. 탈퇴 행은 인덱스에 안 들어간다
-- https://www.postgresql.org/docs/current/indexes-partial.html
-- 예 11.3과 같은 자리. 조건에 맞는 행만 유니크.
CREATE TABLE users (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL,
deleted_at timestamptz
);
CREATE UNIQUE INDEX users_email_live_key
ON users (email)
WHERE deleted_at IS NULL;
-- 산 행 하나
INSERT INTO users (email) VALUES ('lee@example.com');
-- 탈퇴. 인덱스가 이 이메일을 놓아 준다
UPDATE users
SET deleted_at = now()
WHERE email = 'lee@example.com'
AND deleted_at IS NULL;
-- 같은 주소로 다시 가입. 통과
INSERT INTO users (email) VALUES ('lee@example.com');
삭제 시각이 널인 행만 인덱스에 들어간다. 탈퇴한 주소는 다시 가입할 수 있다
조건은 쿼리와 같게 적습니다 | 로그인과 가입은 deleted_at IS NULL을 같이 겁니다. 문서가 부분 인덱스는 쿼리 조건이 인덱스 조건과 같거나 더 좁을 때만 탄다고 적습니다. 앱만 필터하고 인덱스는 전체면, 유니크가 죽은 행까지 막힌다.
널을 같은 값으로 볼 때와 부분 인덱스를 쓸 때
널을 같은 값으로 보는 옵션은 빈 칸이 테이블에 하나여야 할 때 씁니다. 선택 전화번호처럼 빈 칸이 여러 개여야 하면 부분 유니크가 맞습니다.
기본 유니크는 널을 서로 다른 값으로 봅니다. 같은 열이 널인 행이 여러 줄 들어갑니다. 포스트그레 15부터 NULLS NOT DISTINCT를 붙이면 널도 같은 값이라, 빈 칸은 한 줄만 남습니다. 슈퍼베이스와 네이버 클라우드 알디에스 15 이상에서 됩니다. 18 문서 제약 절과 유니크 인덱스 절이 같은 설명을 합니다.
선택 휴대폰 번호는 반대입니다. 번호가 있는 행만 겹치면 안 되고, 번호 없는 회원은 여러 명이어야 합니다. 그때는 WHERE phone IS NOT NULL 부분 유니크입니다. 널 동일 옵션을 넣으면 번호 없는 회원이 두 번째부터 거절됩니다.
원하는 규칙
구문
빈 칸 여러 줄
산 이메일만 하나
UNIQUE (email) WHERE deleted_at IS NULL
죽은 행은 인덱스 밖
번호가 있을 때만 하나
UNIQUE (phone) WHERE phone IS NOT NULL
허용. 번호 없는 회원
빈 테넌트가 하나
UNIQUE NULLS NOT DISTINCT (tenant_id)
거절. 널도 중복
선택 번호는 값이 있는 행만 묶고, 빈 테넌트는 널도 하나로 본다
-- 선택 휴대폰. 빈 칸은 여러 명
CREATE UNIQUE INDEX users_phone_key
ON users (phone)
WHERE phone IS NOT NULL;
-- 빈 테넌트 아이디는 테이블에 하나. 포스트그레 15+
-- https://www.postgresql.org/docs/current/ddl-constraints.html
CREATE TABLE workspace_defaults (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id bigint,
UNIQUE NULLS NOT DISTINCT (tenant_id)
);
드리즐은 웨어를 바로 적고, 프리즈마는 미리보기 플래그가 필요하다
드리즐은 유니크 인덱스에 웨어를 붙입니다. 프리즈마는 2026년 2월 7.4부터 부분 인덱스가 미리보기이고, 스키마에 웨어를 적으려면 플래그를 켭니다.
드리즐 인덱스 문서는 uniqueIndex().on().where(sql`...`)를 적습니다. 널 동일은 컬럼 unique 두 번째 인자에 { nulls: "not distinct" }, 또는 unique().on().nullsNotDistinct()입니다. 소프트 삭제 이메일은 웨어가 있는 유니크 인덱스입니다.
프리즈마 인덱스 문서는 partialIndexes 미리보기를 켠 뒤에 @@unique([email], where: { deletedAt: null }) 또는 where: raw("...")를 씁니다. 포스트그레, 에스큐라이트, 에스큐엘 서버, 코크로치에서 됩니다. 마이에스큐엘은 부분 인덱스가 없어서 안 됩니다. 7.4 마이그레이션이 부분 인덱스를 잘못 잡는 이슈가 2026년 2월에 열려 있으니, 생성 에스큐엘을 한 번 열어 봅니다.
무중단으로 인덱스를 붙이는 순서는 마이그레이션 체크리스트입니다. CREATE UNIQUE INDEX CONCURRENTLY는 트랜잭션 안에 못 넣습니다. 드리즐 킷이 마이그레이션을 트랜잭션으로 감싸면, 그 파일은 수동 에스큐엘로 빼 둡니다.
산 행만 막으려면 부분 유니크. 빈 칸이 하나여야 하면 널 동일. 테이블 유니크는 탈퇴 행까지 막는다
23505가 나면 앱이 먼저 볼 코드와 복구 순서
유니크 위반 에스큐엘스테이트는 23505입니다. 가입 화면은 그 코드를 이메일 중복 문구로 바꿉니다. 복구는 죽은 행을 되살리는 일이라, 산 행이 이미 있으면 또 막힙니다.
노드 pg는 error.code === '23505'입니다. 제약 이름은 constraint 칸에 들어옵니다. 부분 인덱스 이름을 users_email_live_key로 두면, 휴대폰 유니크와 문구를 나눌 수 있습니다. 웹훅이 같은 키를 두 번 넣는 칸은 멱등 글입니다. 여기는 회원 이메일입니다.
탈퇴를 되돌릴 때는 산 행이 같은 이메일을 쓰는지 먼저 봅니다. 있으면 되돌리기가 23505입니다. 그때는 새 이메일을 받거나, 산 행을 합칩니다. 하드 삭제는 유니크를 자동으로 비웁니다. 주문 아이디를 남겨야 하면 소프트, 로그와 메일만 남기고 지워도 되면 하드입니다. 개인정보 보관 기간은 약관 칸이라 여기서 단정하지 않습니다.
가입은 23505를 이메일 중복으로 바꾸고, 복구 전에 산 행을 본다
-- 복구 전. 같은 이메일의 산 행이 있으면 되돌리기 금지
SELECT id
FROM users
WHERE email = 'lee@example.com'
AND deleted_at IS NULL;
-- 산 행이 없을 때만
UPDATE users
SET deleted_at = NULL
WHERE id = 42
AND deleted_at IS NOT NULL;
-- 앱 (pg)
-- if (err.code === '23505' && err.constraint === 'users_email_live_key')
-- return { field: 'email', message: '이미 가입된 이메일입니다' }
대소문자와 공백을 그대로 두면 구멍이 납니다 | 유니크는 문자열을 그대로 비교합니다. Lee@example.com과 lee@example.com은 다른 값입니다. 가입 전에 소문자와 앞뒤 공백을 접거나, lower(email) 식 인덱스를 겁니다.
제약 이름으로 이메일과 휴대폰 문구를 나눈다. 복구는 산 행이 없을 때만 한다
이미 쌓인 테이블에 부분 유니크를 붙일 때
중복이 있는 채로 유니크를 만들면 생성이 실패합니다. 산 행의 같은 이메일을 먼저 찾고, 인덱스는 동시 생성으로 붙입니다.
기존 회원에 같은 이메일의 산 행이 둘이면, 부분 유니크는 바로 거절합니다. 아래 조회가 0줄이어야 합니다. 남은 줄은 수동으로 한 줄만 남깁니다. 아웃박스로 메일을 보내는 칸은 아웃박스 글입니다.
동시 생성은 쓰기를 막지 않습니다. 트랜잭션 블록 안에서는 못 씁니다. 실패하면 잘못된 인덱스가 남으니 DROP INDEX CONCURRENTLY로 지우고 다시 만듭니다. 네이버 클라우드 알디에스에서 문의 테이블이 커진 뒤에 붙일 때, 저는 조회를 먼저 돌리고 새벽에 동시 생성을 넣었습니다.
산 행 중복을 먼저 찾고, 트랜잭션 밖에서 동시 생성한다
-- 생성이 막히는 줄
SELECT email, count(*)
FROM users
WHERE deleted_at IS NULL
GROUP BY email
HAVING count(*) > 1;
-- 트랜잭션 밖에서
CREATE UNIQUE INDEX CONCURRENTLY users_email_live_key
ON users (email)
WHERE deleted_at IS NULL;