길드명·캐릭터명 변경 중복 방지: 예약 토큰과 DB 유일성 보장 패턴
길드명과 캐릭터명 변경에서 발생하는 동시성 중복 문제를 PostgreSQL 예약 토큰, 정규화 키, 유니크 제약조건, 트랜잭션으로 해결하는 백엔드 설계와 구현 패턴을 단계별로 설명합니다. 재시도와 만료 처리까지 다룹니다.

핵심 요약
길드명과 캐릭터명 중복을 막으려면 화면에서 먼저 조회하는 방식이 아니라 데이터베이스가 최종적으로 이름 소유권을 결정해야 합니다. 이름을 정규화한 name_key에 유니크 제약조건을 적용하고 예약 단계에서는 짧은 만료 시간을 가진 예약 토큰을 발급한 뒤 최종 변경을 하나의 트랜잭션으로 커밋합니다.
예약 토큰은 이름 자체가 아니라 특정 플레이어와 대상 엔터티가 해당 이름을 사용할 권한이 있다는 증명입니다. 토큰은 해시로 저장하고 사용자·엔터티·네임스페이스에 바인딩해야 재전송과 이름 가로채기를 줄일 수 있습니다.
왜 길드명과 캐릭터명에 중복 이름이 발생하는가?
가장 흔한 구현은 다음과 같습니다.
- 서버가
GET /names/available?name=...로 사용 가능 여부를 조회합니다. - 클라이언트가 결과를 받은 뒤 이름 변경 요청을 보냅니다.
- 서버가 다시 확인하고 캐릭터나 길드 테이블을 수정합니다.
이 구조에서는 두 요청이 같은 이름을 동시에 조회할 수 있습니다. 두 요청 모두 이름이 비어 있다고 판단한 뒤 각각 UPDATE를 실행하면 애플리케이션 코드만으로는 경쟁 상태를 막을 수 없습니다.
요청 A: 이름 조회 → 사용 가능
요청 B: 이름 조회 → 사용 가능
요청 A: 이름 변경
요청 B: 이름 변경
읽기와 쓰기 사이에 시간이 존재하기 때문입니다. 특히 다음 상황에서 문제가 커집니다.
- 게임 서버가 여러 대이고 요청이 서로 다른 인스턴스로 분산되는 경우
- 읽기 전용 리플리카가 최신 상태를 반영하지 못하는 경우
- 클라이언트가 타임아웃 뒤 같은 요청을 재전송하는 경우
- 길드명과 캐릭터명의 중복 범위가 월드별 또는 지역별로 다른 경우
- 대소문자, 공백, 유니코드 표현이 달라 사람이 같은 이름으로 인식하는 경우
따라서 available 조회 API는 사용자 경험을 위한 사전 안내일 뿐입니다. 실제 유일성은 쓰기 데이터베이스의 제약조건과 트랜잭션이 보장해야 합니다.
이름 유일성의 기준은 무엇인가?
표시용 이름과 비교용 이름을 분리하는 것이 좋습니다.
| 필드 | 목적 | 예시 |
|---|---|---|
display_name | 게임 화면에 표시 | Knight One |
name_key | 중복 검사용 정규화 값 | knight one |
namespace | 중복 범위 구분 | world-1:character |
owner_id | 현재 이름을 사용하는 엔터티 | character_id |
reservation_token | 예약 권한 증명 | 무작위 일회성 토큰 |
name_key를 만들 때는 게임 정책을 먼저 정해야 합니다.
- Unicode NFC 또는 NFKC 정규화 적용 여부
- 영문 대소문자 무시 여부
- 앞뒤 공백 제거 여부
- 연속 공백을 하나로 바꿀지 여부
- 금칙어와 제어 문자 처리 방식
- 서로 다른 유니코드 문자를 같은 문자로 취급할지 여부
예를 들어 Knight, knight, knight를 모두 같은 이름으로 취급한다면 동일한 정규화 규칙을 예약·조회·최종 변경에 모두 적용해야 합니다. 정규화 규칙이 API마다 다르면 유니크 인덱스가 있어도 게임 정책상 중복처럼 보이는 이름이 생길 수 있습니다.
또한 character와 guild의 이름 중복 범위가 다를 수 있습니다. 캐릭터끼리만 중복을 금지할지, 길드와 캐릭터를 합쳐 전역적으로 금지할지에 따라 namespace를 결정합니다.
예약 토큰은 어떻게 설계해야 하는가?
예약 토큰은 다음 조건을 가져야 합니다.
| 조건 | 구현 방법 |
|---|---|
| 예측 불가능성 | 서버에서 암호학적으로 안전한 난수 생성 |
| 짧은 유효 기간 | 예: 발급 후 5분 이내 |
| 일회성 사용 | 커밋 성공 시 즉시 무효화 |
| 대상 바인딩 | 플레이어, 엔터티, 네임스페이스, name_key 검증 |
| 안전한 저장 | 원문 대신 SHA-256 등으로 해시 저장 |
| 재시도 대응 | 별도의 idempotency key 사용 |
예약 토큰 자체는 이름의 유일성을 보장하지 않습니다. 토큰은 예약한 요청과 최종 변경 요청을 연결하는 인증 정보이고 유일성은 PRIMARY KEY나 UNIQUE 제약조건이 보장합니다.
Node.js 예시는 다음처럼 토큰 원문을 한 번만 반환하고 데이터베이스에는 해시를 저장하는 방식입니다.
import crypto from 'node:crypto';
const token = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(token).digest();
// token은 응답에 한 번만 포함하고 tokenHash만 DB에 저장합니다.
토큰을 검증할 때는 요청에서 받은 토큰을 같은 방식으로 해시한 뒤 저장된 해시와 비교합니다. 토큰을 로그, 분석 이벤트, 오류 메시지에 그대로 남기지 않는 것도 중요합니다.
이름 예약과 현재 이름을 하나의 테이블로 관리하는 패턴
가장 단순하고 강한 구조는 예약 이름과 현재 사용 중인 이름을 같은 클레임 테이블에서 관리하는 것입니다. namespace와 name_key를 복합 기본 키로 두면 예약 상태와 현재 상태를 포함해 같은 이름을 동시에 소유할 수 없습니다.
CREATE TABLE name_claims (
namespace text NOT NULL,
name_key text NOT NULL,
display_name text NOT NULL,
owner_type text NOT NULL,
owner_id bigint NOT NULL,
state text NOT NULL,
token_hash bytea,
expires_at timestamptz,
request_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (namespace, name_key),
CHECK (state IN ('reserved', 'current')),
CHECK (
(state = 'reserved' AND token_hash IS NOT NULL AND expires_at IS NOT NULL)
OR
(state = 'current' AND token_hash IS NULL AND expires_at IS NULL)
)
);
CREATE UNIQUE INDEX one_current_name_per_owner
ON name_claims (owner_type, owner_id)
WHERE state = 'current';
CREATE UNIQUE INDEX one_reserved_name_per_owner
ON name_claims (owner_type, owner_id)
WHERE state = 'reserved';
CREATE INDEX expired_reservation_lookup
ON name_claims (expires_at)
WHERE state = 'reserved';
이 구조의 핵심은 다음과 같습니다.
- 같은
namespace의 같은name_key는 한 행만 가질 수 있습니다. - 현재 이름은 만료되지 않습니다.
- 예약 행은
expires_at이 지나면 다른 요청이 회수할 수 있습니다. - 하나의 캐릭터나 길드가 여러 개의 현재 이름을 가질 수 없습니다.
- 이름 변경 중에는 이전 현재 이름과 새로운 예약 이름이 잠시 동시에 존재할 수 있습니다.
이름 정책에 따라 owner_type을 제거하고 namespace에 character와 guild를 포함해도 됩니다. 반대로 월드별로 별도 유일성을 적용하려면 world_id를 namespace 구성 요소로 포함합니다.
이름 예약 API는 어떤 순서로 구현하는가?
1단계: 입력을 정규화하고 정책을 검증한다
예약과 커밋 모두 같은 정규화 함수를 호출해야 합니다.
function toNameKey(input) {
return input
.normalize('NFKC')
.trim()
.replace(/\s+/gu, ' ')
.toLocaleLowerCase('en-US');
}
위 코드는 예시일 뿐입니다. 한국어 이름, 다국어 서버, 특수문자 정책에 맞춰 테스트해야 합니다. 특히 유니코드 대소문자 변환은 언어와 런타임에 따라 결과가 달라질 수 있으므로 서버에서 단일 구현을 사용해야 합니다.
2단계: 예약 트랜잭션에서 유일성을 확보한다
예약 요청은 다음 정보를 서버에서 확인합니다.
- 인증된 플레이어가 해당 캐릭터 또는 길드를 변경할 권한이 있는가
- 이름 길이와 허용 문자 조건을 만족하는가
- 금칙어와 운영 정책에 위배되지 않는가
- 같은 플레이어가 이미 활성 예약을 보유하고 있지 않은가
- 동일한
name_key가 현재 사용 중이거나 다른 사람에게 예약되어 있지 않은가
예약 절차의 의사 코드는 다음과 같습니다.
BEGIN
기존 claim을 namespace와 name_key로 조회하고 행 잠금
기존 claim이 current이면 이름 중복 오류
기존 claim이 reserved이고 만료되지 않았으면 이름 예약 중 오류
기존 claim이 reserved이고 만료되었으면 기존 claim 삭제
새 예약 토큰 생성
name_claims에 reserved 상태로 INSERT
COMMIT
예약 토큰 원문과 만료 시각 반환
INSERT 시점에 기본 키 충돌이 발생하면 애플리케이션에서 다시 확인하지 말고 이름 중복 오류로 처리합니다. 두 요청이 동시에 들어와도 데이터베이스에서 한 요청만 삽입에 성공합니다.
예약 만료 행을 정리하는 작업은 별도 배치로 실행할 수 있지만 예약 요청 시에도 만료된 행을 확인해 즉시 회수할 수 있어야 합니다. 배치가 잠시 지연되어도 만료된 이름이 계속 막히지 않게 하기 위해서입니다.
3단계: 최종 변경을 하나의 트랜잭션으로 커밋한다
최종 변경 API는 클라이언트가 제출한 이름을 그대로 신뢰하지 않고 예약 토큰을 기준으로 예약 행을 찾습니다.
BEGIN
대상 캐릭터 또는 길드 행을 FOR UPDATE로 잠금
예약 claim을 namespace와 name_key로 조회하고 FOR UPDATE
예약 상태가 reserved인지 확인
expires_at이 현재 시각보다 미래인지 확인
토큰 해시가 일치하는지 확인
owner_type과 owner_id가 대상 엔터티와 일치하는지 확인
기존 current claim 삭제
예약 claim을 current 상태로 변경하고 토큰과 만료 시각 제거
캐릭터 또는 길드의 display_name과 name_key 갱신
COMMIT
기존 이름을 먼저 삭제하고 예약 행을 current로 승격해야 one_current_name_per_owner 인덱스와 충돌하지 않습니다. 이 모든 작업이 하나의 트랜잭션 안에서 수행되어야 중간 상태가 외부에 노출되지 않습니다.
트랜잭션 중 유니크 제약조건 위반, 교착 상태, 직렬화 실패가 발생하면 요청을 안전한 오류로 변환하거나 제한된 횟수만 재시도합니다. 재시도할 때는 동일한 변경 요청이 중복 적용되지 않도록 idempotency key를 함께 사용합니다.
예약 토큰과 멱등성 키는 어떻게 구분하는가?
두 값은 목적이 다릅니다.
| 값 | 목적 | 수명 |
|---|---|---|
| 예약 토큰 | 특정 이름을 커밋할 권한 증명 | 예약 만료까지 성공 시 즉시 폐기 |
| 멱등성 키 | 같은 API 요청의 재전송 식별 | API 정책에 따라 수분에서 수일 |
네트워크 타임아웃으로 클라이언트가 커밋 요청을 다시 보내는 상황을 생각해 보겠습니다. 첫 번째 요청이 서버에서는 성공했지만 응답만 유실되었다면 두 번째 요청이 같은 변경을 다시 실행할 수 있습니다. 커밋 요청의 request_id를 저장하고 이미 성공한 결과가 있으면 같은 결과를 반환하도록 만들면 이 문제를 줄일 수 있습니다.
CREATE TABLE name_change_requests (
request_id uuid PRIMARY KEY,
owner_type text NOT NULL,
owner_id bigint NOT NULL,
result_state text NOT NULL,
result_name_key text,
created_at timestamptz NOT NULL DEFAULT now()
);
토큰만으로 멱등성을 해결하려고 하면 토큰을 재사용할지 폐기할지 정책이 불명확해집니다. 토큰은 권한 검증에 사용하고 request_id는 재시도 제어에 사용하는 편이 명확합니다.
분리된 예약 테이블을 사용할 때 주의할 점
기존 시스템이 characters.name_key와 name_reservations.name_key를 별도 테이블로 이미 운영하고 있다면 두 테이블의 유니크 인덱스만으로는 충분하지 않을 수 있습니다. 두 테이블에 동시에 삽입되는 경쟁 조건이 남기 때문입니다.
분리 구조를 유지하려면 다음 중 하나가 필요합니다.
- 이름 소유권을 단일
name_claims테이블로 통합한다. - 별도 네임 레지스트리 테이블을 만들고 모든 현재 이름과 예약을 그곳에서 잠근다.
- 데이터베이스 트랜잭션 안에서 고정된 순서로 관련 행을 잠근다.
- 최종 쓰기 시 데이터베이스 유니크 오류를 정상적인 이름 충돌로 처리한다.
Redis의 SETNX나 분산 락은 빠른 예약 응답을 위한 보조 수단으로 사용할 수 있지만 최종 정합성 저장소로 사용하면 안 됩니다. Redis 장애, 만료 시각 차이, 네트워크 분할, 데이터 유실 상황에서 게임 데이터베이스와 상태가 달라질 수 있습니다.
보안과 운영에서 함께 점검할 항목
- 예약 토큰은 최소 128비트 이상의 예측 불가능한 난수로 생성합니다.
- 토큰 원문을 로그와 분석 시스템에 기록하지 않습니다.
- 토큰을 플레이어 ID, 대상 ID, 네임스페이스,
name_key에 바인딩합니다. - 이름 예약과 변경 API에 속도 제한을 적용합니다.
- 만료된 예약을 주기적으로 삭제하거나 예약 요청 중 회수합니다.
- 읽기 전용 리플리카가 아니라 쓰기 프라이머리에서 최종 충돌을 판단합니다.
- 유니크 제약조건 위반을 500 오류가 아닌 예측 가능한 이름 중복 응답으로 변환합니다.
- 이름 변경 이벤트를 발행할 때 트랜잭션 커밋 이후에 발행하거나 아웃박스 패턴을 사용합니다.
- 캐시 무효화는 데이터베이스 커밋 이후 수행합니다.
- 금칙어 필터는 유일성 검사와 별도로 적용합니다.
이름 변경 이벤트가 다른 서버에 전파되기 전에 채팅, 친구 목록, 길드 목록에 이전 이름이 잠시 표시될 수 있습니다. 이는 이름의 유일성 문제와 캐시 일관성 문제를 분리해서 다뤄야 한다는 뜻입니다. 이름 소유권은 즉시 데이터베이스에서 확정하고 화면 표시는 이벤트 처리 지연을 허용하는 방식이 일반적입니다.
자주 묻는 질문 (FAQ)
예약 토큰이 있으면 유니크 인덱스가 없어도 되나요?
안 됩니다. 토큰은 예약 권한을 증명할 뿐이며 동시 요청을 막는 최종 장치는 데이터베이스의 PRIMARY KEY 또는 UNIQUE 제약조건이어야 합니다.
이름 사용 가능 여부 API가 false를 반환했는데도 최종 변경이 실패할 수 있나요?
가능합니다. 조회 이후 다른 요청이 먼저 이름을 예약하거나 변경할 수 있습니다. 클라이언트는 가능 여부 조회를 참고 정보로만 사용하고 최종 변경 API의 충돌 응답을 처리해야 합니다.
예약 테이블을 주기적으로 삭제하면 충분한가요?
충분하지 않습니다. 정리 작업 사이에 만료된 예약이 계속 남아 있을 수 있으므로 예약 요청 시 만료 여부를 확인해 회수해야 합니다. 최종 커밋에서도 expires_at을 다시 검증해야 합니다.
정리
길드명과 캐릭터명 변경의 중복 문제는 문자열 비교 문제가 아니라 이름 소유권을 원자적으로 이전하는 동시성 제어 문제입니다. 가장 안정적인 패턴은 정규화된 name_key를 만들고 namespace + name_key를 단일 클레임 테이블의 기본 키로 관리하는 것입니다.
예약 단계에서는 짧은 TTL의 일회성 토큰을 발급하고 최종 변경 단계에서는 토큰의 소유자와 대상 엔터티를 검증합니다. 이전 이름 삭제, 새 이름 승격, 엔터티 갱신은 하나의 트랜잭션으로 묶고 유니크 제약조건 충돌을 정상적인 비즈니스 오류로 처리하면 여러 게임 서버와 재시도 상황에서도 이름 유일성을 일관되게 유지할 수 있습니다.



