← 문제로

29. 트랜잭션을 종료하지 않고 커넥션을 풀에 반납(상태 상속 오염) · C#

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

해설 — 트랜잭션을 종료하지 않고 커넥션을 풀에 반납(상태 상속 오염) · C#

난이도: 상

요약

GrantReward 는 Begin() 으로 트랜잭션을 열지만(A), 조기 반환(B)이나 중간 예외 경로에서는 Commit/Rollback 없이 finally 의 Return 만 실행한다. 결과적으로 트랜잭션이 열린 커넥션이 풀로 돌아가고, 다음 요청이 그 커넥션을 빌리면 이전 사용자의 열린 트랜잭션·보유 락·세션 상태를 그대로 이어받는다.

문제점

커넥션 반납 시 트랜잭션 미종료 — 세션 상태가 다음 차용자에게 상속

  • 증상: 다음 요청의 쿼리들이 남아 있던 트랜잭션 안에서 실행되어, 커밋 타이밍이 뒤섞이고(다른 사용자의 작업과 한 트랜잭션에 묶임), 이전 트랜잭션이 잡은 행 락을 계속 보유해 무관한 요청들이 대기·타임아웃한다. 나중에 누군가 Begin 을 다시 호출하면 "이미 트랜잭션이 진행 중" 오류가 나거나, DB에 따라 조용히 중첩 트랜잭션으로 처리된다.
  • 재현 조건:
    1. 가드절 조기 반환: amount <= 0 인 요청. Begin() 후 곧바로 return → finally 가 열린 트랜잭션 커넥션을 반납.
    2. 중간 예외: 두 번째 Exec 가 제약 위반/타임아웃으로 예외 → Commit 도 Rollback 도 못 하고 finally 가 반납.
    3. 다음 요청이 Rent() → 이 커넥션을 받음 → Begin() 시도 시 오류, 또는 남은 트랜잭션에 자기 쓰기가 섞여 나중에 통째로 롤백/커밋됨. 최악은 이전 사용자의 미커밋 데이터를 자기 것으로 커밋하거나, 자기 데이터를 남의 롤백에 휩쓸린다.
  • 근본 원인: 반납(자원 반환)과 트랜잭션 종료(상태 정리)를 분리해 생각했다. 커넥션은 상태를 가진 자원인데, 반납 전에 그 상태를 반드시 기준선으로 되돌려야 한다는 불변식이 코드에 없다.

수정안

반납 직전에 미종료 트랜잭션을 반드시 롤백한다(정상 경로는 이미 커밋했으므로 무해). 더 견고하게는 풀의 Return 자체가 커넥션 상태를 리셋한다.

public void GrantReward(long userId, int amount)
{
    IPooledConn conn = _pool.Rent();
    try
    {
        conn.Begin();
        if (amount <= 0)
        {
            conn.Rollback();          // 조기 반환 전에 트랜잭션 종료
            return;
        }
        conn.Exec($"UPDATE wallet SET gold = gold + {amount} WHERE user_id = {userId}");
        conn.Exec($"INSERT INTO reward_log(user_id, amount) VALUES ({userId}, {amount})");
        conn.Commit();
    }
    catch
    {
        conn.Rollback();
        throw;
    }
    finally
    {
        if (conn.InTransaction) conn.Rollback();  // 마지막 안전망
        _pool.Return(conn);
    }
}

풀 계층에서 방어하는 것이 가장 견고하다. Return(conn) 이 항상 if (conn.InTransaction) conn.Rollback(); 을 수행하고(필요 시 세션 변수·임시 테이블도 리셋), 그래도 지저분하면 커넥션을 폐기하고 새로 만든다. ADO.NET 의 SqlConnection 은 반납 시 자동 리셋(sp_reset_connection) 을 하지만, 애플리케이션 레벨 커스텀 풀은 이 계약을 직접 보장해야 한다.

더 나은 설계

  • 트랜잭션 스코프를 RAII/using 으로: using var tx = conn.BeginTransaction(); 패턴이면 스코프를 벗어날 때 커밋 안 된 트랜잭션이 자동 롤백된다(TransactionScope, DbTransaction.Dispose). 조기 반환·예외에서도 트랜잭션이 새지 않는다.
  • 자원 반납 = 상태 리셋: 풀은 "빌린 것을 되돌린다"만이 아니라 "기준선 상태로 되돌린다"를 계약으로 가져야 한다. 트랜잭션, 세션 변수, 준비된 문, 임시 테이블 모두 대상.
  • 트레이드오프: 반납 시 리셋/롤백은 미세한 왕복 비용이 있지만, 상태 상속 버그의 비용(간헐적 데이터 오염·락 대기)과 비교하면 필수다.

면접 포인트

  • 커넥션 풀의 핵심 계약은 "반납된 커넥션은 갓 만든 것과 구별되지 않아야 한다"이다. 열린 트랜잭션·세션 변수·임시 테이블이 상속되면 서로 무관한 요청이 서로의 상태에 얽힌다.
  • finally 에서 자원을 반납하되 상태를 정리하지 않는 것이 함정이다. 반납과 정리는 한 쌍으로 묶어야 한다.