
카드 사용 정산 시스템을 운영하던 중, 지출결의 생성 후 전문 기관으로 파일을 전송하는 과정에서 예기치 못한 트랜잭션 롤백 문제가 발생했습니다.
또한 잘못된 설계를 사용하고 사용자도 있어서 같이 개발하고있는 분에게 도움되고자
개선한 이야기를 포스팅합니다.
단순한 로직 설계처럼 보였으나, 근본적으로는 네트워크 I/O와 데이터베이스 트랜잭션의 생명주기를 어떻게 관리해야 하는가에 대한 깊은 고민이 필요했던 사례였습니다.
이 포스팅에서는 문제의 원인을 분석하고, 비동기 처리를 적용해 시스템의 안정성을 획기적으로 개선한 과정을 공유합니다.
정산 시스템에서 사용자가 지출결의를 요청하면 시스템은 번호를 생성하고, 이를 파일로 만들어 외부 FTP 서버로 전송합니다.
하지만 다음과 같은 증상이 지속적으로 보고되었습니다.
데이터 유실: FTP 전송 중 오류가 발생하면, 이미 성공적으로 생성되었어야 할 지출결의 특정 값이 DB에서 사라지는 현상 발생.
타임아웃의 연쇄 작용: 외부 FTP 서버 응답이 약 30초 이상 지연되면 DB 커넥션을 붙잡고 대기하다가 결국 전체 트랜잭션이 타임아웃으로 롤백 현상나타남..
리소스 고갈: 월말 결산기 등 사용자가 몰릴 때 FTP 연결 풀이 고갈되어 시스템 전체 응답 속도가 저하됨.
연구원 급여 정산 등 대량 약 5200건 이상 일괄 처리 시, 한 번의 타임아웃으로 인해 전체 작업이 취소되는 사례가 월 50건 이상 빈번하게 발생했습니다.
가장 큰 원인은 @Transactional 어노테이션이 붙은 메서드 내부에서 FTP 전송이라는 무거운 네트워크 작업을 수행한 것입니다.
DB 트랜잭션은 커넥션을 점유한 상태로 FTP 전송이 끝날 때까지 대기하게 됩니다.
Spring의 @Transactional은 런타임 예외 발생 시 기본적으로 롤백을 수행합니다.
FTP 전송 실패는 비즈니스 로직의 실패가 아닌 '외부 시스템 장애'임에도 불구하고, 내부 DB 작업까지 모두 취소시키는 결과를 초래했습니다.
FTP 서버는 동시 접속 제한이 엄격한 경우가 많습니다. 여러 사용자가 동시에 접근할 때 발생하는 대기 시간이 DB 트랜잭션 유지 시간과 결합되어 병목 현상을 가중시켰습니다.
문제 해결의 핵심 원칙은 "네트워크 I/O를 DB 트랜잭션 외부로 완전히 격리하는 것"이었습니다.
프로세스를 두 단계로 분리했습니다.
지출결의번호를 먼저 생성하고, 비동기 전송을 호출하는 구조로 변경했습니다.
@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);
});
}
비동기 작업이 시스템 전체에 영향을 주지 않도록 전용 스레드 풀을 구성했습니다.
@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;
}
}
일시적인 네트워크 장애를 해결하기 위해 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% 이상 감소 |
이번 트러블슈팅 과정을 통해 얻은 가장 큰 교훈은 트랜잭션의 경계를 명확히 하는 것입니다.
PENDING, IN_PROGRESS, SUCCESS, FAILED와 같은 도메인 상태를 관리함으로써 시스템의 추적 가능성이 높아졌습니다.이런이슈로 현재 코드의 @Transactional 안에 네트워크 호출이 숨어있지는 않은지 확인해보게 되었습니다.