유니크 인덱스 걸었는데 왜 중복 데이터가 들어왔죠?

최혜미·2026년 4월 13일

트러블슈팅

목록 보기
27/28
post-thumbnail

안녕하세요 백엔드 개발자 최혜미입니다.

최근 운영 서비스에서 발생했던 장애를 해결하는 과정에서 꽤 흥미로운 사실을 발견해 공유하고자 블로그를 쓰게 되었습니다 ㅎㅎ

여러분은 DB의 유니크 인덱스를 100% 신뢰하시나요?

얼마 전 저희 서비스에서 유니크 제약 조건을 무시하고 냅다 중복 계정이 생성되는 장애가 발생했습니다.

분명 인덱스를 걸어두었는데, 대체 어떻게 중복 데이터가 문을 열고 들어온 걸까요??🤔

단순한 로직 결함인 줄 알았던 이 사건의 배후에는 우리가 흔히 간과하는 MySQL의 인덱스 처리 방식이 숨어 있었습니다.
실제 장애를 해결하며 범인을 추적했던 과정을 소개하고자 합니다.


발단: "회원이 한 명인데, DB에는 세 명?"

사건은 운영팀의 다급한 메시지로 시작되었습니다.
특정 회원의 체험단 등록이 계속 오류가 난다는 제보였습니다.

원인을 파악해 보니, 중복 회원 기준인 이메일+전화번호가 완전히 동일한 활성 계정이 중복으로 존재하고 있었습니다...

살짝 핑계를 대자면 당연히 저는 유니크 인덱스를 믿고 일부 가입 로직에서 중복회원을 고려하지 않았고, 이 중복 데이터 때문에 시스템이 어떤 유저가 진짜인지 판단하지 못하고 에러를 뱉어버린 거죠
(사실 제 잘못이 제일 큽니다...😢)

이때 저는 큰 의문에 빠졌습니다.

"아니, (email, phone, deleted_at) 복합 유니크 인덱스가 있는데 이게 어떻게 가능해?"


범인은 바로 NULL

MySQL 공식 문서를 뒤져보니 아주 허무하게도 범인은 NULL이었습니다.

"A UNIQUE index permits multiple NULL values for columns that can contain NULL."
(UNIQUE 인덱스는 NULL 값을 포함할 수 있는 컬럼에 대해 여러 개의 NULL을 허용한다.)

서비스의 회원 탈퇴는 Soft Delete 방식을 사용하고 있어, 활성 유저는 deleted_at 컬럼이 NULL입니다.

문제는 MySQL이 NULL을 '알 수 없는 값'으로 취급한다는 점입니다!

NULL과 또 다른 NULL은 서로 같은지 비교조차 할 수 없기 때문에, 유니크 인덱스는 이들을 중복으로 보지 않고 통과시켜 버립니다.


🔍 진짜 그럴까? 로컬에서 직접 확인해보기

위처럼 공식문서에 대놓고 나와있지만 진짜 그런지 굳이굳이 테스트를 해보는 것 또한 개발자의 미덕이기에 로컬에서 직접 확인을 해보았습니다

1. 테스트 테이블 생성

먼저 저희 서비스와 유사하게 (email, phone, deleted_at) 복합 유니크 인덱스를 가진 테이블을 만듭니다.

CREATE TABLE test_user_const (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(50) NOT NULL,
    phone VARCHAR(20) NOT NULL,
    deleted_at DATETIME NULL,
    UNIQUE KEY uk_email_phone_deleted (email, phone, deleted_at)
) ENGINE=InnoDB;

2. 정상 케이스 확인

삭제된 데이터들끼리 정보가 같다면, 이때는 인덱스가 아주 잘 작동해서 중복을 막아줍니다.

-- 삭제된 유저 데이터 삽입
INSERT INTO test_user_const (email, phone, deleted_at) 
VALUES ('user2@test.com', '01055556666', '2026-04-13 10:00:00');

-- 똑같은 데이터 한 번 더 삽입 시도 (에러 발생!)
INSERT INTO test_user_const (email, phone, deleted_at) 
VALUES ('user2@test.com', '01055556666', '2026-04-13 10:00:00');

3. 🔥 대망의 NULL 중복 테스트 (장애 재현)

이제 대망의 deleted_at IS NULL 상태인 활성 유저 데이터를 넣어보겠습니다.

-- 첫 번째 활성 유저 삽입 (성공)
INSERT INTO test_user_const (email, phone, deleted_at) 
VALUES ('duplicate@test.com', '01099998888', NULL);

-- 똑같은 정보로 두 번째 활성 유저 삽입 (과연?)
INSERT INTO test_user_const (email, phone, deleted_at) 
VALUES ('duplicate@test.com', '01099998888', NULL);

-- 결과 확인
SELECT * FROM test_user_const WHERE email = 'duplicate@test.com';

놀랍게도 duplicate@test.com 계정이 두 개나 생성된 것을 보실 수 있습니다. MySQL 입장에서는 "NULL은 비교할 수 없는 값"이기에, 인덱스 체크를 그냥 통과시켜 버린 것이죠.😱


🤔 어떻게 해결했을까? (현실적인 방어 로직)

물론 DB 레벨에서 가상 컬럼을 만들어 유니크 제약을 거는 '가장 근본적인 방법'도 당연히 검토해 보았습니다.
하지만 실무에서는 늘 현실적인 제약이 따르기 마련이죠...

당장 운영 중인 DB 스키마를 변경하는 건 리스크가 꽤 컸고,
무엇보다 이미 오염된 데이터들을 찾아 정리해야 하는 선행 과제가 먼저 해결되어야 했습니다.

그래서 저는 "지금 당장 내가 할 수 있는 가장 확실한 방법" 부터 시작하기로 했습니다!
바로 애플리케이션 레벨에서 방어선을 구축하는 것이었죠.

1. 우선순위 기반 로그인

사실 이번 장애는 다행히도 '체험단 등록'이라는 관리자 기능을 수행하다가 먼저 발견되었습니다.
관리자 페이지에서의 오류도 문제지만, 사실 진짜 큰 문제는 사용자들의 로그인 기능이었어요.

설령 예기치 못한 이슈로 DB 데이터가 조금 오염되었더라도, 사용자 입장에서는 서비스 이용에 차질이 없어야 하니까요!

만약 중복 계정이 발견되더라도 사용자가 당황하지 않게 시스템이 자동으로 각 계정의 주문 통계(앱 주문 + 커머스 주문 + 최신 주문)를 확인하도록 로직을 수정했습니다.

활동 내역이 가장 많은. 즉 '진짜'일 가능성이 높은 계정을 선택해 로그인시킴으로써 사용자는 장애를 느끼지 못하도록 조치했습니다.

2. 실시간 장애 알림 (FATAL 로그)

앞서 말씀드린 1번 해결책은 사실 서비스 연속성을 위한 임시방편일 뿐입니다.
결국 중복 데이터라는 근본적인 문제를 해결하려면 관리자가 직접 데이터를 확인하고 수동으로 정리해 주는 과정이 반드시 필요하죠.

이를 위해 중복 데이터가 발견되는 즉시 운영팀이 인지할 수 있도록 즉시 대응이 필요한 FATAL 급 알림을 트리거하도록 설정했습니다.
덕분에 문제가 발생한 시점에 즉시 알림을 받고,
사후에라도 어떤 데이터를 남기고 정리해야 할지 명확하게 판단할 수 있는 가이드라인을 마련할 수 있었습니다.

3. 회원가입 로직 강화

사실상 이번 장애를 막는 가장 근본적인 해결책입니다.

기존의 정상적인 회원가입 경로라면 중복 계정이 생성되지 않게 처음부터 잘 구축해 두었지만, 서비스가 확장되면서 '관리자 수동 등록' 같은 비정기적인 가입 경로들이 하나둘 생겨난 것이 화근이었습니다.

이번 장애 역시 정상 경로가 아닌 바로 이런 부가적인 경로를 통해 가입하면서 중복 체크를 교묘히 피해 갔던 것이었죠.

다시는 이런 사태가 발생하지 않도록 모든 회원가입 경로를 전수 조사했습니다. 이제는 어떤 루트로 가입을 시도하더라도 동일한 방어 로직을 거치도록 일원화하여 중복 계정이 생성될 틈을 원천 차단했습니다.


이번 장애를 대응하면서 다시 한번 뼈저리게 느꼈습니다.

"시스템은 언제나 오염될 수 있다."

중복 데이터를 확인하자마자 제가 가장 먼저 한 일은 '깡통 계정'을 삭제하는 것이었지만,
그보다 더 중요하게 생각하며 두 번째로 한 일은 설령 데이터가 꼬여있더라도 사용자는 평소처럼 서비스를 이용할 수 있게 시스템을 다듬는 것이었습니다.

완벽한 시스템은 없을지도 모릅니다. 하지만 예외 상황에서도 멈추지 않는 시스템은 우리 손으로 충분히 만들 수 있죠!

늘 느끼는 거지만 견고한 시스템을 구축하고 싶어도 참 쉽지가 않습니다.
요즘 AI의 발전이 눈부시다고들 하지만 저는 이상하게도 AI를 더 잘 쓰기 위해서,
그리고 이런 예외 상황을 정확히 판단하기 위해서 기본기를 더 많이 공부해야 한다는 생각이 깊어지는 하루입니다.

결국 컨벤션이든 방어 로직이든, 사람이 아니라 시스템이 일하게 만드는 게 최고라는 걸 다시 한번 느꼈습니다...

다음에는 장애 회고 말고 다른 주제로 글을 쓰면 좋겠네요
읽어주셔서 감사합니다!

0개의 댓글