트랜잭션과 멱등성
게시글과 일별 집계를 한 트랜잭션으로 저장하고 UNIQUE 요청 키로 중복 등록과 응답 손실 재시도를 안전하게 처리합니다.
게시글 한 행과 일별 통계 한 행은 서로 다른 테이블이지만 하나의 등록 요청에서 함께 바뀝니다.
게시글만 저장되고 통계 갱신이 실패하면 목록은 정상이지만 운영 지표가 실제보다 작아집니다.
반대로 통계만 증가하면 존재하지 않는 글을 센 셈입니다.
트랜잭션은 여러 SQL을 묶는 문법이 아니라 함께 참이어야 하는 상태를 한 번에 확정하는 경계입니다.
네트워크 응답을 잃어 같은 요청이 다시 올 수 있으므로 원자성에 더해 (author_id, request_key) UNIQUE로 중복 적용을 막습니다.
실습은 board_lab의 members, posts와 회원별 일별 통계 daily_post_stats를 사용합니다.
회원 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합니다.
게시글과 일별 집계의 원자적 저장
통계 행이 없으면 만들고 이미 있으면 증가시키는 UPSERT를 사용합니다.
게시글과 집계가 같은 트랜잭션 안에 있으므로 어느 구문이 실패해도 둘 다 롤백됩니다.
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;첫 실행 뒤 요청 키 게시글은 한 행이고 일별 공개 건수는 정확히 1 증가합니다.
통계 INSERT에서 FK·CHECK·타입 오류가 나면 COMMIT하지 않고 ROLLBACK하여 게시글도 남기지 않습니다.
개선 실행 결과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 오류를 냅니다.
애플리케이션은 트랜잭션을 롤백한 뒤 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;결과가 0행이면 해당 날짜 원본과 통계가 일치합니다.
누락이 나오면 원본에서 절대값을 다시 계산해 통계 행을 교체할 수 있어야 합니다.
운영에서 함께 관찰할 값
- 열린 트랜잭션 시간과 잠금 대기 시간
uq_posts_author_request충돌률과 재생 응답률- 게시글-통계 대사 불일치 건수
- 롤백률과 SQLState별 오류 분포
DDL은 MySQL에서 암묵적 커밋을 만들 수 있으므로 업무 트랜잭션 안에 섞지 않습니다.
연결 풀에 반환하기 전 자동 커밋과 격리 수준을 원래 상태로 복구합니다.
트랜잭션 단위 선택 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 원자성 | 게시글과 함께 성공해야 하는 파생 변경이 무엇인가? |
| 멱등성 | 같은 request_key가 추가 행·추가 집계를 만들지 않는가? |
| 오류 | 각 중간 지점 실패 뒤 게시글과 통계가 모두 롤백되는가? |
| 외부 호출 | DB 트랜잭션 안의 네트워크 대기를 제거했는가? |
| 대사 | 원본에서 통계를 재계산하고 불일치를 찾을 수 있는가? |
함께 성공해야 회원 게시판의 불변식이 유지되는 DB 변경만 가장 짧은 경계로 묶습니다.
외부 호출은 분리하고 요청 키로 불확실한 결과를 조회 가능한 상태로 바꿉니다.
연습 문제
게시글과 첫 댓글을 한 요청에서 함께 등록하고 같은 요청 재시도를 한 번만 반영하세요.
댓글 INSERT가 실패하면 게시글도 남지 않아야 합니다.
해설과 예시 답안
게시글의 (author_id, request_key)를 먼저 선점하고 반환된 게시글 ID로 댓글을 저장합니다.
두 INSERT를 한 트랜잭션에 두며 중복 요청이면 기존 게시글을 조회합니다.
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;댓글 제약 오류를 주입한 뒤 두 테이블에서 해당 요청의 행이 모두 0인지 확인합니다.
핵심 정리
- 자동 커밋의 여러 구문은 자동으로 한 업무가 되지 않습니다.
- 게시글과 함께 참이어야 하는 일별 집계를 한 트랜잭션에서 확정합니다.
- UNIQUE
(author_id, request_key)는 중복 등록과 응답 손실 재시도를 안전하게 만듭니다. - 파생 통계는 원본에서 재계산하고 대사할 수 있어야 합니다.
다음 문서에서는 두 DB 세션이 같은 게시글을 수정할 때 격리 수준과 잠금이 어떤 결과를 만드는지 다룹니다.