트랜잭션과 멱등성
게시글과 일별 집계를 한 트랜잭션으로 저장하고 UNIQUE 요청 키로 중복 등록과 응답 손실 재시도를 안전하게 처리합니다.
게시글 한 행과 일별 통계 한 행은 서로 다른 테이블이지만 하나의 등록 요청에서 함께 바뀝니다.
게시글만 저장되고 통계 갱신이 실패하면 목록은 정상이지만 운영 지표가 실제보다 작아집니다.
반대로 통계만 증가하면 존재하지 않는 글을 센 셈입니다.
트랜잭션은 여러 SQL을 묶는 문법이 아니라 함께 참이어야 하는 상태를 한 번에 확정하는 경계입니다.
네트워크 응답을 잃어 같은 요청이 다시 올 수 있으므로 원자성에 더해 (author_id, request_key) UNIQUE로 중복 적용을 막습니다.
실습은 board_lab의 members, posts와 회원별 일별 통계 daily_post_stats를 사용합니다.
회원 id=1이 존재하고 새 요청 키가 아직 없다는 전제로 진행합니다. 해당 회원이 공개 게시글을 등록하면 게시글과 그날 통계를 함께 반영해야 합니다. 아래 결과 표시는 이 전제의 예상 예시이며 이번 작업에서 실행해 수집한 로그가 아닙니다.
부분 커밋의 위험
자동 커밋 상태에서는 각 구문이 서로 다른 트랜잭션입니다.
게시글 INSERT 뒤 통계 UPDATE에서 오류가 나도 이미 확정된 게시글은 자동으로 되돌아가지 않습니다.
USE board_lab;
CREATE TABLE IF NOT EXISTS daily_post_stats (
member_id BIGINT UNSIGNED NOT NULL,
stat_date DATE NOT NULL,
published_count INT UNSIGNED NOT NULL DEFAULT 0,
total_views BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (member_id, stat_date),
CONSTRAINT fk_daily_post_stats_member
FOREIGN KEY (member_id) REFERENCES members (id)
) ENGINE = InnoDB;
SET autocommit = 1;
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, published_at, version, request_key
)
VALUES (
1, '트랜잭션 경계', '게시글과 통계를 함께 저장해야 한다.',
'PUBLISHED', 0,
'2026-07-14 09:00:00', '2026-07-14 09:00:00',
'2026-07-14 09:00:00', 0, 'post-20260714-bad'
);
-- 잘못된 컬럼 이름 때문에 두 번째 구문만 실패한다.
UPDATE daily_post_stats
SET published_count_typo = published_count + 1
WHERE member_id = 1 AND stat_date = '2026-07-14';두 번째 구문은 오류지만 첫 INSERT는 이미 커밋되었습니다.
예외 처리에서 ROLLBACK을 호출해도 끝난 자동 커밋 구문에는 효과가 없습니다.
자동 커밋 예제의 예상 결과post request_key = post-20260714-bad: 1 row
daily_post_stats published_count: unchanged
ROLLBACK after error: no effect on committed post업무 오류는 SQL 문법 오류와 다릅니다.
모든 구문이 성공했더라도 통계가 두 번 증가하거나 다른 날짜에 기록되면 불변식은 깨집니다.
부분 커밋을 찾는 네 단계
- 업무 경계 — 게시글 INSERT와 통계 UPSERT 중 하나만 남아도 되는지 묻습니다.
- 오류 주입 — 각 구문 사이에 실패를 넣고 커밋된 행을 확인합니다.
- 원본 대사 — 공개 게시글 수와 통계 건수를 같은 기준 시각으로 비교합니다.
- 재시도 — 동일 요청 키를 반복해 추가 행과 추가 집계가 0인지 봅니다.
ACID와 업무 단위
원자성은 내부 변경이 모두 커밋되거나 모두 롤백되는 성질입니다.
일관성은 제약과 업무 규칙이 유효한 상태를 만들도록 설계하는 책임입니다.
격리성은 동시 트랜잭션의 간섭을 제어하고 지속성은 커밋 결과가 장애 뒤에도 보존되는 성질입니다.
트랜잭션 안에는 같은 데이터베이스에서 짧게 끝나는 변경만 둡니다.
메일·HTTP·메시지 발행을 잠금 경계에 넣으면 외부 지연과 실패가 DB 자원을 오래 점유합니다.
등록 순서를 결정하는 기준
- 멱등 키 —
posts(author_id, request_key)UNIQUE로 같은 회원의 같은 등록을 한 행으로 제한합니다. - 원본 저장 — 제목·본문·상태·시각을 게시글에 저장합니다.
- 파생 갱신 — 같은 회원·날짜 통계에 공개 건수와 조회수를 더합니다.
- 한 번 확정 — 두 변경이 모두 성공한 뒤에만 COMMIT합니다.
게시글과 통계 저장의 성공·오류 분기
호출자가 구현할 제어 흐름입니다. 아래 SQL에는 오류 처리기가 없으므로 실패한 뒤 나머지 구문을 계속 실행하면 안 됩니다.
게시글과 일별 집계의 원자적 저장
통계 행이 없으면 만들고 이미 있으면 증가시키는 UPSERT를 사용합니다.
아래는 모든 문장이 성공할 때의 수동 실행 경로입니다. 각 INSERT 직후 오류를 확인하고, 오류가 나면 나머지 INSERT·COMMIT을 실행하지 말고 즉시 ROLLBACK합니다. 일반 제약 오류는 실패한 문장만 되돌릴 수 있으므로 트랜잭션을 시작했다는 이유로 앞선 성공 문장까지 자동 롤백되는 것은 아닙니다.
START TRANSACTION;
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, published_at, version, request_key
)
VALUES (
1, '멱등한 게시글 등록', '같은 요청은 한 번만 반영한다.',
'PUBLISHED', 0,
'2026-07-14 10:00:00', '2026-07-14 10:00:00',
'2026-07-14 10:00:00', 0, 'post-20260714-001'
);
INSERT INTO daily_post_stats (
member_id, stat_date, published_count, total_views, updated_at
)
VALUES (1, '2026-07-14', 1, 0, CURRENT_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE
published_count = published_count + 1,
total_views = total_views + VALUES(total_views),
updated_at = VALUES(updated_at);
COMMIT;두 문장이 처음부터 모두 성공하고 COMMIT되면 새 요청 키 게시글은 한 행이고 일별 공개 건수는 1 증가합니다.
모두 성공해 커밋한 경우의 예상 결과posts(author_id=1, request_key=post-20260714-001): 1
daily_post_stats(1, 2026-07-14): published_count +1
transaction outcome: committed together같은 요청을 다시 실행하면 게시글 INSERT가 UNIQUE 오류를 냅니다.
게시글 INSERT가 중복 오류를 냈는데 통계 UPSERT를 계속하면 집계만 다시 증가할 수 있습니다. 호출자는 먼저 ROLLBACK한 뒤 author_id와 request_key로 기존 글을 찾고, 저장된 요청 내용이 같은지 확인한 경우에 첫 응답의 리소스를 반환합니다.
SELECT id, author_id, title, status, created_at
FROM posts
WHERE author_id = 1
AND request_key = 'post-20260714-001';요청 키는 회원 범위에서 충분히 오래 보존해야 합니다.
키를 너무 빨리 지우면 오래 지연된 재시도가 새 글을 만들 수 있습니다.
원본과 통계 일치 확인
집계 테이블은 원본을 다시 계산할 수 있는 파생 데이터입니다.
정합성 검사는 같은 날짜 경계와 같은 공개 상태를 사용해야 합니다.
WITH source AS (
SELECT
author_id AS member_id,
DATE(published_at) AS stat_date,
COUNT(*) AS published_count,
SUM(view_count) AS total_views
FROM posts
WHERE status = 'PUBLISHED'
AND published_at >= '2026-07-14'
AND published_at < '2026-07-15'
GROUP BY author_id, DATE(published_at)
)
SELECT
s.member_id,
s.stat_date,
s.published_count AS source_count,
d.published_count AS summary_count,
s.total_views AS source_views,
d.total_views AS summary_views
FROM source AS s
LEFT JOIN daily_post_stats AS d
ON d.member_id = s.member_id
AND d.stat_date = s.stat_date
WHERE d.member_id IS NULL
OR d.published_count <> s.published_count
OR d.total_views <> s.total_views;이 LEFT JOIN은 원본 집계에 있는 회원·날짜 키의 누락과 값 차이를 찾습니다. 원본이 없는 여분 통계 행은 찾지 않으므로 0행만으로 양방향 전체 일치를 확정할 수 없습니다.
앞의 잘못된 자동 커밋으로 남은 post-20260714-bad는 새 트랜잭션의 성공으로 복구되지 않습니다. 그 예제를 이어 실행했다면 해당 누락이 대사에 나올 수 있습니다.
누락이 나오면 원본에서 절대값을 다시 계산해 통계 행을 교체할 수 있어야 합니다.
운영에서 함께 관찰할 값
- 열린 트랜잭션 시간과 잠금 대기 시간
uq_posts_author_request충돌률과 재생 응답률- 게시글-통계 대사 불일치 건수
- 롤백률과 SQLState별 오류 분포
DDL은 MySQL에서 암묵적 커밋을 만들 수 있으므로 업무 트랜잭션 안에 섞지 않습니다.
연결 풀에 반환하기 전 자동 커밋과 격리 수준을 원래 상태로 복구합니다.
트랜잭션 단위 선택 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 원자성 | 게시글과 함께 성공해야 하는 파생 변경이 무엇인가? |
| 멱등성 | 같은 request_key가 추가 행·추가 집계를 만들지 않는가? |
| 오류 | 각 중간 지점 실패 뒤 게시글과 통계가 모두 롤백되는가? |
| 외부 호출 | DB 트랜잭션 안의 네트워크 대기를 제거했는가? |
| 대사 | 원본에서 통계를 재계산하고 불일치를 찾을 수 있는가? |
함께 성공해야 회원 게시판의 불변식이 유지되는 DB 변경만 가장 짧은 경계로 묶습니다.
외부 호출은 분리하고 요청 키로 불확실한 결과를 조회 가능한 상태로 바꿉니다.
연습 문제
게시글과 첫 댓글을 한 요청에서 함께 등록하고 같은 요청 재시도를 한 번만 반영하세요.
댓글 INSERT가 실패하면 게시글도 남지 않아야 합니다.
해설과 예시 답안
게시글의 (author_id, request_key)를 먼저 선점하고 반환된 게시글 ID로 댓글을 저장합니다.
아래도 성공 경로만 적은 SQL입니다. 게시글 INSERT에 오류가 나면 즉시 중단·ROLLBACK하고 LAST_INSERT_ID()를 읽거나 댓글을 쓰지 않습니다. 실패 뒤 그 값은 이번 게시글 ID를 보장하지 않아 이전 값이나 정의되지 않은 값을 사용할 수 있습니다. 댓글 INSERT가 실패해도 COMMIT 대신 ROLLBACK합니다. 중복 요청 응답은 롤백 뒤 저장된 요청 내용을 비교해 처리합니다.
START TRANSACTION;
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, published_at, version, request_key
)
VALUES (
1, '질문 글', '첫 댓글까지 함께 저장한다.',
'PUBLISHED', 0, NOW(6), NOW(6), NOW(6), 0, 'post-with-comment-1'
);
SET @post_id = LAST_INSERT_ID();
INSERT INTO comments (post_id, author_id, content, created_at)
VALUES (@post_id, 1, '첫 댓글', NOW(6));
COMMIT;새 요청의 댓글 제약 오류를 주입하고 ROLLBACK한 뒤 두 테이블에 이번 요청의 새 행이 남지 않았는지 확인합니다. 중복 요청이면 기존에 커밋된 행은 남습니다.
핵심 정리
- 자동 커밋의 여러 구문은 자동으로 한 업무가 되지 않습니다.
- 게시글과 함께 참이어야 하는 일별 집계를 한 트랜잭션에서 확정합니다.
- UNIQUE
(author_id, request_key)는 중복 등록과 응답 손실 재시도를 안전하게 만듭니다. - 파생 통계는 원본에서 재계산하고 대사할 수 있어야 합니다.
다음 문서에서는 두 DB 세션이 같은 게시글을 수정할 때 격리 수준과 잠금이 어떤 결과를 만드는지 다룹니다.