데이터베이스에 신규 회원 정보나 주문 데이터를 삽입할 때 무작위로 발생되는 MySQL ERROR 1062 (23000): Duplicate entry 에러를 마주한 경험이 있을 것이다.
시스템이 정상 동작하는 것처럼 보이다가도 동시 요청이 몰리는 이벤트 시점이나 대량 처리 작업 중에 갑자기 에러가 터져 서버 에러 로그를 더럽히곤 한다.
다만 정확한 발생 원인이나 동시성 환경에서의 데이터 레이스 해결책을 모른 채 단편적인 TRY-CATCH 예외 처리로만 무시하는 경우가 많다.
이번에는 MySQL ERROR 1062가 왜 발생하는지 원인을 정확히 짚어보고, 이를 안전하게 제어하는 DB 구문과 ON DUPLICATE KEY UPDATE 활용법을 완벽하게 정리해서 소개하겠다.
MySQL 테이블에서 기본키(PRIMARY KEY)나 유니크 인덱스(UNIQUE KEY)가 설정된 컬럼은 중복된 값을 허용하지 않는다.
이미 존재하고 있는 키 값으로 새로운 행(Row)을 INSERT 하려고 시도할 때, 스토리지 엔진은 데이터 정합성을 보호하기 위해 즉시 작업을 중단하고 1062 에러를 반환한다.
대부분의 개발자들은 애플리케이션 코드에서 미리 SELECT 문으로 데이터가 있는지 조회한 뒤, 없으면 INSERT 하고 있으면 UPDATE 하는 로직을 작성한다.
단일 스레드 테스트 환경에서는 이 방식이 문제없이 작동하는 것처럼 보인다.
그러나 실제 운영 환경처럼 여러 사용자가 동시에 요청을 보낼 경우, SELECT로 존재 여부를 확인한 순간과 실제 INSERT를 수행하는 짧은 시간 차이 사이에 다른 프로세스가 동일한 키 데이터를 먼저 삽입해 버리는 데이터 레이스(Race Condition)가 발생한다.
이 때문에 애플리케이션 레벨의 조건문 체크만으로는 동시성 이슈로 인한 ERROR 1062를 완벽히 막을 수 없다.
MySQL에서는 충돌을 애플리케이션이 아닌 데이터베이스 레이어에서 원자적(Atomic)으로 처리할 수 있는 세 가지 대표적인 구문을 제공한다.
| 방식 | 동작 원리 | 장점 | 단점 / 주의사항 |
|---|---|---|---|
| INSERT IGNORE | 중복 키 발생 시 에러 대신 경고만 발생시키고 해당 행 삽입을 무시함 | 구문이 단순하며 단종 데이터 수집 시 빠른 스킵 가능 | 중복 에러 외의 다른 컬럼 타입 불일치 등 유용한 경고까지 무시될 수 있음 |
| REPLACE INTO | 중복 키 발생 시 기존 행을 내부적으로 DELETE 한 후 새로운 행을 INSERT 함 | 항상 지정한 전체 데이터로 완전 교체 가능 | DELETE 후 INSERT되므로 AUTO_INCREMENT 피크가 급증하고 외래키 제약조건에 영향 줌 |
| ON DUPLICATE KEY UPDATE | 중복 키 발생 시 에러를 내지 않고 지정한 컬럼만 UPDATE를 실행함 | 기존 PK와 AUTO_INCREMENT가 유지되며 필요한 컬럼만 선택적 갱신 가능 | 가장 추천되는 표준 방식이며, 갱신할 컬럼 명시가 필요함 |
사용자의 로그인 일시와 누적 로그인 횟수를 기록하는 상황을 예로 들어보겠다.
동시 요청 시 에러가 터지는 나쁜 코드와 이를 완벽히 해결하는 올바른 쿼리 작성법을 비교해보자.
✗ 잘못된 코드(애플리케이션 검증 방식 - 동시 요청 시 ERROR 1062 발생)
<?php
// 동시성 환경에서 SELECT와 INSERT 사이 레이스 조건 발생
$stmt = $pdo->prepare("SELECT id FROM user_stats WHERE user_id = :user_id");
$stmt->execute([':user_id' => $userId]);
$row = $stmt->fetch();
if (!$row) {
// 동시 요청 시 다른 스레드가 그 사이에 이미 INSERT 했을 수 있음!
$insertStmt = $pdo->prepare("INSERT INTO user_stats (user_id, login_count) VALUES (:user_id, 1)");
$insertStmt->execute([':user_id' => $userId]);
} else {
$updateStmt = $pdo->prepare("UPDATE user_stats SET login_count = login_count + 1 WHERE user_id = :user_id");
$updateStmt->execute([':user_id' => $userId]);
}
?>✓ 올바른 코드(ON DUPLICATE KEY UPDATE 구문 적용)
INSERT INTO user_stats (user_id, login_count, last_login_at)
VALUES (1001, 1, NOW())
ON DUPLICATE KEY UPDATE
login_count = login_count + 1,
last_login_at = VALUES(last_login_at);MySQL 8.0.20 이상 버전에서는 VALUES() 함수 대신 아래와 같이 버추얼 알리아스를 사용하는 별칭 문법이 권장된다.
INSERT INTO user_stats (user_id, login_count, last_login_at)
VALUES (1001, 1, NOW()) AS new_row
ON DUPLICATE KEY UPDATE
login_count = login_count + 1,
last_login_at = new_row.last_login_at;결과/출력값:
최초 데이터 실행 시 1 row affected 메시지와 함께 신규 데이터가 삽입된다.
중복 키가 존재하는 상태에서 재실행 시 2 rows affected 메시지가 나오며 기존 기본키를 유지한 채 login_count가 1 증가하고 시간만 업데이트된다.
에러는 단 한 번도 발생하지 않는다.
실무 개발 시 흔히 범하는 실수 중 하나는 REPLACE INTO 구문을 단순히 UPDATE의 편리한 대안으로 착각하는 것이다.
REPLACE INTO는 이름과 달리 내부적으로 기존 행을 먼저 삭제(DELETE)하고 새로 삽입(INSERT)한다.
이로 인해 해당 행을 참조하고 있는 외래키(Foreign Key) 테이블에 ON DELETE CASCADE 옵션이 걸려 있다면 연관된 하위 데이터가 통째로 날아가는 치명적인 실수를 유발할 수 있다.
✗ 잘못된 사용: 외래키 연관 테이블이나 AUTO_INCREMENT 번호 보존이 중요한 곳에 REPLACE INTO 사용
✓ 올바른 사용: 기본키 정합성을 유지하면서 일부 컬럼만 안전하게 갱신하고 싶다면 반드시 ON DUPLICATE KEY UPDATE 사용
MySQL ERROR 1062는 데이터 정합성을 지키기 위한 중요한 신호지만, 올바른 쿼리 구조 설계를 통해 깔끔하게 제어할 수 있는 문제다.
애플리케이션 단의 섣부른 상태 체크 대신 DB 레벨의 원자적 커맨드를 사용하는 작은 습관이 모여서 고트래픽 환경에서도 무너지지 않는 견고한 시스템을 만든다는 점을 잊지 말자.
이 글의 ON DUPLICATE KEY UPDATE 실전 패턴을 참고해 기존 DB 액세스 로직을 점검하면, 1062 에러 없는 안정적인 백엔드 시스템을 얻을 수 있을 것이다.