29. 트랜잭션을 종료하지 않고 커넥션을 풀에 반납(상태 상속 오염) · C#
난이도 상해설 — 트랜잭션을 종료하지 않고 커넥션을 풀에 반납(상태 상속 오염) · C#
난이도: 상
요약
GrantReward 는 Begin() 으로 트랜잭션을 열지만(A), 조기 반환(B)이나 중간 예외 경로에서는 Commit/Rollback 없이 finally 의 Return 만 실행한다. 결과적으로 트랜잭션이 열린 커넥션이 풀로 돌아가고, 다음 요청이 그 커넥션을 빌리면 이전 사용자의 열린 트랜잭션·보유 락·세션 상태를 그대로 이어받는다.
문제점
커넥션 반납 시 트랜잭션 미종료 — 세션 상태가 다음 차용자에게 상속
- 증상: 다음 요청의 쿼리들이 남아 있던 트랜잭션 안에서 실행되어, 커밋 타이밍이 뒤섞이고(다른 사용자의 작업과 한 트랜잭션에 묶임), 이전 트랜잭션이 잡은 행 락을 계속 보유해 무관한 요청들이 대기·타임아웃한다. 나중에 누군가
Begin을 다시 호출하면 "이미 트랜잭션이 진행 중" 오류가 나거나, DB에 따라 조용히 중첩 트랜잭션으로 처리된다. - 재현 조건:
- 가드절 조기 반환:
amount <= 0인 요청.Begin()후 곧바로return→finally가 열린 트랜잭션 커넥션을 반납. - 중간 예외: 두 번째
Exec가 제약 위반/타임아웃으로 예외 →Commit도Rollback도 못 하고finally가 반납. - 다음 요청이
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에서 자원을 반납하되 상태를 정리하지 않는 것이 함정이다. 반납과 정리는 한 쌍으로 묶어야 한다.
해설 — 트랜잭션을 종료하지 않고 커넥션을 풀에 반납(상태 상속 오염) · C++
난이도: 상
요약
ConnGuard 는 RAII 로 커넥션 반납은 보장하지만, 트랜잭션 종료는 정상 경로(commit())에서만 한다. 조기 반환(B)이나 exec 예외 경로에서는 begin()(A)으로 연 트랜잭션이 열린 채 남고, 소멸자가 그 상태로 커넥션을 풀에 반납한다. 다음 차용자는 열린 트랜잭션·보유 락을 상속한다.
문제점
반납(RAII)과 트랜잭션 종료의 분리 — 세션 상태 상속
- 증상: 다음 요청의 쿼리가 이전 트랜잭션 안에서 실행되어 커밋 경계가 뒤섞이고, 이전 트랜잭션이 잡은 행 락을 계속 보유해 무관한 요청이 대기·데드락한다. 재차
begin()시 "트랜잭션 이미 진행 중" 오류. 미커밋 데이터가 남의 커밋에 함께 나가거나 남의 롤백에 휩쓸린다. - 재현 조건:
- 조기 반환:
amount <= 0→begin()직후return→guard소멸자가inTransaction==true인 커넥션을 반납. - 예외:
exec("INSERT ...")가 던지면 스택 되감기 중guard소멸자가 반납 —rollback()없이. (문제 코드의exec는 "FAIL" 포함 SQL에서 예외를 던지도록 되어 있어 이 경로가 실재한다.) - 다음
rent()가 이 커넥션을 돌려주고, 새 작업이 상속된 트랜잭션에 얹힌다.
- 조기 반환:
- 근본 원인:
ConnGuard의 책임이 "커넥션 반납"으로 한정되어, 커넥션이 보유한 논리적 상태(트랜잭션) 를 정리하지 않는다. RAII 가 자원 수명은 관리했지만 상태 불변식(반납 커넥션은 트랜잭션 없음)은 관리하지 않았다.
수정안
트랜잭션 자체를 RAII 로 감싸, 커밋되지 않은 트랜잭션은 스코프 종료 시 자동 롤백한다. 반납 시에도 마지막 안전망으로 롤백한다.
struct TxGuard {
PooledConn* conn;
bool committed = false;
explicit TxGuard(PooledConn* c) : conn(c) { conn->begin(); }
void commit() { conn->commit(); committed = true; }
~TxGuard() { if (!committed && conn->inTransaction) conn->rollback(); }
};
struct ConnGuard {
ConnPool& pool; PooledConn* conn;
ConnGuard(ConnPool& p, PooledConn* c) : pool(p), conn(c) {}
~ConnGuard() {
if (conn->inTransaction) conn->rollback(); // 반납 전 상태 리셋(안전망)
pool.ret(conn);
}
};
void grantReward(long userId, int amount) {
PooledConn* conn = pool_.rent();
ConnGuard cguard(pool_, conn);
TxGuard tx(conn); // begin
if (amount <= 0) return; // tx 소멸자가 rollback → 깨끗이 반납
conn->exec("UPDATE wallet ...");
conn->exec("INSERT INTO reward_log ..."); // 예외 시 tx 소멸자가 rollback
tx.commit();
}
소멸자 순서상 tx(트랜잭션) 가 cguard(반납) 보다 먼저 파괴되므로, 반납 시점엔 이미 트랜잭션이 종료돼 있다. ConnGuard 의 롤백은 이중 안전망이다. 소멸자에서 rollback() 이 던지지 않도록(noexcept 경계) 주의한다.
더 나은 설계
- 상태 있는 자원은 RAII 로 상태까지 관리: 커넥션 반납뿐 아니라 트랜잭션·락·세션 변수도 스코프 종료 시 기준선으로 되돌려야 한다. "자원 수명 관리"와 "상태 불변식 관리"를 한 가드에 묶는다.
- 풀 반납 계약 = 리셋:
pool.ret()이 항상 트랜잭션·세션 상태를 리셋하거나, 지저분하면 커넥션을 폐기·재생성한다. 반납 커넥션은 갓 만든 것과 구별되지 않아야 한다. - 트레이드오프: 소멸자 롤백/리셋은 미세한 비용이 있으나 상태 상속 버그의 간헐적·재현난해한 비용을 없앤다.
면접 포인트
- RAII 는 "자원을 반드시 되돌린다"를 보장하지만, 되돌리는 대상이 상태를 정리한 뒤의 자원이어야 한다. 트랜잭션을 별도 RAII(TxGuard)로 감싸 소멸자 순서로 "종료→반납"을 강제하는 것이 핵심.
- 소멸자에서 던지는 예외는
std::terminate위험이 있으므로, 롤백은 예외를 삼키거나 noexcept 로 설계한다.