랜덤 닉네임 중복을 어떻게 다룰까?
들어가며
수식어 + 동물 형태의 랜덤 닉네임은 가입 요청이 들어올 때마다 만들어도 될까, 아니면 가능한 조합을 미리 전부 만들어서 DB에 넣어두는 게 나을까?
지인 대상으로 MVP를 작게 열어본 뒤 약 100명 정도가 가입해 있었습니다. 실제 사용자가 생기고 나니 기본 닉네임을 계속 랜덤으로 만들어도 괜찮은지 한 번쯤 점검해야겠다는 생각이 들었습니다.
신규 사용자에게는 수식어 + 동물 형태의 기본 닉네임을 자동으로 부여하고 있었습니다. 처음에는 단순하게 생각했습니다. 수식어 하나와 동물 이름 하나를 랜덤으로 뽑고, 이미 사용 중이면 몇 번 더 시도하면 된다고 봤습니다.
그런데 후보 수를 계산해 보니 현재 조합은 5,325개였습니다.
수식어 71개 x 동물 75개 = 5,325개
크다고 보기에는 애매하고, 작다고 보기에는 초기 서비스에서 당장 문제가 될 정도는 아닌 숫자였습니다. 게다가 닉네임에는 숫자 suffix를 붙이고 싶지 않았습니다. 조용한토끼1, 조용한토끼2 같은 방식은 구현은 쉬워도 서비스의 기본 닉네임으로는 어색하다고 봤습니다.
그래서 이번 글에서는 UNIQUE + 재시도 방식과 nickname_pool 방식 사이에서 어떤 기준으로 판단했는지 정리해보려고 합니다.
결론부터 말하면, 현재 규모에서는 UNIQUE + 재시도를 유지하고 후보군을 늘릴 수 있는 여지를 남기는 쪽이 가장 낫다고 봤습니다. nickname_pool은 더 안정적인 방식이지만, 지금 문제에 비해 관리해야 할 정책이 많았습니다.
1. 처음 생각한 방식
처음 구현은 아주 단순합니다. 닉네임 생성기는 수식어 목록과 동물 목록에서 각각 하나씩 랜덤으로 뽑습니다.
fun generate(): String = "${MODIFIERS.random()}${ANIMALS.random()}"
가입 흐름에서는 최대 10번까지 닉네임 생성을 재시도합니다.
repeat(NICKNAME_GENERATION_MAX_ATTEMPT_COUNT) {
userService.createOAuthUser(profile, userNicknameGenerator.generate())?.let { return it }
userService.updateOAuthUser(profile)?.let { return it }
}
throw CustomException(ErrorCode.NICKNAME_GENERATION_FAILED)
DB에는 닉네임 중복을 막기 위한 unique index를 둡니다.
CREATE UNIQUE INDEX users_nickname_unique_idx
ON users (nickname)
WHERE deleted_at IS NULL;
애플리케이션에서 랜덤 값을 잘 뽑는 것만으로는 동시성 문제가 해결되지 않습니다. 동시에 들어온 두 가입 요청이 같은 닉네임을 만들 수 있기 때문입니다.
요청 A: 조용한토끼 생성
요청 B: 조용한토끼 생성
그래서 최종 방어선은 반드시 DB의 unique 제약이어야 합니다.
요청 A: insert 성공
요청 B: unique 충돌
요청 B: 다음 닉네임으로 재시도
현재 구조에서는 insert ... on conflict do nothing에 가까운 방식으로 충돌을 처리합니다. 중복이면 insert 결과가 없고, 그 경우 다음 랜덤 닉네임으로 다시 시도합니다.
처음에는 이 정도면 충분하다고 생각했습니다. 하지만 후보 풀이 5,325개뿐이라면 "10번 재시도해도 실패할 가능성"과 "나중에 후보가 거의 찼을 때 어떤 일이 생기는지"를 따져봐야 했습니다.
2. 중복 확률을 계산해보기
전체 후보가 5,325개이고 이미 사용 중인 닉네임이 U개라면, 새로 한 번 뽑은 닉네임이 중복될 확률은 아래처럼 계산할 수 있습니다.
중복 확률 = U / 5,325
예를 들어 이미 사용 중인 닉네임 수가 늘어날수록 1회 생성 시 중복 확률은 이렇게 올라갑니다. 10번 모두 실패할 확률까지 같이 보면, 초기 규모에서는 실패 가능성이 꽤 낮다는 것도 확인할 수 있습니다.
사용 중인 닉네임 수 1회 생성 중복 확률 10회 모두 실패할 확률
| 100개 | 약 1.88% | 약 0.00000000000000055% |
| 300개 | 약 5.63% | 약 0.000000000032% |
| 500개 | 약 9.39% | 약 0.0000000053% |
| 1,000개 | 약 18.78% | 약 0.0000055% |
이미 300개가 사용 중이라고 해도, 10번을 모두 실패할 확률은 매우 낮습니다.
(300 / 5,325)^10 x 100 ≈ 0.000000000032%
초기 규모에서는 10번 모두 중복될 확률이 낮은 편입니다. 다만 중복이 불가능하다는 뜻은 아니기 때문에, 단순히 "현재 가입자가 많지 않다"는 조건만 보면 UNIQUE + 10회 재시도 방식은 실용적인 선택지라고 볼 수 있습니다.
문제는 후보 풀이 거의 찼을 때입니다. 남은 닉네임이 1개뿐이라면 랜덤으로 성공 후보를 찾는 일은 꽤 비효율적이 됩니다.
최선: 1회 시도
평균: (5,325 + 1) / 2 = 2,663회 시도
최악: 5,325회 시도
이때 비용은 문자열을 조합하는 데서 생기지 않습니다. 진짜 부담은 DB insert 실패와 unique 충돌 처리에서 생깁니다. 그래서 후보 풀이 작거나 고갈에 가까워질 수 있다면 다른 방식도 검토해야 합니다.
3. 선택지 1 - 랜덤 생성 + UNIQUE + 재시도
이 방식은 가입 시점에 닉네임을 만들고, DB unique 제약으로 중복을 막은 뒤, 충돌하면 다시 생성합니다.
장점은 단순함입니다.
- 별도 테이블이 필요 없습니다.
- seed migration을 관리하지 않아도 됩니다.
- 초기 사용자 수가 적으면 충돌 확률도 낮습니다.
- 동시성 방어를 DB unique 제약에 맡길 수 있습니다.
단점도 분명합니다.
- 이론상 제한 횟수 안에 모두 충돌하면 가입이 실패합니다.
- 후보 풀이 찰수록 충돌 확률이 계속 높아집니다.
- 실패 원인이 "후보 고갈"인지 "운 나쁜 재시도"인지 바로 드러나지 않습니다.
특히 마지막 지점이 마음에 걸렸습니다. 사용자가 가입하려는데 "사용 가능한 닉네임 생성에 실패했습니다"가 발생한다면, 운영자는 이게 정말 후보 고갈 때문인지, 재시도 횟수가 너무 낮아서인지 따로 확인해야 합니다.
그래도 현재 조건에서는 이 방식이 가장 부담이 적었습니다. 닉네임을 별도 테이블에 쌓아두고 재고처럼 관리해야 할 만큼 중요한 자원으로 보지는 않았고, 가입 요청이 짧은 시간에 크게 몰릴 가능성도 낮다고 봤기 때문입니다.
4. 중간 선택지 - 후보 전체를 캐싱하기
다음으로 생각한 방식은 앱 시작 시점에 가능한 닉네임 5,325개를 전부 만들어서 메모리에 올려두는 것입니다. 가입 요청이 들어오면 후보 목록을 랜덤 순서로 순회하면서 insert를 시도합니다.
이 방식은 "10번만 운 나쁘게 실패해서 가입이 막히는 문제"를 줄여줍니다. 후보 수가 5,325개라면 메모리 부담도 거의 없습니다.
하지만 이 방식도 근본적으로 race condition을 없애지는 못합니다. 서버 인스턴스가 여러 대라면 각 인스턴스가 같은 후보 목록을 들고 있을 수 있습니다. 결국 최종 중복 방어는 여전히 DB unique 제약이 담당해야 합니다.
서버가 여러 대로 늘어나고 후보 목록을 중앙에서 관리하고 싶다면, Redis Set에 후보를 넣고 SPOP으로 하나씩 꺼내는 변형도 생각할 수 있습니다. 다만 이 경우 Redis 후보 목록 초기화, 장애 시 복구, DB insert 실패 시 후보를 되돌릴지 같은 정책이 필요합니다. 단순 캐시라기보다는 별도의 후보 저장소에 가까워지기 때문에, 현재 규모에서는 과하다고 봤습니다.
그리고 후보 풀이 거의 찬 상태에서는 평균 DB 시도 횟수가 커질 수 있습니다. 남은 후보 중에서만 뽑는 구조가 아니기 때문입니다.
그래서 이 방식은 nickname_pool보다 가볍지만, 현재 단순 재시도 방식에 비해 얻는 이점도 애매하다고 봤습니다.
5. 선택지 3 - nickname_pool 테이블 만들기
가장 명확한 방식은 가능한 닉네임 조합을 미리 DB에 저장해두는 것입니다.
CREATE TABLE nickname_pool (
id BIGSERIAL PRIMARY KEY,
nickname VARCHAR(15) NOT NULL UNIQUE,
assigned_user_id BIGINT,
assigned_at TIMESTAMP
);
가입 시에는 아직 할당되지 않은 닉네임 하나를 row lock으로 잡아서 가져옵니다.
SELECT id, nickname
FROM nickname_pool
WHERE assigned_user_id IS NULL
ORDER BY random()
LIMIT 1
FOR UPDATE SKIP LOCKED;
이 방식은 장점이 뚜렷합니다.
- 남은 후보 중에서만 가져옵니다.
- 남은 닉네임 수를 운영 지표로 볼 수 있습니다.
- 동시성 제어를 DB row lock으로 표현할 수 있습니다.
- 후보가 거의 찬 상황에서도 안정적입니다.
하지만 그만큼 운영 비용도 생깁니다.
- nickname_pool 테이블이 추가됩니다.
- 초기 조합을 넣는 seed migration이 필요합니다.
- 닉네임 변경이나 탈퇴 시 기존 닉네임을 반납할지 정책을 정해야 합니다.
- 단어 목록이 바뀌면 조합 seed를 다시 관리해야 합니다.
- 할당, 반납, 재고 확인이라는 별도 흐름이 생깁니다.
nickname_pool은 "미리 만든 유한한 재고를 원자적으로 하나씩 할당한다"는 모델입니다. 이 모델 자체는 좋습니다. 다만 지금 닉네임 문제에 그 정도 모델링이 꼭 필요한지는 별개의 문제였습니다.
6. 쿠폰 코드라면 이야기가 달라진다
랜덤 닉네임 문제는 쿠폰 코드 발급과 겉모습이 비슷합니다.
둘 다 유한한 후보 풀에서 중복 없이 하나를 발급합니다.
하지만 도메인 특성이 다릅니다. 쿠폰 코드는 보통 아래 조건을 가집니다.
- 유한한 재고입니다.
- 중복 발급이 비용 사고로 이어질 수 있습니다.
- 이벤트나 푸시 발송 때 짧은 시간에 대량 발급될 수 있습니다.
- 발급, 예약, 사용, 만료, 취소 같은 상태 전이가 필요합니다.
- 누가 언제 발급받고 사용했는지 감사 로그가 중요합니다.
- 남은 수량을 운영 지표로 관리해야 합니다.
이런 경우에는 pre-generated pool이 자연스럽습니다. 쿠폰을 미리 만들어두고, 발급 시 미사용 코드 하나를 원자적으로 할당하는 쪽이 도메인과 잘 맞습니다.
반면 닉네임은 쿠폰보다 훨씬 느슨합니다.
- 중복이나 고갈이 바로 비용 사고로 이어지는 자원은 아닙니다.
- 사용자가 직접 변경할 수 있습니다.
- 변경되거나 탈퇴한 사용자의 닉네임은 다시 사용 가능할 수 있습니다.
- 후보군을 비교적 쉽게 늘릴 수 있습니다.
- 남은 닉네임 수가 꼭 엄격한 운영 지표일 필요는 없습니다.
- 실제 식별자는 닉네임이 아니라 user id입니다.
그래서 쿠폰에서 좋은 설계가 닉네임에도 항상 정답은 아니라고 봤습니다.
7. 후보군을 늘리는 쪽이 더 단순할 수 있다
현재 문제의 핵심은 후보 풀이 5,325개로 크지 않다는 점입니다. 그렇다면 nickname_pool을 만들기 전에 후보군을 늘리는 선택지도 있습니다.
예를 들어 색상 + 수식어 + 동물처럼 조합 축을 하나 더 추가하면 후보 수가 크게 늘어납니다.
30 x 71 x 75 = 159,750개
예시는 이런 형태가 됩니다.
2단 조합: 조용한 토끼
3단 조합: 푸른 조용한 토끼
처음부터 3단 조합을 전부 쓰는 방법도 있고, 2단 조합에서 충돌이 반복될 때만 3단 조합으로 fallback하는 방법도 있습니다.
이 방식은 nickname_pool보다 훨씬 단순합니다. 테이블도 추가하지 않고, 반납 정책도 복잡하게 만들지 않습니다. 후보 수가 충분히 커지면 UNIQUE + 재시도 방식의 실용성도 같이 올라갑니다.
8. 현재 선택
현재 도메인에서는 nickname_pool을 바로 도입하지 않는 쪽이 낫다고 판단했습니다.
이유는 아래와 같습니다.
- 가입자가 짧은 시간에 대량 유입되는 상황이 아닙니다.
- 닉네임 후보 조합은 쉽게 늘릴 수 있습니다.
- 닉네임은 사용자가 변경할 수 있습니다.
- 기존 닉네임을 다시 사용 가능하게 만들 여지가 있습니다.
- 쿠폰처럼 중복이나 고갈이 곧바로 비용 사고로 이어지지 않습니다.
- nickname_pool을 만들면 테이블, seed, 할당, 반납 정책이 함께 생깁니다.
그래서 우선순위는 이렇게 두었습니다.
1. users.nickname UNIQUE 제약은 유지한다.
2. 랜덤 생성 후 unique 충돌 시 재시도한다.
3. 재시도 실패가 우려되면 후보군을 확장한다.
4. 후보 고갈이나 운영 가시성이 실제로 필요해질 때 nickname_pool을 검토한다.
현재 구현의 핵심은 "랜덤 생성이 중복을 막아준다"가 아닙니다. 중복은 DB unique 제약으로 막고, 랜덤 생성은 후보를 고르는 방법으로만 사용한다는 점입니다.
9. 남은 고민
아직 남은 고민도 있습니다.
첫 번째는 재시도 횟수입니다. 지금은 최대 10회로 두었지만, 후보 풀이 얼마나 사용됐는지에 따라 적절한 값이 달라질 수 있습니다. 사용자 수가 늘어난다면 실제 충돌률을 로그나 지표로 확인해야 합니다.
두 번째는 고갈에 가까워졌을 때의 정책입니다. 단순히 에러를 던질지, 3단 조합으로 fallback할지, 그 시점에 nickname_pool로 전환할지 미리 기준을 잡아두면 좋습니다.
세 번째는 닉네임 반납 정책입니다. 현재 unique index는 deleted_at IS NULL인 사용자만 대상으로 합니다. 탈퇴한 사용자의 닉네임을 다시 쓸 수 있는 구조입니다. 다만 사용자가 닉네임을 변경했을 때 기존 닉네임을 즉시 풀어도 되는지는 별도 정책으로 봐야 합니다.
정리
이번 고민에서 얻은 결론은 하나였습니다.
같은 랜덤 중복 문제라도 도메인 특성에 따라 정답이 달라집니다.
쿠폰 코드는 pre-generated pool이 자연스럽습니다. 재고, 비용, 감사 로그, 대량 발급 같은 조건이 있기 때문입니다.
하지만 닉네임은 조금 다릅니다. 표시 이름에 가깝고, 후보군을 늘리기 쉽고, 고갈이 곧바로 비용 사고로 이어지지도 않습니다. 그래서 현재는 UNIQUE + 재시도 + 후보군 확장 가능성을 먼저 선택하는 게 더 단순하고 도메인에도 잘 맞는다고 봤습니다.
처음부터 가장 견고한 구조를 만드는 것보다, 지금 문제의 크기에 맞는 복잡도를 선택하는 게 더 중요하다는 생각이 들었습니다.
'DB' 카테고리의 다른 글
| Tasteam V2 이관기 2: DB는 무중단이었는데 왜 S3 이관은 다르게 했을까? — aws s3 sync 기반 이관기 (0) | 2026.02.28 |
|---|---|
| Tasteam V2 이관기 1: 다른 AWS 계정으로 DB를 무중단으로 옮긴 이야기 (0) | 2026.02.24 |