15. 트랜잭션을 연 채 하는 결제 게이트웨이 동기 호출 (커넥션 풀 고갈 + 행 잠금 장기 보유)
난이도 상해설 — 트랜잭션을 연 채 하는 결제 게이트웨이 동기 호출 (커넥션 풀 고갈 + 행 잠금 장기 보유)
난이도: 상
요약
ConfirmPurchase는 (A)에서 트랜잭션을 시작하고 주문 행을 FOR UPDATE로 잠근 뒤, 트랜잭션을 연 채로 (B)에서 결제 게이트웨이에 동기 HTTP 호출을 보낸다. 이 호출은 DB 작업이 아니라 외부 네트워크 왕복이라 수백 ms에서 수 초까지 걸릴 수 있는데, 그 시간 내내 (1) DB 커넥션 하나가 "사용 중"으로 묶여 있고 (2) 해당 주문 행의 잠금이 계속 유지된다. 게이트웨이가 느려지면 동시 구매 확정 요청 각각이 커넥션을 오래 붙잡아 풀이 고갈되고, 그 여파가 구매와 무관한 다른 요청(인벤토리 조회 등)까지 "커넥션을 얻지 못했습니다" 오류로 번진다.
문제점
1. (트랜잭션 스코프에 외부 I/O 포함, 커넥션 풀 고갈) — (A)(B)
- 증상: 결제 게이트웨이가 느려지는 시간대에 구매 확정 API가 전반적으로 느려지고, 급기야 커넥션 풀이 고갈되어 구매와 무관한 다른 요청(인벤토리 조회 등)까지 커넥션 대여 실패/타임아웃을 겪는다.
- 재현조건: 동시 구매 확정 요청 수가 커넥션 풀 크기에 근접하고, 게이트웨이 응답이 평소보다 느려짐(혼잡/네트워크 지연). 각 요청이 트랜잭션을 열어둔 채 게이트웨이 응답을 기다리는 시간이 길어질수록, 그 시간 동안 커넥션이 "일하지 않으면서 점유"되는 비율이 커진다.
- 근본원인: DB 트랜잭션(과 그것이 물고 있는 커넥션)은 DB 작업에만 필요한 자원인데, 그 수명이 훨씬 느리고 신뢰도가 낮은 외부 서비스 호출의 응답 시간에 종속돼 버렸다. 커넥션 풀 크기는 "DB 작업이 빠르게 끝난다"는 전제로 산정되므로, 트랜잭션 하나가 게이트웨이 응답을 기다리며 몇 초씩 커넥션을 쥐고 있으면 그 전제가 무너진다.
2. (행 잠금 장기 보유 → 연쇄 대기) — (B)
- 증상: 같은 주문 행(또는 락 범위가 넓다면 인접 행들)에 접근하려는 다른 트랜잭션들이 게이트웨이 응답이 올 때까지 블로킹된다. 게이트웨이가 느려질수록 대기 트랜잭션이 쌓이고, 그 트랜잭션들도 각자 커넥션을 쥔 채 대기하므로 문제 1과 상호 증폭한다.
- 근본원인:
SELECT ... FOR UPDATE로 잡은 행 잠금이 커밋/롤백까지 유지되는데, 커밋 시점이 외부 I/O 완료에 달려 있어 잠금 보유 시간이 네트워크 상황에 좌우된다.
수정안
결제 게이트웨이 확인을 트랜잭션 밖에서 먼저 수행하고, DB 트랜잭션은 그 결과를 반영하는 짧은 쓰기만 담당하게 한다.
public bool ConfirmPurchase(string orderId, long characterId, int itemId)
{
// 1) 트랜잭션 밖에서 먼저 외부 게이트웨이 확인 (커넥션 대여 전)
bool approved = _gateway.ConfirmPayment(orderId);
if (!approved)
{
MarkOrderFailed(orderId); // 별도의 짧은 트랜잭션으로 실패 처리
return false;
}
// 2) 승인된 경우에만 커넥션을 빌려 짧은 트랜잭션으로 커밋
IDbConnection conn = _pool.Rent();
try
{
IDbTransaction tx = conn.BeginTransaction();
try
{
conn.QuerySingle(tx, "SELECT status FROM orders WHERE id=@id FOR UPDATE", new { id = orderId });
conn.Execute(tx, "UPDATE orders SET status='CONFIRMED' WHERE id=@id", new { id = orderId });
conn.Execute(tx, "UPDATE inventory SET count = count + 1 WHERE char_id=@cid AND item_id=@iid",
new { cid = characterId, iid = itemId });
tx.Commit();
return true;
}
catch
{
tx.Rollback();
throw;
}
}
finally
{
_pool.Return(conn);
}
}
private void MarkOrderFailed(string orderId)
{
IDbConnection conn = _pool.Rent();
try
{
IDbTransaction tx = conn.BeginTransaction();
try
{
conn.Execute(tx, "UPDATE orders SET status='FAILED' WHERE id=@id", new { id = orderId });
tx.Commit();
}
catch { tx.Rollback(); throw; }
}
finally { _pool.Return(conn); }
}
- 이렇게 하면 커넥션과 행 잠금은 순수 DB 작업(주문 잠금 확인 + 상태 갱신 + 재화 지급) 동안만 유지되고, 게이트웨이 응답을 기다리는 시간은 커넥션/잠금 어느 쪽도 점유하지 않는다.
- 트랜잭션 밖에서 확인하는 순서로 바뀌었으므로, "확인 완료 후 커밋 직전에 다른 프로세스가 같은 주문을 먼저 처리"하는 경합에 대비해
UPDATE ... WHERE status <> 'CONFIRMED'처럼 조건부 갱신이나 낙관적 잠금(버전 체크)을 곁들이는 것이 안전하다.
더 나은 설계
- 원칙: 트랜잭션 안에는 신뢰할 수 있고 빠른 자원(같은 DB)만 두고, 외부 서비스 호출(결제, 알림, 다른 마이크로서비스)은 트랜잭션 밖에서 먼저 끝낸 뒤 결과만 짧은 트랜잭션에 반영한다.
- 게이트웨이 호출에는 타임아웃과 서킷 브레이커를 둬서, 게이트웨이 장애가 구매 확정 스레드를 무한정 묶어두지 않게 한다.
- 커넥션 대여 시간·트랜잭션 유지 시간을 메트릭으로 관측해, "느린 트랜잭션"이 늘어나는 초기 징후를 풀 고갈 이전에 탐지한다.
- 결제 승인 결과를 웹훅/비동기 콜백으로 받는 구조로 바꾸면 동기 대기 자체를 없앨 수 있지만, 그 경우 콜백 지연/유실에 대한 멱등 처리가 별도로 필요하다(이 저장소 concurrency_memory/problem17 참고).
면접 포인트
- DB 트랜잭션(과 커넥션, 행 잠금)의 수명은 신뢰도가 낮거나 느릴 수 있는 외부 I/O에 절대 종속시키면 안 된다 — 트랜잭션은 "빠르게 끝나는 로컬 작업"이라는 전제로 자원(풀 크기, 타임아웃)이 설계돼 있다.
- 한 요청 유형(구매 확정)의 지연이 커넥션 풀이라는 공유 자원을 통해 무관한 다른 요청(인벤토리 조회)까지 전염시킬 수 있다 — 자원 격리(풀 분리, 우선순위)나 외부 호출을 트랜잭션 밖으로 빼는 설계가 이런 전염을 막는다.
- "외부 호출 먼저, DB 커밋은 짧게 나중에" 순서로 바꾸면 트랜잭션 밖에서 승인된 결과가 커밋 시점까지 여전히 유효한지 보장하는 멱등/조건부 갱신 전략이 함께 필요하다.
해설 — RAII 트랜잭션 가드를 연 채 하는 결제 게이트웨이 블로킹 호출
난이도: 상
요약
TxGuard(A)는 RAII로 구현되어 예외나 조기 return에서도 반드시 rollback이 호출되도록 잘 짜여 있다 — 자원 누수/미정리 트랜잭션 문제는 없다. 하지만 그 안전장치와 별개로, tx가 스코프에 살아있는 동안, 즉 트랜잭션이 열려 있는 동안 (B)에서 결제 게이트웨이에 블로킹 네트워크 호출을 수행한다. TxGuard가 아무리 예외 안전하더라도, 트랜잭션의 생존 기간 자체가 게이트웨이 응답 시간만큼 늘어난다는 사실은 바뀌지 않는다. 그 동안 커넥션 하나가 풀에서 빠져나가 있고, queryLockOrderRow로 잡은 주문 행 잠금도 유지된다.
문제점
1. (트랜잭션 스코프에 외부 I/O 포함, 커넥션 풀 고갈) — (A)(B)
- 증상: 게이트웨이가 느려지는 시간대에 구매 확정 처리가 전반적으로 느려지고,
pool_->rent()가 빈 풀 때문에 블로킹/타임아웃되는 스레드가 늘어난다 — 구매와 무관한 다른 요청(인벤토리 조회 등)도 같은 풀을 쓰면 함께 지연된다. - 재현조건: 동시 구매 확정 요청 수가 풀 크기에 근접하고, 게이트웨이 응답이 지연됨.
TxGuard tx(conn)이 생성된 시점부터tx.commit()또는 소멸자 호출 시점까지가 커넥션의 실질 점유 구간인데, 그 구간에 네트워크 왕복이 끼어 있어 점유 시간이 DB 작업량이 아니라 외부 서비스 응답 시간에 좌우된다. - 근본원인:
TxGuard는 "트랜잭션을 안전하게 정리한다"는 문제만 해결했지, "트랜잭션을 얼마나 오래 열어둘 것인가"라는 스코프 설계 문제는 해결하지 못한다. RAII 가드의 생존 범위 안에 블로킹 I/O를 넣으면, 그 I/O가 끝날 때까지 가드가 지키는 자원(커넥션)이 계속 점유된다.
2. (행 잠금 장기 보유 → 연쇄 대기) — (B)
- 증상:
queryLockOrderRow로 잠근 주문 행에 접근하려는 다른 트랜잭션(같은 주문에 대한 중복 확정 시도, 또는 관리 도구의 조회 등)이 게이트웨이 응답이 올 때까지 블로킹된다. - 근본원인: 행 잠금의 해제 시점이
commit/rollback에 묶여 있고, 그 시점이 외부 I/O 완료에 종속되어 있다.
수정안
결제 게이트웨이 확인을 트랜잭션(과 커넥션 대여) 전에 먼저 수행하고, 승인된 경우에만 커넥션을 빌려 짧은 트랜잭션으로 커밋한다.
bool confirmPurchase(const std::string& orderId, int64_t characterId, int itemId) {
// 1) 커넥션을 빌리기 전에 먼저 외부 게이트웨이 확인
bool approved = gateway_->confirmPayment(orderId);
if (!approved) {
markOrderFailed(orderId);
return false;
}
// 2) 승인된 경우에만 커넥션을 빌려 짧은 트랜잭션으로 커밋
auto conn = pool_->rent();
TxGuard tx(conn);
if (!conn->queryLockOrderRow(orderId)) {
pool_->giveBack(conn);
return false;
}
conn->execute("UPDATE orders SET status='CONFIRMED' WHERE id='" + orderId + "'");
conn->execute("UPDATE inventory SET count = count + 1 WHERE char_id=" +
std::to_string(characterId) + " AND item_id=" + std::to_string(itemId));
tx.commit();
pool_->giveBack(conn);
return true;
}
void markOrderFailed(const std::string& orderId) {
auto conn = pool_->rent();
TxGuard tx(conn);
conn->execute("UPDATE orders SET status='FAILED' WHERE id='" + orderId + "'");
tx.commit();
pool_->giveBack(conn);
}
- 이제 커넥션과 행 잠금은 순수 DB 작업(주문 잠금 확인 + 상태 갱신 + 재화 지급) 동안만 유지되고, 게이트웨이 응답을 기다리는 동안에는 어느 쪽도 점유되지 않는다.
- 순서가 바뀌었으므로, 확인 완료 후 커밋 직전에 같은 주문이 다른 경로로 먼저 처리될 가능성에 대비해
UPDATE ... WHERE status <> 'CONFIRMED'같은 조건부 갱신을 곁들이는 것이 안전하다. pool_->giveBack(conn)을 각 반환 경로마다 반복해서 부르는 것도 실수하기 쉬운 지점이다 — 커넥션 반납 자체도conn을 감싸는 별도의 RAII 래퍼(소멸자에서giveBack호출)로 만들면 더 안전하다.
더 나은 설계
- 원칙: RAII 가드는 "정리를 잊지 않는 것"은 보장하지만 "얼마나 오래 쥐고 있을지"는 보장하지 않는다 — 트랜잭션 가드의 생존 범위를 설계할 때는 그 안에 블로킹 I/O(특히 외부 서비스 호출)가 들어가지 않도록 별도로 검토해야 한다.
- 게이트웨이 호출에는 타임아웃(예:
std::future+wait_for)과 서킷 브레이커를 둬서 게이트웨이 장애가 요청 처리 스레드를 무한정 묶어두지 않게 한다. - 커넥션 대여~반납 구간의 소요 시간을 메트릭으로 남겨, 외부 I/O가 트랜잭션 안으로 섞여 들어오는 회귀를 조기에 탐지한다.
면접 포인트
- RAII/스마트 포인터로 자원 해제를 보장하는 것과, 그 자원을 얼마나 오래 점유할지를 설계하는 것은 별개의 문제다 — 가드가 예외 안전해도 스코프 안에 블로킹 I/O가 있으면 점유 시간 문제는 그대로 남는다.
- DB 트랜잭션(커넥션, 행 잠금)의 수명을 신뢰도가 낮거나 느릴 수 있는 외부 서비스 호출에 종속시키면 안 된다 — 외부 호출은 트랜잭션 밖에서 먼저 끝내고, 트랜잭션은 그 결과를 반영하는 짧은 로컬 쓰기만 담당해야 한다.
- 공유 풀 자원(커넥션)의 점유 시간이 늘어나면 그 풀을 쓰는 무관한 요청까지 지연이 전염된다 — 자원 격리나 외부 호출의 트랜잭션 이탈이 이런 전염을 막는 핵심 대책이다.