가맹점 접수 API의 응답시간을 약 5~15초에서 1초로 줄였습니다. 파일 전송을 요청 스레드에서 분리해 @Async로 실행한 결과였습니다.
하지만 빨라진 응답 뒤에 새로운 실패 경로가 생겼습니다. 접수 데이터는 DB에 저장됐고 사용자는 성공 응답을 받았지만, 비동기 스레드가 SFTP 세션을 얻지 못하면 신청서와 KYC 서류가 파일 서버에 전달되지 않았습니다. 실패 사실을 남기는 상태도, 원본을 다시 전송할 주체도 없었습니다.
실제로 가맹점 접수 2건에서 파일 묶음 전체가 유실됐습니다. 이 문제를 해결하면서 단순히 SFTP 연결을 안정화하는 데서 멈추지 않았습니다. 세션 부족이라는 직접 원인과, 비동기 실패를 복구할 수 없다는 구조 원인을 분리해 해결했습니다.
개선 후 1년 이상, 가맹점 접수 2만 건 이상을 처리하는 동안 같은 유형의 파일 유실은 관찰되지 않았습니다. 다만 이것을 이론적인 무손실 보장이라고 부르지는 않습니다. 당시 선택한 로컬 디스크에는 분명한 실패 경계가 남아 있기 때문입니다.
한 번의 개선이 다음 문제를 만들었다
처음부터 비동기 구조였던 것은 아닙니다. 변화는 두 단계로 진행됐습니다.
최초 구조에서는 접수 요청 안에서 신청서와 KYC 파일을 생성하고 SFTP 전송까지 마친 뒤 응답했습니다. 성공의 의미는 명확했지만 파일 개수와 크기, 외부 파일 서버의 I/O 시간이 API 지연시간에 그대로 포함됐습니다. 요청마다 편차가 있었고 응답에는 약 5~15초가 걸렸습니다.
응답 지연을 줄이기 위해 파일 전송을 @Async로 분리했습니다. API는 접수 데이터를 저장한 뒤 약 1초 안에 응답할 수 있었습니다. 접수 트랜잭션을 커밋한 다음 파일 묶음 전송을 비동기로 실행했기 때문에, SFTP 실패는 이미 확정된 접수를 되돌릴 수 없었습니다. 비동기는 실패를 없앤 것이 아니라 요청이 끝난 뒤로 옮겼습니다.
접수 1건에는 가맹점 신청서 1개와 KYC 서류 3개가 기본으로 포함됐고, 경우에 따라 최대 7개까지 생성됐습니다. 비동기 작업은 로그인된 SFTP 세션 하나로 이 파일 묶음을 전송했습니다. 장애가 발생한 2건에서는 세션 로그인 단계에서 세션을 얻지 못해 묶음 전체가 전달되지 않았습니다.
정확한 예외 메시지는 남아 있지 않습니다. 다만 인프라팀과 장애 시각의 파일 서버 로그인 기록을 대조한 결과, 여러 서비스와 개발자가 함께 쓰던 계정이 동시 세션 제한에 도달한 사실을 확인했습니다. 구체적인 서버 오류나 변경 후 계정 한도는 기억에 의존해 만들지 않고, 공유 계정의 세션 한도가 직접 원인이었다는 확인 범위까지만 기록합니다.
세션을 늘리는 것만으로는 부족했다
원인은 두 층으로 나뉘었습니다.
| 원인 | 드러난 문제 | 필요한 대응 |
|---|---|---|
| 직접 원인 | 공유 SFTP 계정의 세션 한도에 도달해 신규 로그인 실패 | 계정 정책 조정과 세션 재사용 |
| 구조 원인 | 비동기 전송 실패를 기록하거나 다시 실행할 수 없음 | 전송 의무의 영속화와 재처리 주체 |
인프라팀에 공유 계정의 동시 세션 상향을 요청했습니다. 애플리케이션에서는 JSch Session을 Apache Commons Pool2로 감싼 세션 풀을 Spring Bean으로 만들었습니다. 이중화된 WAS 두 대가 각각 최대 5개의 세션을 재사용하므로, 애플리케이션이 사용하는 세션은 전체 최대 10개로 제한됩니다.
세션의 생존 여부는 확인했지만, 검증 시점을 연결하지 못했다
세션 팩토리의 validateObject()에는 두 단계 검증을 구현했습니다. 먼저 Session.isConnected()로 연결 상태를 확인하고, 이어서 SFTP 채널을 생성해 connect()까지 성공하는지 검사했습니다. 로컬 객체의 상태만 보는 데서 끝내지 않고 실제 채널 연결 가능 여부까지 확인하려는 의도였습니다.
그러나 validateObject()를 구현하는 것만으로 검증이 자동 실행되지는 않습니다. 당시에는 Commons Pool2의 testOnBorrow, testOnReturn, testWhileIdle을 활성화하지 않았습니다. 파일 전송의 성공 여부와 관계없이 빌린 세션도 풀에 반환했습니다. 따라서 전송 중 네트워크나 Transport 계층에서 손상된 세션이 다시 대여될 가능성이 남아 있었습니다.
이 허점이 당시 2건의 유실을 일으킨 원인은 아닙니다. 확인된 직접 원인은 공유 계정의 동시 세션 한도였습니다. 다만 세션 풀을 다시 구현한다면 오류의 범위를 구분하겠습니다. 경로나 권한처럼 파일 단위 오류가 발생했고 세션이 유효하다면 반환할 수 있지만, 연결 단절이나 채널 연결 실패처럼 세션 자체를 신뢰할 수 없는 경우에는 returnObject() 대신 invalidateObject()로 폐기해야 합니다.
여기서 maxTotal=5는 항상 세션 5개를 유지한다는 뜻이 아니라 최대 생성 수를 제한한다는 뜻입니다. 세션 하나를 폐기하면 풀 크기는 일시적으로 4개가 될 수 있고, 이후 대여 요청에서 여유가 필요하면 팩토리가 새 세션을 생성해 최대 5개까지 다시 채웁니다.
다시 구현한다면 다음처럼 풀의 검증 시점과 반환 정책을 함께 연결하겠습니다. 아래 코드는 당시 구현을 그대로 옮긴 것이 아니라, 확인된 한계를 보완한 핵심 예시입니다.
config.setTestOnBorrow(true); // 대여 전에 validateObject() 실행
Session session = null;
boolean invalidated = false;
try {
session = sessionPool.borrowObject();
uploadBundle(session, files);
} catch (JSchException | SftpException e) {
if (session != null && isTransportFailure(session, e)) {
invalidated = true;
sessionPool.invalidateObject(session);
}
throw e;
} finally {
if (session != null && !invalidated) {
sessionPool.returnObject(session);
}
}
핵심은 모든 예외에서 세션을 폐기하는 것이 아닙니다. Session이 끊겼거나 채널 연결에 실패한 Transport 오류라면 폐기하고, 원격 경로나 권한처럼 파일 단위 오류이면서 세션 연결이 유효하다면 풀에 반환합니다. testOnBorrow=true는 반환된 세션을 다음 요청이 사용하기 직전에 다시 검증합니다. 다만 이 예시의 검증은 채널 connect()까지 수행하므로, 안정성을 얻는 대신 대여마다 추가 연결 비용을 지불합니다.
이 조치로 접수마다 로그인 세션을 새로 만들던 비용과 세션 수의 무제한 증가를 통제할 수 있었습니다. 하지만 세션 풀만으로는 파일 서버 장애, 네트워크 단절, 프로세스 종료 같은 다른 실패를 복구할 수 없습니다. 실패 원인이 달라지면 파일은 다시 사라질 수 있었습니다.
그래서 질문을 바꿨습니다.
어떻게 하면 전송이 실패하지 않을까?
가 아니라,
전송이 실패해도 시스템이 해야 할 일을 잊지 않으려면 무엇을 남겨야 할까?
를 해결해야 했습니다.
실제로 검토한 선택지
당시 검토한 선택지는 다음과 같습니다.
| 선택지 | 장점 | 한계와 판단 |
|---|---|---|
| API 응답 전 동기 SFTP | 응답 성공과 파일 전달의 의미가 단순함 | 약 5~15초의 응답 지연과 외부 파일 서버 장애 전파 때문에 되돌아가기 어려움 |
| 메모리에서 즉시 재시도 | 구현 범위가 작고 일시 오류 흡수 가능 | 프로세스가 종료되면 재시도 정보와 파일 위치를 함께 잃음 |
| DB에 실패 상태만 기록 | 실패를 조회할 수 있음 | 누가 언제 재실행할지, 원본 파일을 어디서 찾을지 해결하지 못함 |
| Outbox와 배치 재처리 | 기존 RDB 안에서 전송 의무와 재시도 상태를 영속화 | 최대 10분의 재처리 지연과 DB Polling 비용을 수용해야 함 |
| S3 도입 | 인스턴스와 무관한 공유 원본 보관 | 이미 별도 파일 서버가 있어 당시에는 저장소·권한·보존 정책을 추가하는 비용이 과하다고 판단 |
| 메시지 브로커 도입 | 전송 Worker 분리와 이벤트 기반 재처리에 유리 | 원본 파일 보관 문제는 별도로 풀어야 하고 현재 규모에는 Consumer 운영까지 추가할 이유가 작음 |
S3와 메시지 브로커가 기술적으로 나쁜 선택은 아니었습니다. 다만 파일의 최종 목적지가 이미 별도 파일 서버였고, 문제의 규모에 비해 저장소나 비동기 Consumer의 운영 범위를 넓히는 비용이 컸습니다. 메시지 브로커만 도입해서는 재전송할 원본 파일을 어디에 보관할지도 해결되지 않았습니다.
반면 Outbox는 이미 운영 중인 RDB 트랜잭션으로 접수가 저장됐다면 파일을 보내야 한다는 기록도 반드시 존재하도록 만들 수 있었습니다. 응답시간 1초를 유지하면서 실패를 자동 복구한다는 목표에도 가장 가까웠습니다.
Outbox에는 파일이 아니라 전송 의무를 저장했다
접수 데이터와 Outbox 레코드를 하나의 DB 트랜잭션에서 저장했습니다.
BEGIN
INSERT merchant_application (...)
INSERT file_transfer_outbox (
application_id,
file_bundle_uuid,
owner_server_ip,
status = 'PENDING'
)
COMMIT
비동기 파일 전송 제출
위 의사코드는 트랜잭션 경계를 설명하기 위한 축약 예시입니다. 핵심은 다음 세 가지입니다.
- 접수 1건마다 Outbox 레코드 1건을
PENDING으로 생성합니다. - 로컬 파일 묶음을 찾을 UUID와 파일을 보유한 WAS의 내부 IP를 함께 기록합니다.
- 접수 저장과 Outbox 저장은 같은 트랜잭션에 포함합니다.
실제 로컬 경로에는 접수를 식별할 수 있는 값과 UUID가 사용됐지만, 회사 내부 식별 규칙과 개인정보가 드러나지 않도록 이 글에서는 접수 식별자 + UUID로만 표현합니다.
여기서 Outbox가 보장하는 것은 파일 전송 자체가 아닙니다. 전송해야 한다는 사실을 DB가 잊지 않는 것입니다. SFTP 전송 결과에 따라 상태를 바꾸고, 끝나지 않은 상태를 배치가 다시 읽습니다.
로컬 파일과 서버 IP로 재처리 소유권을 정했다
파일 원본은 SFTP 전송에 성공할 때까지 접수를 처리한 WAS의 로컬 디스크에 보관했습니다. 외부 저장소를 추가하지 않아도 되고 로컬 I/O가 빠르다는 장점이 있었습니다. 전송에 성공하면 파일을 삭제했습니다.
하지만 이중화 환경에서는 다른 WAS가 로컬 파일을 읽을 수 없습니다. 그래서 Outbox에 파일을 생성한 서버의 내부 IP를 기록했습니다. 각 서버의 10분 배치는 자신의 IP로 생성된 PENDING과 FAILED만 조회했습니다.
배치가 FAILED만 조회하면 Outbox 저장 직후 프로세스가 종료된 PENDING은 영원히 남을 수 있습니다. 그래서 두 상태를 모두 재처리 대상으로 삼았습니다.
SELECT *
FROM file_transfer_outbox
WHERE owner_server_ip = :currentServerIp
AND status IN ('PENDING', 'FAILED');
최초 전송에서 실패하면 즉시 FAILED로 바꾸고, 메모리 안에서 지수 백오프 재시도를 하지는 않았습니다. 10분 배치가 최대 3회 재시도하며, 끝까지 실패하면 GIVE_UP으로 전환하고 개발팀과 운영팀이 함께 있는 메신저방에 알렸습니다. 이 구조로 반복 실패를 최대 30분 안에 드러낼 수 있었습니다.
중복을 제거하지 않고 안전하게 허용했다
10분 배치가 막 생성된 PENDING을 조회하면 최초 비동기 작업과 배치가 같은 접수를 동시에 전송할 수 있습니다. 이 경합을 막는 락이나 조회 유예 조건은 당시 구현하지 않았습니다.
대신 파일 서버의 계약을 이용했습니다.
- 접수 1건의 파일 4~7개는 재시도할 때 항상 묶음 전체를 다시 보냅니다.
- 원격 경로와 파일명이 같으면 기존 파일을 덮어씁니다.
- 일부 파일만 성공한 뒤 실패해도 다음 시도에서 전체 묶음을 다시 맞춥니다.
따라서 이 설계는 정확히 한 번 전송을 보장하지 않습니다. 중복 전송이 최종 파일 상태를 바꾸지 않는다는 조건 아래 적어도 한 번 재시도하는 구조에 가깝습니다. 파일별 성공 상태와 락을 추가하는 대신 접수 단위 상태 하나로 운영 복잡도를 낮춘 선택이었습니다.
전송 완료는 ChannelSftp.put()의 정상 반환으로 판단했다
접수 파일 묶음의 각 파일에 대해 ChannelSftp.put()이 예외 없이 반환되면 전송 성공으로 판단했습니다. 묶음 안에서 하나라도 예외가 발생하면 접수 단위 Outbox를 FAILED로 바꾸고, 다음 배치에서 전체 파일을 다시 전송했습니다. 일부 파일만 올라간 상태를 별도로 관리하지 않아도 동일한 원격 파일명으로 덮어쓸 수 있었기 때문입니다.
이 기준은 구현과 운영이 단순하지만, 원격 파일의 크기나 체크섬까지 검증하는 종단 간 무결성 확인은 아닙니다. 네트워크 경계에서 결과가 불분명해도 다음 재시도가 전체 묶음을 다시 덮어쓰므로 누락 복구에는 유리했지만, 파일 내용의 동일성까지 증명하려면 임시 파일명 업로드 후 서버가 원자성을 보장하는 rename, 원격 크기 확인 또는 체크섬 비교가 추가로 필요합니다. 따라서 이 글의 성과는 put() 성공과 운영 관찰을 기준으로 한 결과입니다.
결과: 빨라진 응답을 유지하면서 복구 경로를 만들었다
성과를 한 번의 개선으로 합치면 원인과 결과가 흐려집니다. 비동기 전환과 Outbox 도입의 효과를 나눠서 봐야 합니다.
| 항목 | 최초 동기 구조 | 비동기 전환 후 | Outbox 적용 후 |
|---|---|---|---|
| API 응답시간 | 약 5~15초 | 약 1초 | 약 1초 유지 |
| 전송 실패 기록 | 요청 실패로 확인 | 별도 상태 없음 | PENDING·FAILED·GIVE_UP 추적 |
| 자동 재처리 | 없음 | 없음 | 10분 주기, 최대 3회 |
| 최종 실패 인지 | 요청 시 확인 | CS 이후 수동 확인 | 최대 30분 내 공동 알림 |
| 운영 결과 | - | 접수 2건의 파일 묶음 유실 | 1년 이상·접수 2만 건 이상 유실 0건 관찰 |
접수당 파일은 기본 4개, 최대 7개였습니다. 따라서 처리된 개별 파일은 최소 8만 개 이상이지만 정확한 파일 수를 별도로 집계하지 않았습니다. 성과에서는 추정치 대신 확인 가능한 가맹점 접수 2만 건 이상만 사용합니다.
저는 실제 유실 건 분석부터 인프라팀과의 계정 정책 협의, 대안 비교, JSch 세션 풀, Outbox 상태 설계, 로컬 파일 관리, 10분 배치, 알림, 테스트, 배포와 운영까지 전 과정을 직접 담당했습니다.
유실 0건이 모든 실패를 막았다는 뜻은 아니다
운영 결과만 보면 목표를 달성했습니다. 그러나 로컬 디스크 선택에는 명확한 한계가 있습니다.
파일을 가진 WAS가 전송 전에 디스크를 잃으면 다른 인스턴스는 원본을 복구할 수 없습니다. 내부 IP로 배치 소유권을 나눈 방식도 해당 서버가 돌아온다는 전제에 기대고 있습니다. Outbox에는 전송 의무가 남더라도 원본 파일이 사라지면 재처리할 수 없습니다.
당시에는 다음 조건을 고려해 이 위험을 수용했습니다.
- 별도 파일 서버가 이미 존재했습니다.
- 새로운 외부 저장소 운영을 최소화해야 했습니다.
- 로컬 파일 접근 속도와 구현 범위가 중요했습니다.
- 실제로 반복되던 실패는 공유 계정의 세션 획득 문제였습니다.
따라서 이 글의 유실 0건은 도입 후 1년 이상의 관찰 결과이지, 모든 장애 조건에서 파일 유실이 불가능하다는 보장이 아닙니다.
다시 설계한다면 서버 IP가 아니라 객체 키를 남긴다
지금 다시 설계한다면 원본 파일을 S3 같은 공유 영속 스토리지에 먼저 저장하겠습니다.
이 구조에서는 Outbox가 WAS 내부 IP 대신 객체 Key를 가집니다. 어느 인스턴스든 파일을 읽고 재처리할 수 있어 서버 장애와 배포에 대한 결합이 줄어듭니다. 작업 선점은 조건부 UPDATE나 lease로 관리해 현재 구조의 중복 실행 가능성도 줄일 수 있습니다.
물론 새로운 비용도 생깁니다. 객체 저장과 DB 저장은 하나의 로컬 트랜잭션으로 묶을 수 없으므로, 객체를 먼저 저장한 뒤 DB 저장이 실패해 남은 고아 객체를 정리하는 정책이 필요합니다. 저장 비용, 보존 기간, 암호화, 접근 권한도 운영 대상이 됩니다.
세션 풀의 반환 정책도 함께 바꾸겠습니다. Transport 계층 오류가 발생한 세션은 즉시 폐기하고, 대여 시점에는 testOnBorrow로 validateObject()를 실행해 손상된 세션의 재사용을 막겠습니다. 현재 검증은 SFTP 채널의 connect()까지 수행하므로 요청마다 검증하면 추가 연결 비용이 생깁니다. 처리량이 커진다면 모든 대여에서 무조건 검사하기보다, 명확한 연결 오류에서는 즉시 폐기하고 유휴 세션은 testWhileIdle로 주기적으로 검사하는 조합도 비교하겠습니다.
트래픽과 즉시성이 지금 수준이라면 Outbox와 DB Polling은 유지할 수 있습니다. 재처리 지연을 줄이거나 처리량을 독립적으로 확장해야 한다면 메시지 브로커를 작업 알림으로 추가하되, 원본 파일과 처리 상태의 기준은 공유 스토리지와 Outbox에 두겠습니다.
이번 개선에서 얻은 기준은 단순합니다.
비동기로 응답을 빨리 끝냈다면, 요청이 끝난 뒤 해야 할 일과 다시 시작할 근거를 반드시 남겨야 한다.
세션 풀은 실패 빈도를 줄였습니다. Outbox는 실패가 유실로 바뀌는 경로를 끊었습니다. 그리고 로컬 디스크의 한계를 확인하면서, 운영에서 관찰된 성공과 아키텍처가 이론적으로 보장하는 범위를 구분하게 됐습니다.
Related
- 하나의 결제 시스템을 두 개의 배포 단위로 나누기까지 - 결제 후 부가 작업을 이벤트와 Outbox로 분리한 다음 단계
