재우니 개발자 블로그

저장 버튼 하나로 DB 두 곳 이상에 쓰는 기능을, 중간에 실패해도 데이터가 어긋나지 않게 만듭니다. 내 상황이 네 가지 경우 중 무엇인지 고르고, 그 경우의 코드를 순서대로 따라 하면 됩니다.

 

  • 대상: ASP.NET Core 8 + Dapper로 저장 기능을 처음 만드는 주니어 개발자
  • 환경: IIS가 설치된 Windows Server EC2 한 대, MSSQL EC2 한 대(같은 리전, 서로 다른 인스턴스), MSSQL 안에 이름이 다른 DB 여러 개
  • 읽는 순서: 단어 익히기 → 내 경우 고르기 → 경우별 따라 하기 → 자주 하는 실수 → 직접 확인하기

 

이 문서의 동작 설명은 Microsoft 공식 문서와 코드 분석을 기준으로 합니다. 우리 서버에서 실제로 어떻게 동작하는지는 마지막 장의 확인 코드로 직접 재 보세요.

 

1. 먼저 알아 둘 단어

이 여섯 단어만 알면 나머지 내용을 모두 읽을 수 있습니다.

 

단어 쉬운 뜻 비유
트랜잭션 여러 작업을 "전부 성공 아니면 전부 취소"로 묶은 단위 은행 이체: 내 통장 빼기 + 친구 통장 넣기
커밋(Commit) 묶음 전체를 확정 이체 완료 도장
롤백(Rollback) 묶음 전체를 처음 상태로 되돌림 이체 취소, 돈이 원래 통장으로
DTC(MSDTC) 서로 다른 DB를 한 트랜잭션으로 묶어 주는 Windows 서비스 두 은행 사이의 중재자
승격 .NET이 가벼운 트랜잭션을 DTC가 관리하는 무거운 분산 트랜잭션으로 바꾸는 것 동네 거래가 국제 거래로 바뀜
2PC(2단계 커밋) 모두에게 "확정해도 되나?"를 먼저 묻고, 전원이 "예"일 때만 확정하는 방식 결혼식: 두 사람 모두 "예" 해야 성혼 선언

 

 

가장 중요한 차이 하나만 기억하세요. 2PC는 전원 동의 후 한 번에 확정하고, 이 가이드의 경우 3은 한 명씩 차례로 확정합니다. 차례로 확정하면 첫 사람이 도장을 찍은 뒤 두 번째 사람이 사라지는 일이 생길 수 있습니다.

 

 

2. 내 상황은 어떤 경우인가

질문 세 개에 차례로 답하면 네 가지 경우 중 하나가 정해집니다. 코드를 쓰기 전에 이것부터 정하세요.

 

 

같은 서버 안의 다른 DB라면 경우 2가 가장 안전합니다. DB가 다른 서버에 있을 때만 경우 3과 경우 4 중에서 고릅니다.

 

경우 DB 위치 한쪽만 확정될 위험 DTC 필요 난이도
1 같은 DB 없음 아니요 쉬움
2 같은 서버, 다른 DB 없음 아니요 쉬움 (권한 확인 필요)
3 다른 서버 커밋 도중 장애 시 있음 아니요 보통
4 다른 서버 DTC가 조정 (드물게 사람이 정리) 예 코드는 쉬움, 서버 설정은 어려움

3. 경우 1: 같은 DB의 여러 테이블

같은 DB에만 쓴다면 TransactionScope로 감싸는 것이 가장 짧고 안전합니다. 리포지토리 코드는 거의 바꿀 필요가 없습니다.

1단계: 스코프를 만드는 도우미 만들기

옵션을 매번 적지 않도록 한 곳에 모아 둡니다.

using System.Transactions;

public static class TransactionHelper
{
    public static TransactionScope Begin()
    {
        return new TransactionScope(
            TransactionScopeOption.Required,
            new TransactionOptions
            {
                // 기본값은 Serializable(가장 엄격, 락이 많음)이라 ReadCommitted로 낮춘다
                IsolationLevel = IsolationLevel.ReadCommitted,
                Timeout = TimeSpan.FromMinutes(1)
            },
            // async/await 를 쓰면 반드시 필요. 없으면 await 이후 스코프를 잃어버린다
            TransactionScopeAsyncFlowOption.Enabled);
    }
}

 

 

2단계: 리포지토리는 평소처럼 쓰기

스코프 안에서 연결을 열기만 하면 .NET이 알아서 트랜잭션에 참여시킵니다. Dapper는 닫힌 연결을 넘기면 스스로 열어 줍니다.

public async Task<long> CreateAsync(BoardPostEntity entity)
{
    // 스코프 안에서 열리므로 자동으로 트랜잭션에 참여한다
    using var connection = _connectionFactory.CreateGwConnection();
    return await connection.ExecuteScalarAsync<long>(SqlQueries.InsertPost, entity);
}

 

 

3단계: 핸들러에서 감싸기

public async ValueTask<long> Handle(CreatePostCommand request, CancellationToken ct)
{
    using var scope = TransactionHelper.Begin();

    long postId = await _postRepository.CreateAsync(request.ToEntity());
    await _projectLinkRepository.CreateAsync(postId, request.ProjectId);
    await _attachmentRepository.MoveFromTempAsync(postId, request.AttachmentIds);

    scope.Complete();   // 여기까지 예외 없이 와야만 커밋된다
    return postId;
}

 

 

이럴 때는 이렇게 됩니다

상황 결과
중간에 예외 발생 Complete()를 못 불렀으니 전부 롤백
Complete() 전에 return 역시 전부 롤백 (경우 3과 반대이니 주의)
연결을 하나 닫고 다음 연결을 엶 (연결 문자열 완전히 같음) 가벼운 트랜잭션 유지
연결 문자열이 조금이라도 다름 (DB 이름, Application Name 등) 승격 시도 → NotSupportedException
Task.WhenAll로 두 리포지토리를 동시에 실행 연결 두 개가 동시에 열려 승격 시도 → 오류
스코프 밖에서 미리 열어 둔 연결을 사용 트랜잭션에 참여하지 않음, 롤백되지 않음

 

 

핵심 규칙은 두 가지입니다. 같은 연결 팩토리를 쓰고, await로 하나씩 차례로 호출하세요.

 

 

4. 경우 2: 같은 SQL 서버 안의 다른 DB

 

DB 이름은 달라도 같은 SQL Server 인스턴스에 있다면, 연결 하나로 두 DB에 모두 쓸 수 있습니다. 연결이 하나면 트랜잭션도 하나라서 DTC 없이 완전히 "전부 성공 아니면 전부 취소"가 됩니다. 가능하다면 이 방법이 가장 좋습니다.

 

1단계: 정말 같은 인스턴스인지 확인하기

두 연결 문자열로 각각 접속해 아래 쿼리를 실행합니다. 결과가 같으면 같은 인스턴스입니다.

-- 서버 이름과 현재 DB 이름을 함께 본다
SELECT @@SERVERNAME AS ServerName, DB_NAME() AS CurrentDb;

 

 

2단계: 권한 확인하기

웹앱이 쓰는 로그인은 두 DB 모두에 사용자로 등록돼 있어야 합니다. 다른 DB의 SP를 부르려면 그 DB에서 EXECUTE 권한도 필요합니다. 권한이 없으면 실행할 때 권한 오류가 납니다.

 

 

3단계: 연결 하나로 저장하기

 

테이블이나 SP 이름 앞에 DB이름.dbo.를 붙이면 다른 DB를 가리킵니다. 이것을 3부분 이름이라고 합니다.

public async Task<long> SaveAsync(SaveProjectCommand request, CancellationToken ct)
{
    // GW DB로 연결 하나만 연다
    await using var connection = _connectionFactory.CreateGwConnection();   // SqlConnection 반환
    await connection.OpenAsync(ct);
    await using var transaction = connection.BeginTransaction();

    try
    {
        // 1) 다른 DB(ERP)의 SP를 같은 연결, 같은 트랜잭션으로 호출
        var erpResult = await connection.QuerySingleAsync<ErpSaveResult>(
            "ERP.dbo.SP_SaveProject",
            new { request.CompanySeq, request.XmlData },
            transaction,
            commandType: CommandType.StoredProcedure);

        // 실패 상태면 return 이 아니라 예외를 던져 롤백시킨다
        if (erpResult.Status != 0)
            throw new InvalidOperationException($"ERP 저장 실패: {erpResult.Message}");

        // 2) GW DB 테이블에 로그 저장. 같은 transaction 을 넘긴다
        long logId = await connection.ExecuteScalarAsync<long>(
            SqlQueries.InsertProjectLog, request.ToLogEntity(), transaction);

        await transaction.CommitAsync(ct);   // 두 DB가 한 번에 확정된다
        return logId;
    }
    catch
    {
        await transaction.RollbackAsync(CancellationToken.None);   // 두 DB가 한 번에 취소된다
        throw;
    }
}

 

이럴 때는 이렇게 됩니다

상황 결과
어디서든 예외 발생 두 DB 모두 롤백
커밋 도중 연결 끊김 둘 다 확정되거나 둘 다 취소됨. 한쪽만 확정되는 일은 없음
DB 이름이 개발기와 운영기에서 다름 DB 이름을 코드에 박지 말고 설정 파일에서 읽기. 사용자 입력으로 DB 이름을 만들면 절대 안 됨
회사마다 ERP DB가 다름 (같은 인스턴스) 회사 ID로 설정에서 DB 이름을 찾아 쓰고, 허용 목록에 있는 이름인지 먼저 검사
다른 DB가 다른 서버에 있음 이 방법은 불가. 경우 3이나 경우 4로
SQL Server가 Always On 가용성 그룹 교차 DB 트랜잭션 지원 조건을 따로 확인해야 함

서버명.DB.dbo.테이블 같은 4부분 이름(Linked Server)은 다른 방식입니다. SQL 서버끼리 DTC를 쓰게 되므로 이 경우에 해당하지 않습니다.

 

 

5. 경우 3: 다른 서버의 DB (DTC를 쓰지 않을 때)

 

DB가 서로 다른 SQL 서버에 있고 DTC를 쓰지 않기로 했다면, DB마다 트랜잭션을 따로 열고 마지막에 차례로 커밋합니다. 실행 중 오류는 전부 롤백되지만, 커밋 도중 장애가 나면 한쪽만 확정될 수 있습니다. 이 한계를 알고 쓰는 방법입니다.

 

1단계: 리포지토리에 "트랜잭션 여는 입구" 만들기

public interface ITransactionRepository
{
    // 이 리포지토리가 쓰는 DB에 연결을 열고 트랜잭션을 시작해 돌려준다
    (IDbConnection Connection, IDbTransaction Transaction) BeginTransaction();
}

 

 

2단계: 쓰기 메서드는 트랜잭션을 "받기만" 하기

모든 쓰기 메서드의 마지막 인자에 IDbTransaction? transaction = null을 둡니다. 받은 트랜잭션이 있으면 그 연결을 그대로 씁니다.

public async Task<long> CreateAsync(ProjectLogEntity entity, IDbTransaction? transaction = null)
{
    if (transaction != null)
    {
        // 받은 트랜잭션의 연결을 쓴다. 이 연결은 서비스 것이므로 여기서 Dispose 하면 안 된다
        return await transaction.Connection!.ExecuteScalarAsync<long>(
            SqlQueries.InsertProjectLog, entity, transaction);
    }

    // 트랜잭션 없이 단독으로 부를 때만 자기 연결을 연다
    using var connection = _connectionFactory.CreateGwConnection();
    return await connection.ExecuteScalarAsync<long>(SqlQueries.InsertProjectLog, entity);
}

 

 

3단계: 묶음 서비스 (개선판)

원래 서비스에 세 가지를 더했습니다. 바깥 스코프 방어, 커밋 개수 세기, 부분 커밋 경보입니다.

public async Task<TResult> ExecuteAsync<TResult>(
    Func<IReadOnlyList<(IDbConnection Connection, IDbTransaction Transaction)>, CancellationToken, Task<TResult>> action,
    CancellationToken cancellationToken = default,
    params Func<(IDbConnection Connection, IDbTransaction Transaction)>[] transactionFactories)
{
    // [추가 1] 바깥에 TransactionScope 가 열려 있으면 연결이 거기에 끌려가 승격 오류가 난다
    if (System.Transactions.Transaction.Current != null)
        throw new InvalidOperationException("이 서비스는 TransactionScope 안에서 호출하면 안 됩니다.");

    var transactions = new List<(IDbConnection Connection, IDbTransaction Transaction)>();
    int committedCount = 0;   // [추가 2] 몇 번째까지 확정됐는지 센다

    try
    {
        foreach (var factory in transactionFactories)
            transactions.Add(factory());                           // DB마다 하나씩 연다

        var result = await action(transactions, cancellationToken);   // 실제 저장 로직

        foreach (var (_, transaction) in transactions)
        {
            transaction.Commit();                                  // 순서대로 확정
            committedCount++;
        }
        return result;
    }
    catch (Exception ex)
    {
        if (committedCount > 0)
        {
            // [추가 3] 이미 확정된 DB가 있다 = 데이터가 어긋났다. 사람이 확인해야 한다
            _logger.LogCritical(ex, "부분 커밋 발생: {Total}개 중 {Committed}개 확정",
                transactions.Count, committedCount);
        }
        else
        {
            _logger.LogError(ex, "트랜잭션 실행 실패 - 전부 롤백");
        }

        // 아직 확정되지 않은 것만 롤백한다 (확정된 것을 롤백하면 오류가 난다)
        foreach (var (_, transaction) in transactions.Skip(committedCount))
        {
            try { transaction.Rollback(); }
            catch (Exception rollbackEx) { _logger.LogError(rollbackEx, "ROLLBACK 실패"); }
        }
        throw;
    }
    finally
    {
        foreach (var (connection, transaction) in transactions)
        {
            transaction.Dispose();
            connection.Dispose();
        }
    }
}

 

 

4단계: 핸들러에서 쓰기

transactions[0]이 ERP, 그 뒤는 넘긴 리포지토리 순서입니다. 실패는 반드시 예외로 알립니다.

var (erpResponse, logId) = await _transactionService.ExecuteWithErpAsync(
    async (transactions, ct) =>
    {
        var erpTx = transactions[0].Transaction;   // ERP
        var gwTx  = transactions[1].Transaction;   // GW

        var erp = await _erpSpRepository.SaveToErpAsync(companyId, xmlData, erpTx);
        if (erp.Status != 0)
            throw new InvalidOperationException($"ERP 저장 실패: {erp.Message}");   // return 금지

        // 같은 GW DB에 쓰는 것은 전부 같은 gwTx 를 넘긴다
        var id = await _projectLogRepository.CreateAsync(logEntity, gwTx);
        await _remarkLogRepository.CreateAsync(remarkEntity, gwTx);
        return (erp, id);
    },
    cancellationToken,
    _erpTransactionRepository, companyId,   // transactions[0]
    _projectLogRepository);                 // transactions[1] (GW는 하나만 넘긴다)

 

 

이럴 때는 이렇게 됩니다

상황 결과
람다 안에서 예외 전부 롤백
람다가 return으로 끝남 전부 커밋 (실패도 return하면 커밋되니 주의)
취소 요청(CancellationToken) 예외가 나므로 전부 롤백
첫 번째 커밋이 실패 아무것도 확정되지 않음, 전부 롤백
두 번째 커밋이 실패 첫 번째는 확정, 두 번째는 롤백 → LogCritical 경보
바깥에 TransactionScope가 열려 있음 시작하자마자 예외로 알려 줌

커밋 순서 팁: 실패하기 쉬운 쪽을 먼저 커밋하세요. 첫 커밋이 실패하면 아무것도 확정되지 않기 때문입니다.

6. 경우 4: DTC를 쓰기로 했을 때

DTC가 동작하는 환경이라면 설정 한 줄로 다른 DB끼리도 TransactionScope를 쓸 수 있습니다. 이때는 진짜 2PC라서 경우 3의 "한쪽만 확정" 문제가 DTC의 조정으로 바뀝니다. 대신 서버 설정이 맞아야 하고, 느려집니다.

1단계: .NET에서 분산 트랜잭션 허용하기

.NET 7부터는 승격이 기본으로 꺼져 있어서, 켜지 않으면 NotSupportedException이 납니다. 앱이 시작될 때 한 번만 켭니다. Windows에서만 동작합니다.

// Program.cs 맨 위, builder 를 만들기 전에
System.Transactions.TransactionManager.ImplicitDistributedTransactions = true;

var builder = WebApplication.CreateBuilder(args);

 

 

2단계: 경우 1과 같은 코드로 다른 DB를 묶기

using var scope = TransactionHelper.Begin();   // 경우 1의 도우미 그대로

// ERP 연결이 먼저 열린다 (가벼운 트랜잭션)
var erp = await _erpSpRepository.SaveToErpAsync(companyId, xmlData);
if (erp.Status != 0)
    throw new InvalidOperationException($"ERP 저장 실패: {erp.Message}");

// GW 연결이 열리는 순간 DTC 분산 트랜잭션으로 승격된다
await _projectLogRepository.CreateAsync(logEntity);

scope.Complete();   // DTC가 두 서버에 "확정해도 되나?"를 묻고 한 번에 확정한다

 

 

3단계: 서버 설정 체크리스트 (IIS 서버와 SQL 서버 양쪽 모두)

SQL 서버끼리(Linked Server) DTC가 되는 것과 웹앱 서버 → SQL 서버 DTC가 되는 것은 다릅니다. 아래를 인프라 담당과 함께 확인하세요.

 

 

  • 두 서버 모두 Distributed Transaction Coordinator 서비스가 실행 중이고 자동 시작
  • 구성 요소 서비스 → 로컬 DTC 속성 → 보안 탭: 네트워크 DTC 액세스, 인바운드 허용, 아웃바운드 허용
  • 인증 방식을 두 서버가 같게 맞춤 (도메인에 가입되지 않은 서버는 상호 인증이 안 될 수 있으니 인프라 담당과 결정)
  • Windows 방화벽과 EC2 보안 그룹에서 TCP 135와 RPC 동적 포트를 양방향으로 허용
  • 두 서버가 서로의 컴퓨터 이름을 찾을 수 있음 (ping 상대컴퓨터이름이 되는지)
  • 두 EC2를 같은 이미지(AMI)로 복제했다면 DTC 고유 ID(CID)가 겹치지 않는지 확인. 겹치면 SQL 서버가 아닌 쪽에서 msdtc -uninstall → 재부팅 → msdtc -install로 새로 만든다

 

 

이럴 때는 이렇게 됩니다

상황 결과
1단계 설정을 안 함 두 번째 연결을 여는 순간 NotSupportedException
DTC 서비스가 멈춤 / 포트 막힘 저장 자체가 실패 (데이터는 안전하지만 기능이 멈춤)
확정 단계에서 네트워크가 끊김 "확정인지 취소인지 모르는" 트랜잭션이 남을 수 있음. 구성 요소 서비스에서 사람이 정리
같은 DB만 쓰는 화면 승격이 안 되므로 경우 1과 똑같이 가볍게 동작

 

 

정리하면 경우 4는 데이터 일관성이 가장 강하지만, 서버 하나가 더 "망가질 수 있는 부품"이 됩니다. 팀이 경우 3을 고른 것은 이 비용을 피하려는 선택이지, DTC가 불가능해서가 아닙니다.

 

 

7. 자주 하는 실수 7가지

대부분의 사고는 아래 일곱 가지에서 나옵니다. 증상을 보면 원인을 바로 찾을 수 있도록 정리했습니다.

# 실수 이렇게 보입니다 이렇게 고칩니다
1 경우 3에서 실패를 return으로 처리 오류 화면이 떴는데 ERP에 반쪽 데이터가 남음 실패는 무조건 throw
2 같은 DB인데 리포지토리마다 트랜잭션을 따로 엶 저장 버튼 후 화면이 멈췄다가 약 30초 뒤 타임아웃 같은 DB는 트랜잭션 하나, 모든 호출에 같은 transaction 전달
3 Task.WhenAll로 저장을 동시에 실행 승격 오류(NotSupportedException) 또는 연결 관련 오류 await로 하나씩 차례로
4 catch에서 로그만 남기고 예외를 삼킴 DB 오류가 났는데 저장 성공으로 처리됨 트랜잭션 안에서는 catch 후 반드시 throw;
5 트랜잭션 안에서 S3 업로드, 메일, 외부 API 호출 DB는 롤백됐는데 메일은 이미 나감 외부 작업은 커밋이 끝난 뒤에
6 안에 ROLLBACK이 있는 SP를 묶음 안에서 호출 오류 266(트랜잭션 개수 불일치), 바깥 작업까지 사라짐 SP 본문 확인 후, 그런 SP는 묶음 밖에서 먼저 실행
7 람다 안에서 BeginTransaction()을 또 부르거나 바깥에 TransactionScope를 엶 트랜잭션이 하나 더 생겨 2번과 같은 멈춤, 또는 승격 오류 서비스가 넘겨 준 트랜잭션만 사용

 

 

2번이 왜 멈추는지 조금 더 설명하면 이렇습니다. 트랜잭션 A가 부모 행을 쓰고 아직 확정하지 않았습니다. 트랜잭션 B가 자식 행을 쓰려면 부모가 있는지 확인해야 해서 A의 확정을 기다립니다. 그런데 A는 B가 끝나야 확정합니다. 두 트랜잭션이 우리 앱 안에서 서로를 기다리니 SQL Server는 교착 상태로 알아채지 못하고, 명령 타임아웃(기본 30초)까지 그냥 기다립니다.

 

8. 직접 확인하기

세 가지 시험으로 우리 서버의 실제 동작을 확인합니다. 모두 개발기에서만 하세요.

 

시험 A: 승격이 되는지 눈으로 보기

IIS 서버에서 작은 콘솔 앱을 만들어 실행합니다. 트랜잭션에 "분산 ID"가 붙었는지로 판단합니다. 비어 있으면 가벼운 트랜잭션, 값이 있으면 DTC로 승격된 것입니다.

 

  1. 명령 프롬프트에서 프로젝트를 만듭니다.
dotnet new console -n TxProbe
cd TxProbe
dotnet add package Microsoft.Data.SqlClient

 

 

  1. Program.cs를 아래 코드로 바꿉니다. 연결 문자열 두 줄은 우리 환경에 맞게 고칩니다.
using System.Transactions;
using Microsoft.Data.SqlClient;

// 실행할 때 "dtc"를 붙이면 승격을 허용한다
if (args.Contains("dtc"))
    TransactionManager.ImplicitDistributedTransactions = true;

const string gw  = "Server=SQL서버;Database=GW;Integrated Security=true;TrustServerCertificate=true";
const string erp = "Server=SQL서버;Database=ERP;Integrated Security=true;TrustServerCertificate=true";

Test("A. 같은 DB를 차례로 열기", gw, gw);
Test("B. 다른 DB를 차례로 열기", gw, erp);

static void Test(string name, string cs1, string cs2)
{
    try
    {
        using var scope = new TransactionScope();

        using (var c1 = new SqlConnection(cs1)) { c1.Open(); }   // 첫 연결: 열고 닫기
        using (var c2 = new SqlConnection(cs2)) { c2.Open(); }   // 두 번째 연결

        var id = Transaction.Current!.TransactionInformation.DistributedIdentifier;
        Console.WriteLine(id == Guid.Empty
            ? $"{name}: 가벼운 트랜잭션 유지"
            : $"{name}: DTC로 승격됨");
        // scope.Complete()를 부르지 않으니 자동 롤백된다
    }
    catch (Exception ex)
    {
        Console.WriteLine($"{name}: 오류 - {ex.GetType().Name}: {ex.Message}");
    }
}

 

 

  1. 두 번 실행합니다. 설정이 섞이지 않도록 각각 따로 실행하세요.
dotnet run
dotnet run -- dtc

 

 

문서 기준으로 예상되는 결과는 아래와 같습니다. 다르게 나오면 그것이 우리 환경의 실제 모습이니 기록해 두세요.

 

 

실행 방법 A. 같은 DB B. 다른 DB
dotnet run 가벼운 트랜잭션 유지 NotSupportedException
dotnet run -- dtc 가벼운 트랜잭션 유지 DTC로 승격됨 (서버 설정이 맞으면) 또는 DTC 연결 오류

 

시험 B: 실행 중 오류가 정말 롤백되는지 보기

저장 로직 안에 임시로 예외를 넣습니다. 일반 사용자에게 영향이 없도록 비고에 표시 문자열이 있을 때만 터지게 합니다.

// [임시 검증] 시험이 끝나면 반드시 지울 것
if (request.BaseInfo.Remark?.Contains("[강제롤백시험]") == true)
    throw new InvalidOperationException("강제 롤백 시험");

 

 

이때 프로젝트 이름처럼 값이 실제로 바뀌는 항목도 함께 수정해서 저장하세요. 같은 값으로 저장하면 "롤백됐다"와 "원래 안 바뀌었다"를 구분할 수 없습니다. 저장 후 DB에서 확인합니다.

-- GW: 방금 로그에 찍힌 로그 ID가 남아 있으면 롤백 실패
SELECT MAX(ProjectLogId) AS max_id FROM ProjectLog WHERE PJTSeq = @pjtSeq;

-- ERP: 이름과 수정 시각이 시험 전 값 그대로여야 롤백 성공
SELECT PJTName, LastDateTime FROM _TPJTProject
WHERE CompanySeq = @companySeq AND PJTSeq = @pjtSeq;

 

 

시험 C: 커밋 도중 장애 (부분 커밋) 재현하기

 

경우 3의 한계를 직접 보고, 개선판의 LogCritical 경보가 찍히는지 확인합니다.

 

  1. 서비스의 커밋 반복문에서 committedCount++; 줄에 중단점을 겁니다.
  2. 두 번째 트랜잭션의 세션 번호를 알 수 있게, 개발기에서만 람다 안에 SELECT @@SPID를 실행해 로그로 남깁니다.
  3. 디버그 실행 후 저장합니다. 첫 번째 커밋 직후 중단점에서 멈춥니다.
  4. SSMS에서 두 번째 세션을 끊습니다: KILL <세션 번호>;
  5. 계속 실행합니다. 두 번째 커밋이 실패하고 "부분 커밋 발생: 2개 중 1개 확정" 로그가 찍히면 성공입니다.
  6. DB를 조회해 첫 번째 DB만 바뀌었는지 확인하고, 시험 데이터를 원래대로 돌려 둡니다.

 

9. 한 장 요약과 체크리스트

 

방법은 DB의 수와 위치로 정해집니다. 같은 DB면 경우 1, 같은 서버면 경우 2, 다른 서버면 경우 3이나 경우 4입니다.

새 저장 코드를 쓰기 전에 아래를 하나씩 확인하세요.

 

  • 쓰는 DB가 몇 곳이고, 각각 어느 SQL 서버에 있는지 확인했다
  • 같은 서버의 다른 DB라면 연결 하나 + DB이름.dbo.테이블을 먼저 검토했다
  • 실패는 return이 아니라 throw로 알린다
  • 같은 DB에는 트랜잭션을 하나만 열고, 모든 호출에 같은 transaction을 넘긴다
  • Task.WhenAll로 저장을 동시에 돌리지 않는다
  • 트랜잭션 안의 catch는 반드시 다시 throw한다
  • S3, 메일, 외부 API는 커밋이 끝난 뒤에 호출한다
  • 묶음 안에서 부르는 SP 본문에 ROLLBACK이 없는지 봤다
  • 개발기에서 강제 롤백 시험(시험 B)을 값이 바뀌는 항목으로 해 봤다

 

참고 자료