12. 소프트 삭제와 UNIQUE 제약의 충돌 (길드명 재사용 실패)
난이도 하해설 — 소프트 삭제와 UNIQUE 제약의 충돌 (길드명 재사용 실패)
난이도: 하
요약
guild.name 에 걸린 UNIQUE(name) 제약은 is_deleted 값과 무관하게 테이블 전체 행에 적용된다. 그런데 애플리케이션 로직(A)은 "살아있는 길드끼리만 중복 금지"를 의도했고 실제로 ExistsAsync 검사도 is_deleted=false 조건을 걸었지만, 정작 DB 의 UNIQUE 제약 자체는 그 조건을 모른다. 그 결과 해산된(소프트 삭제된) 길드의 이름이 여전히 UNIQUE 인덱스를 점유하고 있어, 애플리케이션 검사(A)는 통과했는데(살아있는 동명 길드가 없으므로) 실제 INSERT 는 UNIQUE 제약 위반으로 실패하는 모순이 발생한다. 또한 복구 로직(B)은 복구 대상 이름과 같은 이름을 가진 살아있는 길드가 이미 생겼을 가능성을 전혀 검사하지 않아, 복구 시도 자체가 UNIQUE 위반으로 실패하거나(운영자 입장에서 원인 파악이 어려움) 검사 없이 복구를 강행하면 제약 위반 예외가 발생한다.
문제점
(A) 애플리케이션 레벨 검사와 DB UNIQUE 제약의 조건 불일치 — 재생성 실패
- 증상: 길드 "블랙타이거"가 해산(
is_deleted=true)된 뒤, 다른 플레이어가 같은 이름 "블랙타이거"로 새 길드를 만들려 하면ExistsAsync(... is_deleted=false ...)는 false(살아있는 동명 길드 없음)를 반환해 애플리케이션은 "생성 가능"으로 판단하지만, 실제INSERT문은UNIQUE(name)제약에 걸려 실패한다. 플레이어에게는 "이미 존재"라는 원인 불명의 오류로 보이고, 실제로는 재사용 가능해야 할 이름이 막혀 있다. - 재현 조건: 길드 A 를 해산 → 같은 이름으로 새 길드 생성 시도.
- 근본 원인: DB 스키마의
UNIQUE(name)이is_deleted를 고려하지 않는 전체 컬럼 제약이라, 논리적으로 "삭제된 것으로 취급"되는 행도 여전히 유일성 공간을 점유한다.
(B) 복구 시 이름 충돌 미검증
- 증상: 길드 A(이름 "블랙타이거")가 해산된 뒤 다른 누군가 같은 이름으로 새 길드 A' 를 만들었다면, 이제 살아있는 길드는 A' 하나다. 이 상태에서 운영자가 원래 A 를 복구하려 하면,
RestoreGuildAsync는 아무 검사 없이UPDATE ... SET is_deleted=false를 실행해 두 개의 살아있는 "블랙타이거" 를 만들려다 UNIQUE 제약 위반으로 실패하거나(제약이 정상 작동 시), 혹은 이름 충돌 검사가 없는 다른 방식이면 조용히 데이터 정합성이 깨질 수 있다. - 재현 조건: 길드 해산 → 동명 길드 재생성 → 원래 길드 복구 시도.
- 근본 원인: 복구는 "생성"과 동일한 유일성 제약을 다시 만족시켜야 하는 연산인데, 그 검증이 생성 경로에만 있고 복구 경로에는 없다.
수정안 (정확한 코드)
부분 유니크 인덱스(partial unique index) 로 "살아있는 행끼리만 유일" 이라는 실제 의도를 DB 제약 자체에 반영한다(PostgreSQL 기준). 그러면 소프트 삭제된 행의 이름은 더 이상 유일성 공간을 점유하지 않아 재사용이 자연스럽게 허용되고, 애플리케이션의 존재 검사와 DB 제약의 조건이 일치해 모순이 사라진다.
-- 기존 컬럼 레벨 UNIQUE(name) 제거 후, 부분 유니크 인덱스로 대체
ALTER TABLE guild DROP CONSTRAINT guild_name_key; -- 기존 UNIQUE(name) 제약 제거
CREATE UNIQUE INDEX guild_name_alive_uidx
ON guild (name)
WHERE is_deleted = false; -- 살아있는 행 사이에서만 유일성 강제
using System.Threading.Tasks;
public sealed class GuildRepo
{
private readonly IDb _db;
public GuildRepo(IDb db) => _db = db;
public async Task<bool> CreateGuildAsync(string name, ulong ownerId)
{
// 부분 유니크 인덱스 덕분에, 이 애플리케이션 레벨 검사와 DB 제약의
// 조건(is_deleted=false 범위)이 정확히 일치한다 — 모순 사라짐.
if (await _db.ExistsAsync("SELECT 1 FROM guild WHERE name=@n AND is_deleted=false", name))
return false;
var rows = await _db.ExecAsync(
"INSERT INTO guild(name, owner_id, is_deleted) VALUES(@n, @o, false)",
name, ownerId);
return rows > 0;
}
// 복구 시 이름 충돌을 명시적으로 재검증한다.
public async Task<bool> RestoreGuildAsync(int guildId, string name)
{
if (await _db.ExistsAsync("SELECT 1 FROM guild WHERE name=@n AND is_deleted=false", name))
return false; // 이미 같은 이름의 살아있는 길드가 존재 — 복구 거부(개명 후 재시도 안내)
var rows = await _db.ExecAsync(
"UPDATE guild SET is_deleted=false WHERE id=@id AND is_deleted=true", guildId);
return rows > 0;
}
}
핵심: ① 부분 유니크 인덱스로 "살아있는 행끼리만 유일"이라는 규칙을 DB 제약 자체가 강제하게 만들어, 애플리케이션 검사와 실제 제약의 조건 불일치를 없앤다. ② 복구도 생성과 동일한 유일성 검증을 거치게 해, 복구가 데이터 정합성을 깨뜨리지 않도록 한다.
더 나은 설계 (+ 트레이드오프)
- 삭제 시 이름을 변형(예: 접미사 추가)해 저장: 소프트 삭제 시
name을"블랙타이거#deleted#<timestamp>"처럼 바꿔 저장하면 컬럼 레벨UNIQUE(name)을 그대로 유지하면서도 이름 재사용이 가능해진다. 다만 원래 이름을 복구 시 되돌리는 로직이 추가로 필요하고, 원본 이름 조회(감사/검색)가 번거로워진다. - 부분 유니크 인덱스(채택안): DB 제약이 의도를 정확히 표현해 애플리케이션 로직이 단순해진다. PostgreSQL/SQL Server 등은 지원하지만 MySQL 은 부분 인덱스를 직접 지원하지 않아(생성 컬럼 트릭 등 우회 필요) DB 종류에 따라 이식성 제약이 있다.
- 소프트 삭제 대신 별도 아카이브 테이블로 이동: 삭제 시
guild_archive로 옮기고 원본 테이블에서는 실제로 제거하면 UNIQUE 제약이 애초에 살아있는 행에만 적용된다. 복구는 아카이브에서 되돌리며 이름 충돌 재검증. 구조가 명확해지지만 마이그레이션·조인 복잡도가 늘어난다. - 트레이드오프: 부분 유니크 인덱스가 가장 적은 코드 변경으로 의도를 정확히 표현하지만, MySQL 처럼 미지원 DB 라면 이름 변형이나 아카이브 테이블 방식을 고려해야 한다.
면접 포인트
UNIQUE제약은 기본적으로 테이블의 모든 행에 적용된다 — 소프트 삭제처럼 "논리적으로 존재하지 않는" 행을 구분해야 하는 유일성 요구에는 부분 유니크 인덱스(조건부 UNIQUE)가 정답이다.- 애플리케이션 레벨 존재 검사(
SELECT ... WHERE condition)와 DB 제약의 조건이 다르면, 검사는 통과했는데 제약 위반으로 실패하는 모순이 생긴다 — 둘의 조건을 반드시 일치시켜야 한다. - 복구(soft-delete undo)는 생성과 동일한 유일성 규칙을 다시 만족해야 하는 연산이다 — 생성 경로에만 있는 검증을 복구 경로에도 반드시 적용해야 한다.
해설 — 소프트 삭제와 UNIQUE 제약의 충돌 (캐릭터명 재사용 실패, C++)
난이도: 하
요약
character.name 에 걸린 UNIQUE(name) 제약은 is_deleted 값과 무관하게 테이블 전체 행에 적용된다. 애플리케이션 로직(A)은 "살아있는 캐릭터끼리만 중복 금지"를 의도해 exists 검사에 is_deleted=0 조건을 걸었지만, DB 의 UNIQUE 제약 자체는 그 조건을 모른다. 그 결과 소프트 삭제된 캐릭터의 이름이 여전히 UNIQUE 인덱스를 점유해, 애플리케이션 검사(A)는 통과했는데(살아있는 동명 캐릭터 없음) 실제 INSERT 는 UNIQUE 제약 위반으로 실패하는 모순이 생긴다. 복구 로직(B)도 복구 대상 이름을 가진 살아있는 캐릭터가 이미 있을 가능성을 검사하지 않아, 복구가 실패하거나 정합성이 깨질 수 있다.
문제점
(A) 애플리케이션 레벨 검사와 DB UNIQUE 제약의 조건 불일치 — 재생성 실패
- 증상: 캐릭터 "칼바람"이 소프트 삭제된 뒤, 다른 계정이 같은 이름 "칼바람"으로 새 캐릭터를 만들려 하면
exists(... is_deleted=0 ...)는 false 를 반환해 애플리케이션은 생성 가능으로 판단하지만, 실제INSERT는UNIQUE(name)위반으로 실패한다. - 재현 조건: 캐릭터 A 를 소프트 삭제 → 같은 이름으로 새 캐릭터 생성 시도.
- 근본 원인: DB 스키마의
UNIQUE(name)이is_deleted를 고려하지 않는 전체 컬럼 제약이라, 논리적으로 삭제된 행도 여전히 유일성 공간을 점유한다.
(B) 복구 시 이름 충돌 미검증
- 증상: 캐릭터 A(이름 "칼바람")가 소프트 삭제된 뒤 다른 계정이 같은 이름으로 새 캐릭터 A' 를 만들었다면, 이제 살아있는 캐릭터는 A' 하나다. 이 상태에서 유예기간 내 원래 A 를 복구하려 하면
restoreCharacter는 아무 검사 없이UPDATE ... SET is_deleted=0을 실행해 두 개의 살아있는 "칼바람"을 만들려다 UNIQUE 제약 위반으로 실패한다. - 재현 조건: 캐릭터 소프트 삭제 → 동명 캐릭터 재생성 → 원래 캐릭터 복구 시도.
- 근본 원인: 복구는 생성과 동일한 유일성 제약을 다시 만족시켜야 하는 연산인데, 그 검증이 생성 경로에만 있고 복구 경로에는 없다.
수정안 (정확한 코드)
부분 유니크 인덱스로 "살아있는 행끼리만 유일"을 DB 제약 자체에 반영하고, 복구 경로에도 동일한 유일성 검증을 추가한다(PostgreSQL 기준 스키마 변경 + C++ 리포지토리 수정).
ALTER TABLE character DROP CONSTRAINT character_name_key;
CREATE UNIQUE INDEX character_name_alive_uidx
ON character (name)
WHERE is_deleted = 0; -- 살아있는 행 사이에서만 유일성 강제
#include <string>
class IDb {
public:
virtual ~IDb() = default;
virtual bool exists(const std::string& sql, const std::string& arg) = 0;
virtual int execUpdate(const std::string& sql, const std::string& name, unsigned long long ownerId) = 0;
virtual int execRestore(const std::string& sql, int characterId) = 0;
// 복구 시 이름 충돌 재검증에 사용
virtual bool existsAliveName(const std::string& name) = 0;
};
class CharacterRepo {
IDb& db_;
public:
explicit CharacterRepo(IDb& db) : db_(db) {}
bool createCharacter(const std::string& name, unsigned long long ownerId) {
// 부분 유니크 인덱스 덕분에 이 검사와 DB 제약의 조건(is_deleted=0 범위)이
// 정확히 일치한다 — 모순 사라짐.
if (db_.exists("SELECT 1 FROM character WHERE name=? AND is_deleted=0", name))
return false;
int rows = db_.execUpdate(
"INSERT INTO character(name, owner_id, is_deleted) VALUES(?, ?, 0)",
name, ownerId);
return rows > 0;
}
// 복구 시 이름 충돌을 명시적으로 재검증한다.
bool restoreCharacter(int characterId, const std::string& name) {
if (db_.existsAliveName(name))
return false; // 이미 같은 이름의 살아있는 캐릭터 존재 — 복구 거부(개명 후 재시도 안내)
int rows = db_.execRestore(
"UPDATE character SET is_deleted=0 WHERE id=? AND is_deleted=1", characterId);
return rows > 0;
}
};
핵심: ① 부분 유니크 인덱스로 "살아있는 행끼리만 유일"이라는 규칙을 DB 제약 자체가 강제해 애플리케이션 검사와의 조건 불일치를 없앤다. ② 복구도 생성과 동일한 유일성 검증을 거치게 해 데이터 정합성을 지킨다.
더 나은 설계 (+ 트레이드오프)
- 삭제 시 이름을 변형해 저장: 소프트 삭제 시
name에 삭제 시각/토큰을 덧붙여 저장하면 컬럼 레벨UNIQUE(name)을 그대로 유지하며 이름 재사용이 가능해진다. 다만 복구 시 원래 이름을 복원하는 로직과, 삭제된 이름으로 재조회하려는 감사/검색 경로가 추가로 필요하다. - 부분 유니크 인덱스(채택안): DB 제약이 의도를 정확히 표현해 애플리케이션 로직이 단순해진다. PostgreSQL/SQL Server 는 지원하지만 MySQL 은 직접 지원하지 않아(생성 컬럼+유니크 조합 등 우회 필요) DB 종류에 따라 이식성을 고려해야 한다.
- 아카이브 테이블로 분리: 삭제 시 원본에서 실제로 제거하고 별도
character_archive로 옮기면 UNIQUE 제약이 애초에 살아있는 행에만 적용된다. 복구는 아카이브에서 되돌리며 이름 충돌 재검증. 구조는 명확해지지만 조인/마이그레이션 복잡도가 늘어난다. - 트레이드오프: 부분 유니크 인덱스가 가장 적은 코드 변경으로 의도를 정확히 표현하지만, 미지원 DB 환경에서는 이름 변형이나 아카이브 테이블 방식이 대안이 된다.
면접 포인트
UNIQUE제약은 기본적으로 테이블의 모든 행에 적용된다 — 소프트 삭제처럼 논리적으로 존재하지 않는 행을 구분해야 하는 유일성 요구에는 부분 유니크 인덱스가 정답이다.- 애플리케이션 레벨 존재 검사와 DB 제약의 조건이 다르면, 검사는 통과했는데 제약 위반으로 실패하는 모순이 생긴다 — 둘의 조건을 반드시 일치시켜야 한다.
- 복구(soft-delete undo)는 생성과 동일한 유일성 규칙을 다시 만족해야 하는 연산이다 — 생성 경로에만 있는 검증을 복구 경로에도 반드시 적용해야 한다.