Skip to content
hello tis
Go back

UPSERT, INSERT IGNORE 사용기 - 상태 변경과 멱등성 관리

개요

포인트 이력을 관리하면서 발생한 문제를 공유하려 한다. 현재 시스템은 이력 생성 → 포인트 사용 취소 시 해당 이력 취소 마킹(UPDATE) → 포인트 사용 취소 과정을 분산락 + 요청 멱등성으로 따닥 이슈를 방지하고 있다.

더 좋은 방법이 있을까 고민하던 중, 5천억건이 넘는 금융 데이터를 처리하는 토스 개발자에게 배우는 MySQL 강의를 보고 UPSERT, INSERT IGNORE를 적용해본 내용을 정리한다.

UPSERT - 상태 변경이 필요한 경우

ON DUPLICATE KEY UPDATE

ON DUPLICATE KEY UPDATE는 삽입 작업이 UNIQUE 제약조건을 위반하는 경우, 오류를 반환하지 않고 기존 행을 새 값으로 자동 업데이트하는 MySQL 문법이다.

INSERT INTO t1 (a,b,c) VALUES (1,2,3)
	ON DUPLICATE KEY UPDATE c=c+1;

ON DUPLICATE KEY UPDATE 주의사항

두 개 이상의 고유 인덱스가 일치하는 경우, 첫 번째 인덱스만 업데이트된다. 아래 예시처럼 ab 컬럼 모두 UNIQUE 제약조건이 있다면, 내부적으로 다음과 같이 동작한다.

UPDATE t1 SET c=c+1 WHERE a=1 OR b=2 LIMIT 1;

따라서 두 개 이상의 고유 인덱스가 있는 테이블에는 이 구문 사용을 권장하지 않는다.

INSERT vs UPDATE 제약조건 처리 차이

INSERT 에서 UNIQUE 제약 조건에 위배되도 statement 는 실행된다. 그러나 UPDATE 에서 UNIQUE 제약 조건에 위배되면 오류를 반환한다.

-- 테이블 생성
CREATE TABLE t (a SERIAL, b BIGINT NOT NULL, UNIQUE KEY (b));
INSERT INTO t VALUES ROW(1,1), ROW(2,2);
TABLE t;

-- 실행: INSERT의 중복은 허용되지만, UPDATE의 b=b-1은 고유 키 위반으로 거부됨
INSERT INTO t VALUES ROW(2,3), ROW(3,3) ON DUPLICATE KEY UPDATE a=a+1, b=b-1;

UPDATE 제약조건 오류를 무시하려면 IGNORE 키워드를 사용한다.

Table t;
# 이전 결과
# a, b
# 1,1
# 2,2

INSERT IGNORE INTO t VALUES ROW(2,3), ROW(3,3)
ON DUPLICATE KEY UPDATE a=a+1, b=b-1;

# 이후 결과
# a, b
# 1,1
# 2,2
# 3,3

ON DUPLICATE KEY UPDATE 단점

아래와 같이 실행했을 때, AUTO_INCREMENT 값 낭비되는 현상 확인이 가능하다.

CREATE DATABASE test_db;
CREATE TABLE test_table (a SERIAL, b BIGINT NOT NULL, UNIQUE KEY (b));

--- * 2회 실행
INSERT IGNORE INTO test_table VALUES ROW(2,3), ROW(3,3)
ON DUPLICATE KEY UPDATE a=a+1, b=b-1;

--- information_schema 캐시 갱신
ANALYZE TABLE test_table;

--- AUTO_INCREMENT 값 조회
SELECT TABLE_NAME, AUTO_INCREMENT
	FROM information_schema.TABLES
	WHERE TABLE_SCHEMA='test_db' AND TABLE_NAME='test_table';

이 때, information_schema 정보가 캐싱되서 이전 정보가 기록될 수 있으니 캐시 갱신이 필요하다.

ON DUPLICATE KEY UPDATE 관련 Lock 동작

UPSERT가 어떤 Lock을 사용하는지 실험해보았다. DB 격리 레벨은 REPEATABLE-READ다.

  1. 1번 세션에서 BEGINON DUPLICATE KEY UPDATE 실행. COMMIT 대기
  2. 2번 세션에서 BEGINON DUPLICATE KEY UPDATE 실행. COMMIT 대기
  3. 3번 세션에서 BEGINON DUPLICATE KEY UPDATE 실행. COMMIT 대기
  4. 4번 세션에서 락 정보 조회

실제 사용한 쿼리는 다음과 같다.

INSERT INTO users (username, email, password_hash, display_name, login_count)
VALUES ('john_doe', 'john@example.com', SHA2('pass', 256), 'John', 1)
ON DUPLICATE KEY UPDATE login_count = login_count + 1;

락 정보를 조회했을 때 결과를 공유한다.

ENGINE_TRANSACTION_IDLOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
539204TABLEIXGRANTEDnull
539205TABLEIXGRANTEDnull
539206TABLEIXGRANTEDnull
539204RECORDXGRANTED’john_doe’, 20001
539204RECORDXGRANTEDsupremum pseudo-record
539204RECORDX,REC_NOT_GAPGRANTED20001
539205RECORDX,INSERT_INTENTIONWAITINGsupremum pseudo-record
539206RECORDX,INSERT_INTENTIONWAITINGsupremum pseudo-record

분석한 내용을 공유한다.

TABLE-IX(Intention Exclusive) Lock 이란

테이블 전체 잠금과 행 잠금 간의 충돌을 빠르게 감지하기 위해서 테이블 수준에서 특정 행에 대해 행에 배타 잠금(X Lock)을 걸 예정임을 미리 표시한다.

RECORD-X(Exclusive) Lock 이란

행 수준의 배타 잠금으로, 해당 행에 대한 읽기와 쓰기를 모두 독점한다.

RECORD-INSERT_INTENTION Lock 이란

Intention lock은 테이블 수준 잠금으로, 트랜잭션이 나중에 테이블의 행에 대해 어떤 유형의 잠금(공유 또는 배타적)이 필요한지 표시한다. ON DUPLICATE KEY UPDATEIntention lock을 사용한다.

PRIMARYRECORD 데이터에서 LOCK_DATAsupremum pseudo-record로 설정된다. - > 현재 레코드에서 끝점까지 전부 대기한다.

INDEXLOCK_MODELOCK_DATA설명
usernameX’john_doe’, 20001john_doe 레코드 + 앞 갭
PRIMARYX,REC_NOT_GAP20001id=20001 레코드만
PRIMARYXsupremum pseudo-record마지막 레코드 ~ 끝 갭

INSERT IGNORE - 멱등성 관리가 필요한 경우

INSERT IGNORE

INSERT IGNORE는 삽입 시 UNIQUE 제약조건을 위반하면 오류를 발생시키지 않고 해당 행을 무시하는 MySQL 문법이다.

INSERT IGNORE INTO issue_history (transaction, company, history_id)
VALUES ('TX-001', 'EST', 123);

INSERT IGNORE 주의사항

AUTO_INCREMENT 값 증가

중복으로 무시된 경우에도 AUTO_INCREMENT 값이 증가한다. ON DUPLICATE KEY UPDATE와 동일한 동작이며, 3. UPSERT, INSERT IGNORE 사용기 - 상태 변경과 멱등성 관리 해당 글에서 AUTO_INCREMENT 값 증가되는 문제 확인 가능하다.

UPSERT 실사례 1 : 포인트 거래 발생 날짜 관리(Flag 기반 테이블 관리)

실사례 1 상황

포인트 정산시 과거 날짜는 변경이 적은 상황에서 전체를 스캔하는 것은 비효율적이다. 변경이 발생한 날짜를 표시(Change Flag)하고, 정산 시점에 표시된 날짜만 재계산(Lazy Evaluation)한다.

변경이 발생한 날짜만 정산하기 위해 플래그 기반 추적을 도입했다. 포인트 작업이 발생하면 해당 날짜에 Change Flag를 표시하고, 배치에서는 플래그가 있는 날짜만 재정산한다.

	   변경 플래그 테이블
┌─────────────┬────────────┐
│ target_date │ is_changed │
├─────────────┼────────────┼
│ 2025-01-10  │ true       │ ← 재정산 필요
│ 2025-01-11  │ false      │ ← 정산 완료
│ 2025-01-12  │ true       │ ← 재정산 필요
└─────────────┴────────────┘

실사례 1 문제

상태 전이로 표현하면 다음과 같다.

 [레코드 없음]

      │ 포인트 작업 발생

┌─────────────┐
│ is_changed  │
│   = true    │ ◀─────────────────┐
└──────┬──────┘                   │
       │                          │
       │ 정산 배치 완료               │ 포인트 작업 발생
       ▼                          │ (적립/사용/취소)
┌─────────────┐                   │
│ is_changed  │ ──────────────────┘
│   = false   │
└─────────────┘

실사례 1 원인

초기 구현은 다음과 같았다.

fun recordDate(historyDate: LocalDate) {
	// 1. SELECT 쿼리
	val nowRecord = repository.find(historyDate)
	// 2. 없으면 INSERT
		?: repository.save(PointAccountingRecord(historyDate))
	// 3. 조건부 UPDATE
	if (nowRecord.isChanged != historyDate.isChanged) {
		repository.update(nowRecord.change())
	}
}

동시성 문제 (Race Condition)

성능 문제

실사례 1 해결

UPSERT를 적용하여 단일 쿼리로 개선했다.

INSERT INTO point_accounting_record (target_date, is_changed)
VALUES (#{targetDate}, true)
ON DUPLICATE KEY UPDATE is_changed = true;

여전히 남은 문제: 불필요한 UPDATE

Change Flag 값이 이미 true여도 UPDATE 구문 자체는 실행된다. 값이 동일하면 MySQL이 실제 디스크 쓰기를 스킵하지만, 쿼리 파싱과 실행 비용은 발생한다.

DB 호출 자체를 줄이려면 애플리케이션 레벨 캐싱이 필요하다. 이미 true로 변경된 날짜는 캐시에 저장하고, 해당 날짜에 대한 추가 요청은 DB 호출 없이 무시하는 방식이다.

UPSERT 실사례 2 - 포인트 총액 계산 방법 (상태 스냅샷 관리)

실사례 2 상황

포인트 이력 테이블에 모든 적립, 사용, 취소 기록을 저장하고, 잔액이 필요할 때마다 이력을 집계하고 있다.

실사례 2문제

활성 사용자가 많아지면 이력이 계속 쌓이고, 매 조회마다 전체를 집계해야 한다. 데이터가 늘어날수록 조회 성능이 저하된다.

SELECT SUM(
    CASE
        WHEN action_type IN ('EARN', 'CANCEL_REDEEM') THEN initial_point
        WHEN action_type IN ('REDEEM', 'CANCEL_EARN') THEN -initial_point
    END
) AS balance
FROM point_history
WHERE user_id = ? AND is_cancel = false;

실사례 2해결

잔액을 매번 계산하지 않고, 포인트 작업 시점에 미리 계산된 잔액을 별도 테이블에 저장한다. 스냅샷 갱신은 포인트 작업과 같은 트랜잭션에서 처리하여 정합성을 보장한다.

-- 적립 시
INSERT INTO user_point_snapshot (user_id, total_point)
	VALUES (?, ?) AS new
	ON DUPLICATE KEY UPDATE total_point = total_point + new.total_point;

-- 사용 시
UPDATE user_point_snapshot
	SET total_point = total_point - ?
	WHERE user_id = ?;

캐싱과의 비교 했을 때, 포인트처럼 불일치가 치명적인 데이터는 트랜잭션으로 정합성을 보장할 수 있는 DB 스냅샷 방식이 적합하다. 불일치 문제를 우려한다면, 트랜잭션으로 정합성 보장할 수 있는 DB 스냅샷 방식이 적합하다.

항목DB 스냅샷Redis 캐싱
트랜잭션 정합성✅ DB 트랜잭션으로 보장❌ 별도 동기화 필요
인프라DB만 사용Redis 필요
데이터 유실낮음휘발 가능성 있음

고려사항: Row Lock 경합

동일 user_id에 대한 UPDATE가 집중되면 Row Lock 경합이 발생할 수 있다. 다만 InnoDB의 Row Lock은 해당 Row만 잠그므로, 특정 사용자의 작업이 다른 사용자에게 영향을 주지는 않는다.

트랜잭션이 오래 유지되면 Undo Log가 비대해질 수 있으므로, 트랜잭션 범위를 최소화하는 것이 중요하다. 특히 트랜잭션 내에서 외부 API 호출 등 지연이 발생할 수 있는 작업은 트랜잭션 밖으로 분리해야 한다.

현재 서비스에서는 기존 Redis 분산락 사용 시에도 락 경합이 거의 없는 수준이라, Row Lock 경합 문제는 크게 우려되지 않는다.

INSERT IGNORE 실사례 1 : 트랜잭션 ID 관리

실사례 1 상황 외부 제휴사에서 포인트 적립 요청이 들어올 때, 동일한 트랜잭션 ID로 중복 요청이 빈번했다. 동일한 요청은,포인트가 중복 적립되지 않도록 방지해야 했다. 현재는 분산락과 중복 검증 쿼리로 이를 처리하고 있다.

@DistributedLock(key = "포인트 발급 키")
fun issuePoint(...) {
    // 1. 트랜잭션 ID 중복 검증 - 이미 처리된 요청이면 예외 발생
    issueHistoryService.validIssue(command.company, command.transaction)
    // 2. 트랜잭션 실행
    return transactionHandler.runInTransaction {
        // 3. 이력 저장
        issueService.issuePoint(...)
        // 4. 포인트 적립
        earnService.earnPoint(...)
    }
}

실사례 1 문제 : 비효율

실사례 1 해결: INSERT IGNORE 적용

분산락 없이 DB 제약조건만으로 중복을 방지할 수 있다면, 구조가 훨씬 단순해진다.

-- 1. 선점 시도 (affected rows로 중복 판단)
INSERT IGNORE INTO issue_history (transaction, company, history_id)
VALUES ('TX-001', 'EST', 0);
-- affected rows = 1 → 신규, 0 → 중복

affected rows로 중복을 판단하면서 문제를 해결할 수 있다.

마무리하며

문제 해결 방법은 다양하다. 같은 문제라도 비효율적인 방식부터 효율적인 방식까지 여러 선택지가 존재한다. 결국 내가 얼마나 알고 있느냐에 따라 선택할 수 있는 해결책의 폭이 달라진다. 이번 경험을 통해 꾸준히 공부하는 습관이 제품의 유지보수성을 높이는 데 직접적인 영향을 준다는 것을 다시 한번 느꼈다.

UPSERT는 처음 접하는 개념이라 도입하는 데 우려가 컸다. 성능 이슈는 없을지, 내가 모르는 동작으로 인해 예상치 못한 문제가 발생하지는 않을지 걱정이 앞섰다. 하지만 이번 기회에 동작 원리와 장단점을 분석하고, Lock 동작까지 직접 실험하며 확인했다. 그 과정에서 UPSERT를 제대로 이해하게 되었고, 덕분에 실제 적용에는 큰 어려움이 없었다.

이번 글에서는 INSERT IGNORE를 활용해 분산락 없이 멱등성을 보장하는 방법을 정리했다. DB의 UNIQUE 제약조건이 중복을 방지하고, affected rows 값 하나로 신규와 중복을 판단할 수 있게 되었다. 분산락이 빠지면서 Redis 의존성도 줄었다.

앞서 작성한 UPSERT 와 마찬가지로, 이번에도 새로운 방식을 도입하기 전에 동작 원리를 먼저 파악했다. 그 과정에서 INSERT IGNOREON DUPLICATE KEY UPDATE의 차이, 각각의 적합한 사용처를 명확히 이해하게 되었다.

단순한 차이지만, 상황에 맞는 선택이 코드의 의도를 명확하게 만들어준다.


Share this post:

Previous Post
토스 고양이 키우기를 참고한 게이미피케이션 PoC
Next Post
온라인 추첨 서비스 사이드 프로젝트 회고