FTP 전송 실패로 인한 네트워크 I/O와 DB 트랜잭션의 분리트랜잭션 롤백 문제

카드 사용 정산 시스템을 운영하던 중, 지출결의 생성 후 전문 기관으로 파일을 전송하는 과정에서 예기치 못한 트랜잭션 롤백 문제가 발생했습니다.
또한 잘못된 설계를 사용하고 사용자도 있어서 같이 개발하고있는 분에게 도움되고자
개선한 이야기를 포스팅합니다.
단순한 로직 설계처럼 보였으나, 근본적으로는 네트워크 I/O와 데이터베이스 트랜잭션의 생명주기를 어떻게 관리해야 하는가에 대한 깊은 고민이 필요했던 사례였습니다.

이 포스팅에서는 문제의 원인을 분석하고, 비동기 처리를 적용해 시스템의 안정성을 획기적으로 개선한 과정을 공유합니다.


네트워크 지연이 DB 데이터 증발로

정산 시스템에서 사용자가 지출결의를 요청하면 시스템은 번호를 생성하고, 이를 파일로 만들어 외부 FTP 서버로 전송합니다.
하지만 다음과 같은 증상이 지속적으로 보고되었습니다.

주요 증상

  • 데이터 유실: FTP 전송 중 오류가 발생하면, 이미 성공적으로 생성되었어야 할 지출결의 특정 값이 DB에서 사라지는 현상 발생.

  • 타임아웃의 연쇄 작용: 외부 FTP 서버 응답이 약 30초 이상 지연되면 DB 커넥션을 붙잡고 대기하다가 결국 전체 트랜잭션이 타임아웃으로 롤백 현상나타남..

  • 리소스 고갈: 월말 결산기 등 사용자가 몰릴 때 FTP 연결 풀이 고갈되어 시스템 전체 응답 속도가 저하됨.

실제 발생 사례

연구원 급여 정산 등 대량 약 5200건 이상 일괄 처리 시, 한 번의 타임아웃으로 인해 전체 작업이 취소되는 사례가 월 50건 이상 빈번하게 발생했습니다.


근본 원인 분석

1. 트랜잭션 내 네트워크 I/O 포함

가장 큰 원인은 @Transactional 어노테이션이 붙은 메서드 내부에서 FTP 전송이라는 무거운 네트워크 작업을 수행한 것입니다.
DB 트랜잭션은 커넥션을 점유한 상태로 FTP 전송이 끝날 때까지 대기하게 됩니다.

2. 예외 전파로 인한 무조건적 롤백

Spring의 @Transactional은 런타임 예외 발생 시 기본적으로 롤백을 수행합니다.
FTP 전송 실패는 비즈니스 로직의 실패가 아닌 '외부 시스템 장애'임에도 불구하고, 내부 DB 작업까지 모두 취소시키는 결과를 초래했습니다.

3. 동시성 및 타임아웃 설정 미비

FTP 서버는 동시 접속 제한이 엄격한 경우가 많습니다. 여러 사용자가 동시에 접근할 때 발생하는 대기 시간이 DB 트랜잭션 유지 시간과 결합되어 병목 현상을 가중시켰습니다.


비동기 처리와 상태 기반 관리 해결하게되다.

문제 해결의 핵심 원칙은 "네트워크 I/O를 DB 트랜잭션 외부로 완전히 격리하는 것"이었습니다.

1. 트랜잭션 분리 및 비동기 워크플로우 도입

프로세스를 두 단계로 분리했습니다.

  1. 1단계: DB에 지출결의 데이터를 먼저 저장하고 트랜잭션을 대기상태로 커밋합니다.
  2. 2단계: 커밋 직후 별도 스레드에서 FTP 전송을 비동기로 실행합니다.

2. 개선된 코드 구조

애플리케이션 서비스 레이어

지출결의번호를 먼저 생성하고, 비동기 전송을 호출하는 구조로 변경했습니다.

@Transactional
public FtpTransferId requestTransfer(String paymentRequestNo, String remotePath, 
                                     String fileName, byte[] fileContent) {
    // 1. DB 작업: 지출결의 데이터를 먼저 저장 (빠르게 완료 및 커밋 대상)
    FtpTransfer transfer = FtpTransfer.create(paymentRequestNo, remotePath, fileName, fileContent);
    repository.save(transfer);
    
    // 2. 비동기 호출: 별도 스레드에서 네트워크 작업 수행
    transferAsync(transfer.getTransferId());
    
    // 3. 트랜잭션 즉시 종료 (FTP 성공 여부와 관계없이 번호 유지)
    return transfer.getTransferId();
}

비동기 전송 처리

@Async를 활용하여 메인 트랜잭션과 독립적인 생명주기를 갖게 합니다.

@Async("ftpExecutor")
@Transactional
public void transferAsync(FtpTransferId transferId) {
    repository.findById(transferId)
        .ifPresent(transfer -> {
            try {
                transfer.start(); // 상태 업데이트: 진행 중
                repository.save(transfer);
                
                ftpClient.uploadFile(transfer.getRemotePath(), transfer.getFileName(), transfer.getFileContent());
                
                transfer.complete(); // 상태 업데이트: 완료
            } catch (Exception e) {
                transfer.fail(e.getMessage()); // 실패 시 로그 기록, DB 데이터는 유지
            }
            repository.save(transfer);
        });
}

아키텍처 및 설정

Spring @Async 스레드 풀 설정

비동기 작업이 시스템 전체에 영향을 주지 않도록 전용 스레드 풀을 구성했습니다.

@Configuration
@EnableAsync
public class FtpConfig {
    @Bean(name = "ftpExecutor")
    public Executor ftpExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);    // 기본 스레드 수
        executor.setMaxPoolSize(10);    // 최대 확장 스레드 수
        executor.setQueueCapacity(100); // 대기 큐
        executor.setThreadNamePrefix("ftp-worker-");
        executor.initialize();
        return executor;
    }
}

자동 재시도 메커니즘 (Scheduling)

일시적인 네트워크 장애를 해결하기 위해 1분마다 실패한 작업을 자동으로 다시 시도하는 스케줄러를 추가했습니다.

@Scheduled(fixedDelay = 60000)
@Transactional
public void retryFailedTransfers() {
    List<FtpTransfer> failedTransfers = repository.findFailedTransfers();
    failedTransfers.stream()
        .filter(FtpTransfer::canRetry)
        .forEach(transfer -> transferService.transferAsync(transfer.getTransferId()));
}

개선 효과 분석

개선 후 약 한 달간 모니터링한 결과, 지표상에서 괄목할 만한 성능 향상을 확인할 수 있었습니다.

비교 항목개선 전 (Before)개선 후 (After)개선율
평균 트랜잭션 시간30초 이상1~2초약 95% 단축
FTP 오류 시 데이터 상태롤백 (데이터 손실)보존 및 상태 '실패' 기록안정성 100% 확보
시스템 응답성전송 완료 시까지 대기요청 후 즉시 응답체감 속도 대폭 향상
월간 타임아웃 발생 건수20건 이상1건 미만95% 이상 감소

배운 점 및 결론

이번 트러블슈팅 과정을 통해 얻은 가장 큰 교훈은 트랜잭션의 경계를 명확히 하는 것입니다.

  1. 네트워크 I/O는 트랜잭션의 적이다: DB 커넥션은 한정된 자원입니다.
    외부 API 호출, FTP 전송 등 시간이 불투명한 작업은 반드시 트랜잭션 밖으로 밀어내야 합니다.
  2. 상태 기반 처리의 중요성: 단순히 성공/실패로 끝내는 것이 아니라 PENDING, IN_PROGRESS, SUCCESS, FAILED와 같은 도메인 상태를 관리함으로써 시스템의 추적 가능성이 높아졌습니다.
  3. 사용자 경험 개선: 비동기 처리는 서버 리소스 관리뿐만 아니라, 사용자가 "모래시계"를 보는 시간을 줄여주는 가장 효과적인 방법이었다 생각이 들었습니다

이런이슈로 현재 코드의 @Transactional 안에 네트워크 호출이 숨어있지는 않은지 확인해보게 되었습니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글