← 문제로

15. 트랜잭션을 연 채 하는 결제 게이트웨이 동기 호출 (커넥션 풀 고갈 + 행 잠금 장기 보유)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 트랜잭션을 연 채 하는 결제 게이트웨이 동기 호출 (커넥션 풀 고갈 + 행 잠금 장기 보유)

난이도: 상

요약

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 참고).

면접 포인트

  1. DB 트랜잭션(과 커넥션, 행 잠금)의 수명은 신뢰도가 낮거나 느릴 수 있는 외부 I/O에 절대 종속시키면 안 된다 — 트랜잭션은 "빠르게 끝나는 로컬 작업"이라는 전제로 자원(풀 크기, 타임아웃)이 설계돼 있다.
  2. 한 요청 유형(구매 확정)의 지연이 커넥션 풀이라는 공유 자원을 통해 무관한 다른 요청(인벤토리 조회)까지 전염시킬 수 있다 — 자원 격리(풀 분리, 우선순위)나 외부 호출을 트랜잭션 밖으로 빼는 설계가 이런 전염을 막는다.
  3. "외부 호출 먼저, DB 커밋은 짧게 나중에" 순서로 바꾸면 트랜잭션 밖에서 승인된 결과가 커밋 시점까지 여전히 유효한지 보장하는 멱등/조건부 갱신 전략이 함께 필요하다.