블로그 본문
[복합 트랜잭션] Orders/Ord_items 다중 삽입 및 Carts 소프트 삭제를 결합한 결제 처리(POST /pay)와 수동 롤백(Rollback) 설계
니꼴라 클라우드 테크 9월 20, 2026📌 목차 바로가기
이번 포스팅에서는 결제 연산 시 데이터 정합성을 수호하는 ACID 원자성(Atomicity)의 당위성과 connection.beginTransaction() 기반의 3단계 연쇄 연산, 그리고 에러 발생 시 데이터베이스를 완벽히 원복시키는 수동 롤백(Rollback) 및 커넥션 반납 수칙을 설명하겠습니다.
1. 결제 처리 시 복합 트랜잭션의 필요성과 ACID 원자성(Atomicity) 수호
전자상거래 서비스에서 사용자가 [결제하기] 버튼을 누르는 순간, 백엔드 서버는 단순한 단일 쿼리가 아닌 3가지 이상의 서로 다른 테이블을 가로지르는 복합 데이터 상태 변이를 일으킵니다.
- 주문 마스터 생성 (
OrdersINSERT): 결제 일자, 총 결제 금액, 주문자 식별 정보를 담은 부모 주문 레코드를 생성합니다. - 주문 상세 항목 복제 (
Ord_items다중 INSERT): 장바구니에 담겨 있던 개별 상품 코드, 선택 사이즈, 주문 수량을 신규 생성된 부모 주문 번호와 결합하여 자식 레코드들로 일괄 이관합니다. - 장바구니 상태 변환 (
CartsUPDATE): 결제 처리가 완료된 장바구니 항목들의 주문 완료 여부 상태를'N'에서'Y'로 업데이트 변환합니다.
■ 단일 쿼리 자동 커밋의 위험성과 ACID 원자성
일반적인 데이터베이스 연결 방식은 개별 SQL 문이 실행될 때마다 즉시 디스크에 영구 반영하는 자동 커밋(Auto-Commit) 모드로 가동됩니다.
만약 1단계(Orders 생성)와 2단계(Ord_items 이관)까지 성공한 상태에서, 네트워크 순간 끊김이나 서버 가상 메모리 부족으로 3단계(Carts 상태 변환) 실행 도중 에러가 발생하면 어떻게 될까요?
결제 금액은 지불되고 주문 내역은 생성되었으나 정작 장바구니에는 물품이 그대로 남아있거나, 반대로 장바구니만 비워지고 주문 상세 내역이 날아가는 고아 레코드(Orphan Record) 및 데이터 불일치 대참사가 벌어지게 됩니다.
이를 완벽히 방어하기 위해 여러 개의 SQL 작업을 "모두 성공하든가, 아니면 하나라도 실패 시 전면 취소(All or Nothing)"하는 단일 작업 단위로 묶어주는 트랜잭션의 원자성(Atomicity) 보장 장치가 필수적입니다.
2. Node.js-mysql2 커넥션 풀 기반 수동 트랜잭션 제어 프로시저
Node.js 백엔드 환경에서 mysql2/promise 커넥션 풀을 가동할 때, 트랜잭션을 올바르게 제어하려면 반드시 풀에서 독립된 전용 커넥션 인스턴스를 직접 획득해 가동해야 합니다. (db.query() 구문은 호출할 때마다 풀에서 무작위 커넥션을 빌려 쓰고 즉시 반납하므로 트랜잭션 세션이 유지되지 않습니다.)
■ 3단계 연쇄 트랜잭션 제어 스텝
// 1. 커넥션 풀에서 전용 단일 커넥션 개체 수득
const connection = await db.getConnection();
try {
// 2. 수동 트랜잭션 개시 선포 (Auto-Commit 일시 중지)
await connection.beginTransaction();
// [Step 1] Orders 테이블에 주문 마스터 기록 및 신규 주문번호(ord_no) PK 수득
const [orderResult] = await connection.query(
'INSERT INTO Orders (ord_date, ord_amount, cust_id) VALUES (CURDATE(), ?, ?)',
[totalAmount, custId]
);
const newOrdNo = orderResult.insertId; // AUTO_INCREMENT로 발급된 부모 PK 번호
// [Step 2] 현재 대기 중인 장바구니 항목들을 Ord_items 테이블로 연쇄 이관
await connection.query(`
INSERT INTO Ord_items (ord_no, cart_seq_no, prod_cd, prod_size, ord_qty)
SELECT ?, cart_seq_no, prod_cd, prod_size, ord_qty
FROM Carts
WHERE cust_id = ? AND ord_yn = 'N'
`, [newOrdNo, custId]);
// [Step 3] 장바구니 항목 소프트 삭제 (ord_yn = 'Y' 상태 업데이트)
await connection.query(
'UPDATE Carts SET ord_yn = "Y" WHERE cust_id = ? AND ord_yn = "N"',
[custId]
);
// 3. 전 단계 100% 성공 시 물리 디스크 최종 확정 저장
await connection.commit();
} catch (error) {
// 4. 단 1개의 쿼리라도 에러 발생 시 이미 실행된 연산 전면 취소 원복
await connection.rollback();
throw error;
} finally {
// 5. 사용 완료된 커넥션 객체를 커넥션 풀로 반납 (자원 누수 방지)
connection.release();
}
■ Carts 소프트 삭제(Soft Delete)의 비즈니스 인텔리전스(BI)적 의의
결제가 끝난 장바구니 항목을 DELETE 쿼리로 테이블에서 영구 파기하지 않고, ord_yn = 'Y' 상태값만 변경하는 소프트 삭제(Soft Delete) 기법을 적용하는 이유는 명확합니다.
고객이 어떤 상품을 장바구니에 담았다가 언제 최종 결제로 전환했는지, 혹은 장바구니에 담아두고 결제하지 않은 상품(장바구니 포기율)이 무엇인지에 대한 이력 데이터는 향후 고객 구매 행동 분석, 마이페이지 주문 이력 조회, 맞춤형 타겟팅 마케팅을 가능케 하는 기업의 매우 귀중한 비즈니스 자산이 되기 때문입니다.
3. try-catch-finally 예외 제어와 자원 반납(connection.release) 수칙
트랜잭션 코드 작성 시 아키텍트가 절대로 놓쳐서는 안 되는 가장 치명적인 예외 처리 수칙은 커넥션 자원 반납(release())의 보장입니다.
commit(): 3단계 쿼리가 한 치의 오차 없이 성공했을 때 호출하여 RDBMS 디스크 볼륨에 변경 사항을 영구 확정 저장합니다.rollback():try블록 내에서 쿼리 오류, 데드락(Deadlock), 데이터 타입 불일치 등 예외가 발생하는 즉시catch블록으로 제어가 이동하여, 트랜잭션 개시(beginTransaction) 이전의 깨끗한 상태로 DB 상태를 원자적으로 원복시킵니다.finally { connection.release(); }: 성공(commit)하든 실패(rollback)하든 상관없이, 빌려온 커넥션을 반납하는connection.release()는 반드시finally블록 내부에서 실행되어야 합니다.- 만약
release()처리가 누락되면 커넥션이 풀로 돌아가지 않고 고사(Connection Leak)되어, 수십 번의 결제 요청 후 서버 전체의 DB 연결 채널이 고갈되어 시스템이 마비되는 참사가 발생합니다.
4. 결제 복합 트랜잭션 수송 아키텍처 다이어그램
Node.js Express 서버의 cartView.js 결제 라우터가 커넥션 풀에서 독립 커넥션을 획득하여 beginTransaction()을 선포하고, Orders/Ord_items/Carts 3단계 SQL 연산을 수행한 뒤 try-catch-finally를 경유해 커밋 및 롤백, 커넥션 반납을 완수하는 전체 트랜잭션 수송 구조도입니다.
5. [코드 분석] cartView.js 결제 라우트 (POST /cart/pay) 전체 소스 코드
실제 애플리케이션에서 결제 요청을 받아 3단계 연쇄 연산을 복합 트랜잭션으로 안전하게 제어하는 cartView.js 내 결제 엔드포인트 핵심 구현 코드입니다.
/**
* cartView.js - 결제 처리 복합 트랜잭션 엔드포인트 (POST /cart/pay)
*/
const express = require('express');
const router = express.Router();
const db = require('./db');
const authCheck = require('./authCheck');
// 헬퍼: 세션에서 순수 cust_id 추출
function getCustomerId(req) {
if (!req.session || !req.session.nickname) return null;
return req.session.nickname.split('/');
}
// 결제 처리 트랜잭션 라우트 (POST /cart/pay)
router.post('/pay', async (req, res) => {
if (!authCheck.isOwner(req, res)) {
return res.status(401).send('<script>alert("로그인이 필요합니다."); location.href="/auth/login";</script>');
}
const custId = getCustomerId(req);
// 1. 커넥션 풀에서 전용 커넥션 인스턴스 직접 획득
let connection;
try {
connection = await db.getConnection();
} catch (connErr) {
console.error('DB 커넥션 획득 실패:', connErr);
return res.status(500).send('<script>alert("데이터베이스 연결에 실패했습니다."); history.back();</script>');
}
try {
// 2. 수동 트랜잭션 시작 (Auto-Commit 차단)
await connection.beginTransaction();
// [전제 조건 검사] 현재 주문 대기 중인 장바구니 항목 및 총 결제 금액 계산
const [cartItems] = await connection.query(`
SELECT C.cart_seq_no, C.prod_cd, C.prod_size, C.ord_qty, P.price
FROM Carts C
INNER JOIN Products P ON C.prod_cd = P.prod_cd
WHERE C.cust_id = ? AND C.ord_yn = 'N'
`, [custId]);
if (cartItems.length === 0) {
await connection.rollback();
return res.send('<script>alert("결제할 장바구니 상품이 없습니다."); location.href="/cart";</script>');
}
let totalAmount = 0;
cartItems.forEach(item => {
totalAmount += Number(item.price) * Number(item.ord_qty);
});
// [Step 1] Orders 테이블에 부모 주문 마스터 데이터 INSERT
const [orderResult] = await connection.query(
'INSERT INTO Orders (ord_date, ord_amount, cust_id) VALUES (CURDATE(), ?, ?)',
[totalAmount, custId]
);
const newOrdNo = orderResult.insertId; // 신규 생성된 주문 번호 PK
// [Step 2] Ord_items 테이블에 자식 주문 상세 내역 다중 이관 INSERT
await connection.query(`
INSERT INTO Ord_items (ord_no, cart_seq_no, prod_cd, prod_size, ord_qty)
SELECT ?, cart_seq_no, prod_cd, prod_size, ord_qty
FROM Carts
WHERE cust_id = ? AND ord_yn = 'N'
`, [newOrdNo, custId]);
// [Step 3] Carts 테이블 소프트 삭제 UPDATE (ord_yn = 'Y')
await connection.query(
'UPDATE Carts SET ord_yn = "Y" WHERE cust_id = ? AND ord_yn = "N"',
[custId]
);
// 3. 전 과정 성공 시 디스크 트랜잭션 최종 확정 반영
await connection.commit();
// 결제 완료 안내 및 마이페이지 주문 내역으로 이동
res.send(`
<script>
alert("결제가 성공적으로 완료되었습니다! (주문번호: ${newOrdNo})");
location.href = "/mypage";
</script>
`);
} catch (err) {
// 4. 예외 발생 시 모든 작업을 트랜잭션 개시 이전 상태로 완벽 원복
if (connection) await connection.rollback();
console.error('결제 트랜잭션 처리 중 에러 발생 (롤백 집행):', err);
res.status(500).send('<script>alert("결제 처리 중 오류가 발생하여 모든 연산이 안전하게 취소되었습니다."); history.back();</script>');
} finally {
// 5. 사용이 끝난 커넥션을 커넥션 풀로 반납 (필수)
if (connection) connection.release();
}
});
module.exports = router;
마치며: 무결한 트랜잭션 안보망으로 결제 시스템을 최종 완성했습니다!
오늘 우리는 백엔드 트랜잭션 안보의 핵심 원칙인 ACID 원자성(Atomicity)을 준수하여 Orders, Ord_items, Carts 3개 테이블을 가로지르는 결제 복합 트랜잭션 라우트(POST /cart/pay)를 구축하고, 예외 발생 시 데이터를 완벽히 원복시키는 rollback() 및 자원 반납(release()) 안전장치를 완벽하게 정복했습니다!
"결제 중 예외가 발생하더라도 데이터 불일치나 유령 결제가 발생하지 않도록 트랜잭션 원자성 방패를 가동하고, 장바구니 소프트 삭제를 통해 기업의 비즈니스 인텔리전스 가치까지 수호하는 완성도 높은 결제 시스템을 완성한 것입니다."
결제 처리가 성공적으로 완결되고 데이터가 Orders 및 Ord_items 테이블로 안전하게 이관되었으니, 이제 사용자가 자신의 과거 결제 내역과 주문 상세 항목을 확인하고 작성된 리뷰를 관리하는 마이페이지(myPage.js) 구현 및 4중 복합 조인(JOIN) 쿼리 설계로 나아갈 차례입니다.
다음 포스팅 [비즈니스 분석] 마이페이지(myPage.js)의 4중 복합 조인 주문 조회 쿼리 최적화 및 고객 리뷰 등록/수정/삭제 라이프사이클 구현 편에서는 Orders-Ord_items-Products-Prod_evals 4개 테이블 결합 조회, 주문 날짜별 데이터 그룹화, 별점 리뷰 작성/수정/삭제 라이프사이클 및 개인정보 익명성 보장 마스킹 처리 기법을 설명하겠습니다.
오늘 결제 트랜잭션 실행 중 insertId 수득 오류나 롤백 제어 시 커넥션 풀 고갈 이슈가 발생하셨다면 주저하지 마시고 운영자 메일로 질의해주세요. 오늘도 안전하고 똑똑한 클라우딩 라이프 하세요. 감사합니다! 😉
출처
- 이현호. 실무 프로젝트로 완성하는 클라우드 환경에서 DB 구축과 웹 개발. 길벗캠퍼스. 2026.04. (9.1장 '장바구니 관리 및 결제 모듈', 238-243페이지 참조)
- 이현호. "09장. 애플리케이션 개발 - 장바구니_마이페이지_설명오디오.m4a" 설명 오디오 가이드 스크립트 기반 결제 복합 트랜잭션 필요성, ACID 원자성 수호, Orders/Ord_items/Carts 연쇄 연산, 장바구니 소프트 삭제(ord_yn='Y')의 BI 가치, 수동 rollback() 및 connection.release() 예외 제어 비하인드 대화록 완벽 반영.
- MySQL 8.0 Reference Manual - InnoDB Transactions and Atomic DML (https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-model.html) 및 Node.js mysql2/promise Transaction Guide.
자주 묻는 질문 (FAQ)
Q. 전자상거래 결제 처리 구현 시 단일 SQL 구문 대신 복합 트랜잭션(Transaction)을 적용하는 가장 결정적인 이유는 무엇인가요?
A. 데이터의 불일치를 방지하는 ACID 원자성(Atomicity) 보장 때문입니다. Orders 생성, Ord_items 이관, Carts 상태 변환 중 일부만 실행되고 에러가 나면 고아 레코드가 유발되므로, 전 과정이 '모두 성공'하든가 '모두 취소'되도록 단일 작업 단위로 보호해야 합니다.
Q. Node.js mysql2 커넥션 풀 사용 시 db.query() 대신 connection = await db.getConnection()을 얻어 트랜잭션을 제어하는 이유는 무엇인가요?
A. 세션 연속성 유지 때문입니다. db.query()는 매번 풀에서 임의의 커넥션을 빌려 쓰고 즉시 자동 커밋 후 반납하므로 beginTransaction(), commit(), rollback() 간의 트랜잭션 세션 상태가 공유되지 않아 반드시 전용 커넥션을 직접 획득해 제어해야 합니다.
Q. 트랜잭션 예외 제어 코드 작성 시 'finally' 블록 내부에서 'connection.release()'를 반드시 실행해야 하는 이유는 무엇인가요?
A. 커넥션 풀 자원 고갈(Connection Leak) 방지 때문입니다. commit() 성공이든 rollback() 실패든 상관없이 사용이 끝난 커넥션을 반납하지 않으면 풀의 사용 가능한 커넥션이 모두 소진되어 백엔드 서버 전체의 DB 연결이 마비됩니다.
