짧은 트랜잭션과 멱등 재시도 설계
MySQL 트랜잭션을 짧게 유지하고 잠금 오류와 commit 결과 불확실성에 안전한 멱등 재시도 구조를 설계하는 방법을 정리한다.
짧은 트랜잭션과 멱등 재시도 설계
트랜잭션은 데이터의 원자성을 보장하지만, 오래 열어 둘수록 잠금 보유 시간과 undo 보존 기간이 늘어나고 장애의 영향 범위도 커진다. 반대로 트랜잭션을 짧게 만들기만 하고 애플리케이션 재시도를 고려하지 않으면 deadlock, lock wait timeout, 네트워크 단절, writer failover 뒤에 같은 업무가 두 번 실행될 수 있다.
따라서 운영 환경의 트랜잭션 설계는 다음 두 목표를 함께 만족해야 한다.
- 데이터베이스 안에서 수행하는 작업 단위를 짧고 명확하게 제한한다.
- 실패하거나 결과를 확인하지 못한 요청을 다시 보내도 업무 효과가 한 번만 발생하도록 만든다.
이 글에서는 MySQL 8.0 이상을 기준으로 짧은 트랜잭션의 내부 효과, 재시도 가능한 오류와 commit 결과 불확실성의 차이, idempotency key와 Outbox를 이용한 구현 패턴을 설명한다.
1. 짧은 트랜잭션이 필요한 내부 이유
InnoDB 트랜잭션이 변경한 레코드 잠금은 일반적으로 COMMIT 또는 ROLLBACK까지 유지된다. SQL 한 문장의 실행 시간이 짧더라도 애플리케이션이 트랜잭션을 연 채 외부 API 응답, 사용자 입력, 큐 처리, 긴 계산을 기다리면 잠금 수명은 그 대기 시간만큼 늘어난다.
또한 consistent read가 오래 유지되면 Read View보다 새로운 변경 이력을 즉시 purge할 수 없다. 이 현상은 특정 세션의 CPU 사용률보다 먼저 다음과 같은 간접 증상으로 나타날 수 있다.
- 다른 트랜잭션의 row lock 대기와 deadlock 증가
- undo history 증가와 purge 지연
- 변경된 레코드 버전을 따라가는 읽기 비용 증가
- 배포나 DDL이 기다리는 Metadata Lock 문제
- connection pool 고갈과 요청 지연의 연쇄 확산
sequenceDiagram
participant A as 애플리케이션 요청 A
participant DB as MySQL Writer
participant X as 외부 서비스
participant B as 애플리케이션 요청 B
A->>DB: START TRANSACTION
A->>DB: 행 변경 및 X lock 획득
A->>X: 트랜잭션을 연 채 원격 호출
Note over DB: lock과 undo 관련 상태가 유지됨
B->>DB: 같은 행 변경 요청
DB-->>B: 대기 또는 deadlock/timeout
X-->>A: 늦은 응답
A->>DB: COMMIT
DB-->>B: 대기 해소
핵심은 SQL 개수가 아니라 첫 잠금 획득부터 transaction 종료까지의 벽시계 시간이다. 짧은 트랜잭션은 보통 다음 순서로 구성한다.
- 트랜잭션 밖에서 요청 형식과 권한을 검증한다.
- 원격 조회나 큰 계산을 가능한 한 먼저 끝낸다.
START TRANSACTION이후에는 필요한 행만 읽고 즉시 변경한다.- 모든 데이터베이스 변경을 마치면 곧바로
COMMIT한다. - 이메일, webhook, 메시지 발행과 같은 외부 효과는 commit 이후 또는 Outbox dispatcher에서 처리한다.
2. 트랜잭션 경계는 업무 불변식을 기준으로 정한다
트랜잭션이 짧아야 한다는 이유로 하나의 업무 불변식을 여러 commit으로 임의 분할하면 원자성을 잃는다. 예를 들어 주문 상태 변경과 재고 차감이 항상 함께 성립해야 한다면 두 변경은 같은 트랜잭션에 있어야 한다. 대신 트랜잭션 안에 둘 필요가 없는 작업을 밖으로 이동해야 한다.
| 작업 | 권장 위치 | 이유 |
|---|---|---|
| 입력 형식·정적 정책 검증 | 트랜잭션 전 | 실패가 DB 상태와 무관함 |
| 원격 결제사 조회 | 트랜잭션 전 또는 별도 단계 | 네트워크 지연 동안 row lock을 잡지 않음 |
| 현재 잔액 확인과 차감 | 동일 트랜잭션 | 동시 변경으로부터 업무 불변식을 보호함 |
| 업무 결과와 idempotency record 저장 | 동일 트랜잭션 | 중복 요청과 실제 효과를 원자적으로 연결함 |
| 이벤트 발행 의도 저장 | 동일 트랜잭션의 Outbox | DB commit과 메시지 발행 사이의 유실 구간을 제거함 |
| 실제 webhook·메시지 전송 | commit 이후 | 원격 대기와 실패를 DB 트랜잭션에서 분리함 |
한 트랜잭션 안에서도 잠금 순서는 일정해야 한다. 여러 계좌를 갱신하는 송금이라면 모든 코드 경로가 작은 계좌 ID부터 잠그는 식으로 순서를 통일한다. 이 규칙은 deadlock을 완전히 제거하지는 않지만 서로 반대 순서로 잠그는 전형적인 순환 대기를 크게 줄인다.
3. 재시도해야 하는 실패와 결과가 불확실한 실패
모든 오류를 같은 방식으로 재시도하면 안 된다. 운영 코드는 최소한 다음 세 범주를 구분해야 한다.
3.1 명확히 rollback된 충돌
대표적인 예는 다음과 같다.
ERROR 1213 (40001): Deadlock found when trying to get lockERROR 1205 (HY000): Lock wait timeout exceeded- serialization conflict에 해당하는
SQLSTATE 40001
Deadlock victim이 된 트랜잭션은 전체가 rollback되므로 새 트랜잭션에서 업무 단위를 다시 실행할 수 있다. 반면 기본 innodb_rollback_on_timeout=OFF 환경의 lock wait timeout은 실패한 문장만 rollback될 수 있다. 애플리케이션은 오류 1205를 받으면 현재 트랜잭션을 명시적으로 ROLLBACK하고 처음부터 다시 시작해야 한다. 실패한 문장 다음에 그대로 COMMIT하면 선행 문장만 반영되는 부분 성공이 생길 수 있다.
3.2 재시도해도 해결되지 않는 업무 오류
다음 오류는 일반적으로 같은 입력으로 즉시 재시도할 대상이 아니다.
- 잔액 부족, 재고 부족, 허용되지 않은 상태 전이
- foreign key 위반이나
NOT NULL위반 - SQL 문법 오류와 존재하지 않는 객체
- 권한 오류
이 오류를 무조건 retry loop에 넣으면 부하만 증가하고 실제 결함 탐지를 늦춘다.
3.3 commit 결과 불확실성
가장 주의할 상황은 클라이언트가 COMMIT을 보낸 뒤 성공 응답을 받기 전에 연결이 끊기는 경우다. 서버가 commit했는지 rollback했는지 클라이언트 관점에서 확정할 수 없다. 이때 새 transaction에서 업무 SQL을 단순 반복하면 첫 실행이 이미 반영된 경우 중복 차감이나 중복 주문이 발생한다.
이 문제는 “연결 오류를 한 번 더 시도한다”는 정책으로 해결되지 않는다. 요청마다 안정적인 idempotency key를 부여하고, 같은 key의 재요청이 이전 결과를 조회하도록 설계해야 한다.
4. idempotency key와 업무 효과를 같은 트랜잭션에 저장한다
idempotency key는 사용자가 새로고침할 때마다 바뀌는 임의 값이 아니다. 동일한 논리 요청의 모든 전송 시도에서 같은 값이어야 한다. API gateway가 생성할 수도 있고 클라이언트가 생성해 전달할 수도 있지만, 재시도 계층 전체에서 보존되어야 한다.
다음 예제는 request_key의 UNIQUE 제약으로 처리권을 한 번만 획득하고, 계좌 차감과 결과 저장을 같은 transaction에서 수행한다. 예제의 INSERT IGNORE는 제어된 schema에서 duplicate key를 간결하게 재현하기 위한 것이다. 실제 코드에서는 duplicate key만 분리 처리하고 데이터 잘림이나 다른 경고까지 숨기지 않는 방식을 권장한다.
DROP TABLE IF EXISTS payment_request;
DROP TABLE IF EXISTS account_balance;
CREATE TABLE account_balance (
account_id BIGINT PRIMARY KEY,
balance BIGINT NOT NULL,
CHECK (balance >= 0)
) ENGINE=InnoDB;
CREATE TABLE payment_request (
request_key VARCHAR(64) PRIMARY KEY,
account_id BIGINT NOT NULL,
amount BIGINT NOT NULL,
status ENUM('PROCESSING', 'COMPLETED', 'REJECTED') NOT NULL,
result_balance BIGINT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
completed_at TIMESTAMP(6) NULL
) ENGINE=InnoDB;
INSERT INTO account_balance(account_id, balance) VALUES (1, 50000);
START TRANSACTION;
INSERT IGNORE INTO payment_request(request_key, account_id, amount, status)
VALUES ('pay-20260812-0001', 1, 10000, 'PROCESSING');
SET @first_claimed = ROW_COUNT();
UPDATE account_balance
SET balance = balance - 10000
WHERE account_id = 1
AND balance >= 10000
AND @first_claimed = 1;
SET @first_effect = ROW_COUNT();
UPDATE payment_request
SET status = IF(@first_effect = 1, 'COMPLETED', 'REJECTED'),
result_balance = (SELECT balance FROM account_balance WHERE account_id = 1),
completed_at = CURRENT_TIMESTAMP(6)
WHERE request_key = 'pay-20260812-0001'
AND @first_claimed = 1;
COMMIT;
START TRANSACTION;
INSERT IGNORE INTO payment_request(request_key, account_id, amount, status)
VALUES ('pay-20260812-0001', 1, 10000, 'PROCESSING');
SET @retry_claimed = ROW_COUNT();
UPDATE account_balance
SET balance = balance - 10000
WHERE account_id = 1
AND balance >= 10000
AND @retry_claimed = 1;
COMMIT;
SELECT @first_claimed AS first_claimed,
@first_effect AS first_effect,
@retry_claimed AS retry_claimed;
SELECT account_id, balance FROM account_balance;
SELECT request_key, amount, status, result_balance
FROM payment_request;
DROP TABLE payment_request;
DROP TABLE account_balance;
실행 결과(MySQL 8.0.x):
아래 출력은 준비·정리 DDL과 반복적인 성공 메시지를 줄이고, 처리권 획득과 최종 업무 상태를 보여 주는 부분만 발췌한 것이다.
mysql> CREATE TABLE account_balance (...);
Query OK, 0 rows affected
mysql> CREATE TABLE payment_request (...);
Query OK, 0 rows affected
mysql> INSERT INTO account_balance(account_id, balance) VALUES (1, 50000);
Query OK, 1 row affected
mysql> INSERT IGNORE INTO payment_request(...)
-> VALUES ('pay-20260812-0001', 1, 10000, 'PROCESSING');
Query OK, 1 row affected
mysql> UPDATE account_balance
-> SET balance = balance - 10000
-> WHERE account_id = 1 AND balance >= 10000 AND @first_claimed = 1;
Query OK, 1 row affected
Rows matched: 1 Changed: 1 Warnings: 0
mysql> INSERT IGNORE INTO payment_request(...)
-> VALUES ('pay-20260812-0001', 1, 10000, 'PROCESSING');
Query OK, 0 rows affected
mysql> SELECT @first_claimed AS first_claimed,
-> @first_effect AS first_effect,
-> @retry_claimed AS retry_claimed;
+---------------+--------------+---------------+
| first_claimed | first_effect | retry_claimed |
+---------------+--------------+---------------+
| 1 | 1 | 0 |
+---------------+--------------+---------------+
1 row in set
mysql> SELECT account_id, balance FROM account_balance;
+------------+---------+
| account_id | balance |
+------------+---------+
| 1 | 40000 |
+------------+---------+
1 row in set
mysql> SELECT request_key, amount, status, result_balance FROM payment_request;
+-------------------+--------+-----------+----------------+
| request_key | amount | status | result_balance |
+-------------------+--------+-----------+----------------+
| pay-20260812-0001 | 10000 | COMPLETED | 40000 |
+-------------------+--------+-----------+----------------+
1 row in set
이 패턴에서 중요한 것은 세 가지다.
- 첫 요청은
first_claimed=1로 처리권을 얻고 잔액을 한 번 차감한다. - 같은 key의 재시도는
retry_claimed=0이므로 업무 효과를 반복하지 않는다. - 재시도 호출자는 저장된
status와result_balance를 읽어 첫 요청의 결과를 반환할 수 있다.
idempotency record만 먼저 별도 transaction으로 commit하고 실제 업무 변경을 나중에 수행하면 PROCESSING 상태에서 영구 정지할 수 있다. 반대로 업무 변경만 commit하고 key 저장을 실패하면 중복 실행을 막지 못한다. 단일 DB 안에서 원자적으로 끝낼 수 있는 작업은 반드시 같은 transaction에 둔다.
요청 key 재사용도 검증해야 한다
같은 request_key로 금액이나 대상 계좌가 다른 요청이 들어오면 이전 결과를 그대로 반환해서는 안 된다. 저장된 요청의 핵심 입력을 비교해 일치할 때만 replay로 처리하고, 다르면 409 Conflict와 같은 업무 오류로 거부해야 한다. 필요하면 정규화된 입력의 hash를 idempotency record에 함께 저장한다.
5. 외부 메시지는 Transactional Outbox로 분리한다
DB commit과 메시지 broker 발행을 하나의 로컬 transaction으로 묶을 수 없는 경우가 많다. 다음과 같이 순차 처리하면 두 종류의 불일치가 생긴다.
- DB를 먼저 commit한 뒤 메시지 전송에 실패하면 이벤트가 유실된다.
- 메시지를 먼저 전송한 뒤 DB transaction이 rollback되면 존재하지 않는 변경이 외부에 알려진다.
Transactional Outbox는 업무 행과 “발행해야 할 이벤트”를 같은 DB transaction에 저장한다. 별도 dispatcher가 commit된 Outbox를 읽어 전송하고, 소비자도 event_key를 기준으로 중복을 제거한다.
flowchart LR
R[API 요청과 idempotency key] --> T[짧은 DB 트랜잭션]
T --> B[업무 테이블 변경]
T --> O[Outbox 행 저장]
B --> C{COMMIT}
O --> C
C -->|성공| D[Dispatcher]
D --> M[메시지 브로커 또는 Webhook]
M --> K[소비자 중복 제거]
C -->|응답 유실| Q[같은 key로 상태 조회]
다음 축소 예제는 주문과 Outbox가 함께 commit되고, 같은 event_key의 중복 저장이 차단되는 것을 보여준다.
DROP TABLE IF EXISTS event_outbox;
DROP TABLE IF EXISTS sales_order;
CREATE TABLE sales_order (
order_id BIGINT PRIMARY KEY,
request_key VARCHAR(64) NOT NULL UNIQUE,
order_status VARCHAR(20) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE event_outbox (
event_id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_key VARCHAR(96) NOT NULL UNIQUE,
aggregate_type VARCHAR(32) NOT NULL,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
published_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
KEY ix_outbox_dispatch (published_at, event_id)
) ENGINE=InnoDB;
START TRANSACTION;
INSERT INTO sales_order(order_id, request_key, order_status)
VALUES (9001, 'order-20260812-0001', 'CREATED');
INSERT INTO event_outbox(
event_key, aggregate_type, aggregate_id, event_type, payload
) VALUES (
'order-9001-created', 'ORDER', 9001, 'OrderCreated',
JSON_OBJECT('order_id', 9001, 'status', 'CREATED')
);
COMMIT;
INSERT IGNORE INTO event_outbox(
event_key, aggregate_type, aggregate_id, event_type, payload
) VALUES (
'order-9001-created', 'ORDER', 9001, 'OrderCreated',
JSON_OBJECT('order_id', 9001, 'status', 'CREATED')
);
SET @duplicate_event_inserted = ROW_COUNT();
SELECT o.order_id, o.order_status,
e.event_key, e.event_type,
JSON_UNQUOTE(JSON_EXTRACT(e.payload, '$.status')) AS payload_status
FROM sales_order o
JOIN event_outbox e ON e.aggregate_id = o.order_id;
SELECT @duplicate_event_inserted AS duplicate_event_inserted;
DROP TABLE event_outbox;
DROP TABLE sales_order;
실행 결과(MySQL 8.0.x):
아래 출력은 준비·정리 DDL을 줄이고, 업무 행과 Outbox 행의 동시 저장 및 중복 event 차단 결과를 발췌한 것이다.
mysql> CREATE TABLE sales_order (...);
Query OK, 0 rows affected
mysql> CREATE TABLE event_outbox (...);
Query OK, 0 rows affected
mysql> INSERT INTO sales_order(order_id, request_key, order_status)
-> VALUES (9001, 'order-20260812-0001', 'CREATED');
Query OK, 1 row affected
mysql> INSERT INTO event_outbox(...) VALUES (...);
Query OK, 1 row affected
mysql> COMMIT;
Query OK, 0 rows affected
mysql> INSERT IGNORE INTO event_outbox(...) VALUES (...);
Query OK, 0 rows affected
mysql> SELECT o.order_id, o.order_status,
-> e.event_key, e.event_type,
-> JSON_UNQUOTE(JSON_EXTRACT(e.payload, '$.status')) AS payload_status
-> FROM sales_order o
-> JOIN event_outbox e ON e.aggregate_id = o.order_id;
+----------+--------------+--------------------+--------------+----------------+
| order_id | order_status | event_key | event_type | payload_status |
+----------+--------------+--------------------+--------------+----------------+
| 9001 | CREATED | order-9001-created | OrderCreated | CREATED |
+----------+--------------+--------------------+--------------+----------------+
1 row in set
mysql> SELECT @duplicate_event_inserted AS duplicate_event_inserted;
+--------------------------+
| duplicate_event_inserted |
+--------------------------+
| 0 |
+--------------------------+
1 row in set
Outbox는 정확히 한 번의 네트워크 전달을 보장하지 않는다. dispatcher가 메시지를 전송한 직후 published_at 갱신 전에 죽으면 다시 전송할 수 있다. 따라서 전체 구조는 보통 at-least-once delivery + consumer idempotency로 설계한다. “DB에 Outbox가 있으므로 소비자는 중복을 보지 않는다”는 가정은 안전하지 않다.
6. 애플리케이션 재시도 루프의 구조
재시도는 실패한 SQL 한 문장이 아니라 업무 transaction 전체를 새 connection 상태에서 다시 실행해야 한다. 다음은 언어에 종속되지 않는 구조 예시다.
function execute_with_retry(request, idempotency_key, deadline):
validate_outside_transaction(request)
for attempt in 1..MAX_ATTEMPTS:
if deadline.expired():
return timeout
try:
begin transaction
result = claim_or_load(idempotency_key, request)
if result.already_completed:
commit
return result.saved_response
apply_business_changes(request)
save_response_and_outbox(idempotency_key)
commit
return response
catch deadlock_or_lock_timeout:
rollback
sleep(bounded_exponential_backoff_with_jitter(attempt, deadline))
continue
catch connection_lost_during_commit:
discard_connection
return query_status_with_same_idempotency_key()
catch non_retryable_error:
rollback
raise
6.1 backoff에는 상한과 jitter가 필요하다
동시에 충돌한 모든 요청이 같은 고정 간격으로 재시도하면 다시 동시에 만나 충돌한다. 일반적으로 다음 요소를 함께 둔다.
- 작은 초기 지연
- 시도 횟수에 따른 exponential 증가
- 무작위 jitter
- 요청 전체 deadline보다 작은 최대 지연
- 서비스 전체 retry budget과 최대 횟수
재시도 횟수만 제한하고 전체 deadline을 두지 않으면 DB timeout, connection timeout, 애플리케이션 sleep이 합쳐져 사용자 요청의 상위 SLA를 넘을 수 있다.
6.2 connection을 재사용하기 전에 transaction 상태를 정리한다
오류 뒤 ROLLBACK이 성공했는지 불분명하거나 연결 자체가 끊겼다면 해당 connection을 pool에 반환하지 않는다. 세션에 열린 transaction, 변경된 isolation level, session variable이 남으면 다음 요청이 오염된 상태를 물려받는다. pool은 checkout/checkin 시 transaction 종료 상태와 필요한 session 설정을 보장해야 한다.
7. MySQL 설정과 관측점
다음 쿼리는 검증 환경의 기본 transaction 관련 값과 Performance Schema transaction consumer 상태를 확인한다. events_transactions_history 계열이 NO이면 과거 transaction 관측 범위가 제한되므로 운영 목적에 맞게 overhead를 검토한 뒤 활성화한다.
SELECT VERSION() AS mysql_version,
@@session.autocommit AS autocommit,
@@session.transaction_isolation AS transaction_isolation,
@@session.innodb_lock_wait_timeout AS lock_wait_timeout,
@@global.innodb_rollback_on_timeout AS rollback_on_timeout;
SELECT NAME, ENABLED
FROM performance_schema.setup_consumers
WHERE NAME IN (
'events_transactions_current',
'events_transactions_history',
'events_transactions_history_long'
)
ORDER BY NAME;
실행 결과(MySQL 8.0.x):
mysql> SELECT VERSION() AS mysql_version,
-> @@session.autocommit AS autocommit,
-> @@session.transaction_isolation AS transaction_isolation,
-> @@session.innodb_lock_wait_timeout AS lock_wait_timeout,
-> @@global.innodb_rollback_on_timeout AS rollback_on_timeout;
+---------------+------------+-----------------------+-------------------+---------------------+
| mysql_version | autocommit | transaction_isolation | lock_wait_timeout | rollback_on_timeout |
+---------------+------------+-----------------------+-------------------+---------------------+
| 8.0.46 | 1 | REPEATABLE-READ | 50 | 0 |
+---------------+------------+-----------------------+-------------------+---------------------+
1 row in set (0.00 sec)
mysql> SELECT NAME, ENABLED
-> FROM performance_schema.setup_consumers
-> WHERE NAME IN (
-> 'events_transactions_current',
-> 'events_transactions_history',
-> 'events_transactions_history_long'
-> )
-> ORDER BY NAME;
+----------------------------------+---------+
| NAME | ENABLED |
+----------------------------------+---------+
| events_transactions_current | YES |
| events_transactions_history | YES |
| events_transactions_history_long | NO |
+----------------------------------+---------+
3 rows in set (0.00 sec)
운영 진단에서는 다음 정보를 함께 본다.
information_schema.INNODB_TRX: 현재 열린 InnoDB transaction의 시작 시각, 상태, 변경 행 수performance_schema.data_lock_waits: row lock waiter와 blocker 관계performance_schema.data_locks: 요청·보유 잠금의 mode와 상태performance_schema.events_transactions_current: 현재 instrumented transaction 상태performance_schema.metadata_locks: DDL과 table metadata 관련 대기- 애플리케이션 지표: attempt 수, 최종 성공/실패, backoff 시간, idempotency hit, 결과 불확실성 조회 횟수
단순히 평균 transaction 시간만 보면 소수의 긴 transaction을 놓칠 수 있다. p95/p99, 최대값, 열린 transaction의 나이, lock wait 시간, retry 증폭률을 함께 관찰해야 한다.
8. 흔한 실패 패턴과 주의점
8.1 원격 호출을 transaction 안에서 수행한다
결제사, 인증 서버, DNS, 메시지 broker의 지연이 그대로 잠금 보유 시간이 된다. 원격 호출이 반드시 선행되어야 하면 호출 결과를 얻은 뒤 짧은 DB transaction을 시작한다. DB commit 이후에만 가능한 호출은 Outbox나 보상 가능한 workflow로 분리한다.
8.2 오류 1205 뒤 선행 변경을 commit한다
기본 설정에서는 timeout 문장만 rollback될 수 있다. 애플리케이션은 1205를 업무 transaction 전체 실패로 취급해 명시적으로 rollback해야 한다.
8.3 무한 재시도한다
DB가 과부하일 때 retry는 추가 부하가 된다. 최대 시도 횟수, 전체 deadline, circuit breaker, admission control을 함께 설계한다. 지속적인 deadlock은 재시도 횟수를 늘릴 문제가 아니라 잠금 순서와 접근 경로를 수정할 신호다.
8.4 idempotency key를 상태 저장 없이 cache에만 둔다
cache eviction이나 장애로 key가 사라지면 중복 처리를 막을 수 없다. 금전·주문처럼 중요한 업무는 실제 변경과 원자적으로 연결되는 영속 저장소를 기준으로 삼는다. cache는 조회 가속 수단일 뿐 정합성의 유일한 근거가 되어서는 안 된다.
8.5 PROCESSING 상태를 무기한 신뢰한다
프로세스가 중간에 죽을 수 있으므로 상태 전이, 소유권 lease, 마지막 갱신 시각, 재처리 조건이 필요하다. 다만 하나의 짧은 transaction 안에서 처리권과 업무 변경을 모두 commit한다면 rollback된 미완료 상태가 남지 않는다는 장점이 있다.
8.6 격리 수준을 낮추면 모든 경합이 사라진다고 생각한다
READ COMMITTED는 일부 gap lock 범위를 줄일 수 있지만 동일 행 갱신의 record lock과 업무 불변식 보호는 여전히 필요하다. 격리 수준 변경은 쿼리 의미, phantom 허용 여부, 복제 및 애플리케이션 기대를 함께 검토해야 한다.
9. Aurora MySQL에서의 운영 해석
Aurora MySQL도 MySQL 호환 transaction과 InnoDB 잠금 의미를 제공하므로 짧은 transaction, 일정한 잠금 순서, idempotency key 원칙은 동일하게 적용된다. 다만 분산 storage와 writer failover가 있으므로 다음 차이를 운영 설계에 반영한다.
- writer failover 중 연결 단절:
COMMIT성공 응답을 받지 못했다면 단순 재실행하지 말고 같은 idempotency key로 writer에서 결과를 조회한다. - reader endpoint의 지연: commit 직후 상태 확인처럼 read-after-write가 필요한 요청은 writer endpoint를 사용한다. replica의 순간적인 지연을 “처리되지 않음”으로 오판해 중복 실행하면 안 된다.
- connection pool 복구: DNS와 topology 변경 뒤 기존 connection을 폐기하고 새 writer로 연결할 수 있어야 한다. driver의 failover 지원과 DNS cache 정책을 점검한다.
- parameter group 적용 범위:
innodb_lock_wait_timeout같은 값은 cluster/instance parameter 적용 방식과 기존 session 상속 여부를 확인한다. 설정 변경만으로 긴 transaction 원인을 숨기지 않는다. - 관측 도구 결합: Performance Schema, CloudWatch 지표, Performance Insights 또는 Database Insights의 DB load와 wait 정보를 애플리케이션 retry 지표와 같은 시간축으로 비교한다.
Aurora의 빠른 failover는 commit 결과 불확실성을 없애지 않는다. 장애 복구 시간이 짧아질수록 애플리케이션이 더 빨리 재요청할 수 있으므로 오히려 idempotency 검증 경로가 확실해야 한다.
10. 설계 및 운영 체크리스트
트랜잭션 경계
멱등성과 재시도
- key에
UNIQUE
외부 효과와 관측
11. 결론
안전한 transaction 설계는 COMMIT과 ROLLBACK 사용법만의 문제가 아니다. DB 안의 임계 구역을 짧게 유지하고, 잠금 순서를 일관되게 만들며, 재시도 가능한 충돌과 결과 불확실성을 구분해야 한다. 그 위에 idempotency key, 저장된 업무 결과, Transactional Outbox를 결합하면 deadlock이나 failover가 발생해도 중복 효과 없이 복구할 수 있다.
다음 단계에서는 실제 서비스에서 transaction 시간, lock wait, retry 증폭을 어떤 지표와 trace로 연결해 병목의 원인을 찾을지 살펴볼 수 있다.