
카드 사용 정산 시스템을 운영하며 지출결의 데이터 생성과 외부 FTP 파일 전송을 분리하는 작업을 진행했습니다.
초기에는 애플리케이션 레벨의 비동기 스레드(@Async)를 활용해 DB 트랜잭션과 네트워크 I/O를 분리하여 큰 효과를 보았습니다.
하지만 월말 결산 시 5,200건 이상의 대량 트래픽이 한 번에 몰리거나, 대상 FTP 서버가 아예 다운되어 응답조차 하지 않는 상황에서는 애플리케이션 내부 큐가 가득 차고 DB에 부하가 가는 등 새로운 한계점이 드러났습니다.
이 포스팅에서는 단순 비동기 처리를 넘어, 메시지 브로커와 서킷 브레이커를 도입해 외부 장애로부터 우리 시스템을 완벽하게 보호한 아키텍처 고도화 경험을 공유합니다.
DB 트랜잭션과 네트워크 전송을 @Async로 분리했지만, 트래픽이 폭증할 때 다음과 같은 문제들이 발생했습니다.
메모리 기반 큐의 유실 위험: 스레드 풀의 대기 큐에 쌓인 작업들은 서버가 예기치 않게 재시작될 경우 모두 증발해버립니다.
DB 폴링 오버헤드: 실패한 작업을 재시도하기 위해 1분마다 DB 전체를 스캔하는 스케줄러는 데이터가 쌓일수록 DB 성능을 갉아먹는 주범이 됩니다.
무의미한 타임아웃 대기: FTP 서버가 완전히 죽었음에도, 5,200건의 요청이 각각 30초의 타임아웃을 꽉 채워 대기하면서 시스템의 네트워크 스레드를 고갈시켰습니다.
메모리와 DB에 의존하던 비동기 작업 관리를 전문적인 메시지 브로커로 이관했습니다.
지출결의가 DB에 커밋되면 즉시 메시지만 발행하고 서버는 응답을 반환합니다.
@Service
@RequiredArgsConstructor
public class FtpEventPublisher {
private final RabbitTemplate rabbitTemplate;
public void publishTransferEvent(FtpTransferDto transferDto) {
// FTP 전송에 필요한 데이터만 큐에 적재 후 즉시 리턴
rabbitTemplate.convertAndSend("ftp.exchange", "ftp.routing.key", transferDto);
}
}
스케줄러를 통한 DB 스캔 대신, RabbitMQ의 재시도 큐 기능을 활용했습니다.
전송에 실패한 메시지는 자동으로 DLQ로 이동하며, 지정된 백오프 시간 이후에만 소비자가 다시 꺼내어 재시도합니다. DB 부하는 0이 되었습니다.
@RabbitListener(queues = "ftp.transfer.queue")
public void handleFtpTransfer(FtpTransferDto dto) {
try {
ftpClient.uploadFile(dto.getRemotePath(), dto.getFileContent());
} catch (Exception e) {
// 예외 발생 시 메시지는 자동으로 Retry/DLQ 파이프라인으로 이동
throw new AmqpRejectAndDontRequeueException("FTP 전송 실패, DLQ로 이동", e);
}
}
가장 치명적인 상황은 대상 FTP 서버가 장애를 일으켰을 때입니다.
무의미한 연결 시도를 차단하기 위해 Resilience4j를 활용한 서킷 브레이커를 적용했습니다.
FTP 서버의 실패율이 임계치 50%를 넘으면 서킷이 'OPEN' 상태가 됩니다.
이후 들어오는 5,200건의 요청은 FTP 서버로 아예 접근하지 않고 즉시 예외를 발생시키거나 Fallback 로직을 실행합니다.
이를 통해 우리 서버의 커넥션과 스레드가 고갈되는 것을 원천 차단합니다.
@CircuitBreaker(name = "ftpServer", fallbackMethod = "fallbackFtpTransfer")
public void uploadToFtp(String path, byte[] content) {
// 서킷이 OPEN 상태면 이 로직은 실행되지 않고 즉시 fallback 호출
ftpClient.uploadFile(path, content);
}
// 서킷 브레이커가 열렸을 때 실행될 대체 로직
public void fallbackFtpTransfer(String path, byte[] content, CallNotPermittedException e) {
log.error("FTP 서버 장기 장애 감지. 로컬 디스크에 임시 보관합니다.");
localBackupService.saveTemporarily(path, content);
}
단순 스레드 분리 방식과 메시지 큐/서킷 브레이커 조합의 아키텍처 차이를 비교하면 다음과 같습니다.
| 구분 | 1단계 (스레드 풀 + 스케줄러) | 2단계 (MQ + 서킷 브레이커) |
|---|---|---|
| 작업 보관소 | 서버 메모리 (Tomcat/Spring 내부) | RabbitMQ/Kafka (외부 영속화) |
| 서버 재시작 시 | 진행/대기 중인 작업 유실 위험 | 큐에 안전하게 보존되어 재개 가능 |
| 재시도 메커니즘 | 주기적인 DB 전체 스캔 (DB 부하 증가) | DLQ를 통한 이벤트 기반 재시도 (부하 없음) |
| 상대 서버 장애 시 | 타임아웃까지 대기 (리소스 낭비) | 서킷 브레이커로 즉각 차단 (빠른 실패) |
네트워크 I/O를 분리하는 것은 안정적인 시스템을 위한 첫 단추입니다. 하지만 외부 시스템은 우리의 통제 밖에 있으며, 상대방의 장애가 우리 시스템의 장애로 전파되지 않도록 방화벽을 치고, 트래픽 폭포를 담아낼 저수지를 구축하는 것이 분산 시스템 설계의 핵심임을 배웠습니다.
단순한 로직 변경을 넘어 시스템 전체의 복원력을 고민하게 된 이번 경험은, 향후 대규모 마이크로서비스 아키텍처를 설계하는 데 있어 큰 밑거름이 될 것입니다.