INSERT ... ON DUPLICATE KEY UPDATE는 같은 기본 키(또는 UNIQUE 키)의 행이 없으면 INSERT하고, 있으면 INSERT 대신 뒤에 적은 UPDATE를 실행하는 MySQL 문법이다. 흔히 upsert라고 부른다. '확인하고 나서 쓰기'를 SQL 한 문장으로 묶기 때문에, 동시에 들어온 요청이 그 사이에 끼어들 틈이 없다. 조건을 붙인 UPDATE도 같은 이유로 함께 쓴다.
예시로 하루 사용 횟수를 세는 테이블을 쓴다. 날짜가 기본 키라 날짜마다 행이 하나만 생긴다.
CREATE TABLE daily_usage
(
usage_date DATE PRIMARY KEY,
used_count INT NOT NULL
);오늘 행이 없으면 만들기
INSERT INTO daily_usage (usage_date, used_count)
VALUES ('2026-11-10', 0)
ON DUPLICATE KEY UPDATE used_count = used_count;0이 들어가는 자리는 두 곳이고 뜻이 다르다.
VALUES의0: 행이 없어서 새로 만들 때 넣는 값이다.UPDATE뒤의 식: 행이 이미 있을 때 실행할 UPDATE다.used_count = used_count는 자기 값을 자기에게 넣어 아무것도 바꾸지 않는다. '있으면 에러를 내지 말고 그대로 두라'는 뜻이다.
| 상황 | 결과 |
|---|---|
| 오늘 행이 없음 | used_count가 0인 오늘 행이 생김 |
| 오늘 행이 있고 값이 5 | 5 그대로 |
| 날짜가 바뀜 | 새 날짜 행이 0으로 생기고 어제 행은 그대로 |
날짜가 바뀌면 새 행이 생기므로, 매일 0으로 되돌리는 작업을 따로 둘 필요가 없다.
UPDATE 뒤를 used_count = 0으로 쓰면 이미 센 횟수가 이 문장을 부를 때마다 0으로 돌아간다. '없으면 만들기'와 '있으면 초기화'는 다른 동작이므로, 테스트로 '센 뒤에 다시 불러도 값이 유지되는가'를 확인한다.
INSERT IGNORE를 쓰지 않는 이유
'이미 있으면 넘어가기'만 원한다면 INSERT IGNORE도 떠오른다. 그런데 INSERT IGNORE는 키 중복만 넘기는 것이 아니라 다른 에러도 경고로 낮추고 값을 고쳐서 넣는다. MySQL 8.4에서 실행한 결과는 이렇다.
CREATE TABLE ig (id INT PRIMARY KEY, n TINYINT NOT NULL, name VARCHAR(3) NOT NULL);
INSERT IGNORE INTO ig VALUES (1, 300, 'abcdef'); -- n=127, name='abc'로 저장됨
INSERT IGNORE INTO ig VALUES (2, 1, NULL); -- name=''로 저장됨, 경고만 남음- 타입 범위를 넘는 값은 가장 가까운 값으로 바뀐다(
300→127). - 너무 긴 문자열은 잘린다(
'abcdef'→'abc'). NOT NULL컬럼의 NULL은 그 타입의 암묵적 기본값으로 바뀐다(NULL→'').
넘기고 싶은 것은 '행이 이미 있음' 하나뿐이므로, 그 경우만 짚는 ON DUPLICATE KEY UPDATE를 쓴다.
한도 안에서만 올리기
UPDATE daily_usage
SET used_count = used_count + 1
WHERE usage_date = '2026-11-10'
AND used_count < 6;이 UPDATE는 바뀐 행 수를 돌려준다. 1이면 한도 안이라 올렸다는 뜻이고, 0이면 한도에 막혔다는 뜻이다. MySQL 8.4에서 값이 5일 때 두 번 실행하면 첫 번째는 1, 두 번째는 0을 돌려준다.
SELECT ROW_COUNT(); -- 1, 그다음 실행 뒤에는 0읽고 나서 쓰면 이 보장이 깨진다.
요청 A: SELECT used_count → 5 (한도 6 미만이라 통과)
요청 B: SELECT used_count → 5 (통과)
요청 A: UPDATE ... = 6
요청 B: UPDATE ... = 7 ← 한도 초과조건을 UPDATE의 WHERE 안에 넣으면, InnoDB가 그 행에 잠금을 걸고 UPDATE를 하나씩 차례로 처리한다. 뒤에 온 UPDATE는 앞의 UPDATE가 커밋된 값(6)을 보고 조건 used_count < 6이 거짓이 되어 0을 돌려준다.
오늘 행이 없으면 UPDATE는 항상 0을 돌려준다. 그래서 '없으면 만들기'를 먼저 부르고, 그다음 '한도 안에서 올리기'를 부른다.
Spring Data JPA에서
interface DailyUsageRepository : JpaRepository<DailyUsage, LocalDate> {
@Modifying
@Query(
value = """
INSERT INTO daily_usage (usage_date, used_count) VALUES (:date, 0)
ON DUPLICATE KEY UPDATE used_count = used_count
""",
nativeQuery = true,
)
fun createIfAbsent(date: LocalDate): Int
// 1이면 올림, 0이면 한도에 막힘
@Modifying
@Query(
value = """
UPDATE daily_usage SET used_count = used_count + 1
WHERE usage_date = :date AND used_count < :limit
""",
nativeQuery = true,
)
fun incrementIfBelowLimit(date: LocalDate, limit: Int): Int
}ON DUPLICATE KEY UPDATE는 MySQL 문법이라 JPQL로 쓸 수 없어 네이티브 쿼리로 쓴다. @Modifying 메서드의 반환 타입을 Int로 두면 바뀐 행 수를 받을 수 있다. 다른 DB에서는 같은 일을 PostgreSQL의 INSERT ... ON CONFLICT DO NOTHING/DO UPDATE나 표준 SQL의 MERGE로 한다.
여러 워커가 같은 행을 두고 경쟁하는 다른 패턴은 SELECT FOR UPDATE SKIP LOCKED에 정리했다.