코드는 java-src/extension/17_sftp 에 있고 FileTransfer(인터페이스)·LocalTransfer(로컬 폴더 구현체)·Main(데모) 세 파일로 구성됩니다. 외부 jar 는 필요 없습니다.
cd java-src\extension\17_sftp
javac -encoding UTF-8 *.java && java MainFileTransfer — 전송 인터페이스와 실패 분류public interface FileTransfer extends AutoCloseable {
void connect() throws TransferException;
List<String> list(String remoteDir) throws TransferException;
void upload(String localPath, String remoteName) throws TransferException;
void download(String remoteName, String localPath) throws TransferException;
void rename(String from, String to) throws TransferException;
void delete(String remoteName) throws TransferException;
long size(String remoteName) throws TransferException;
enum Kind { CONNECT, AUTH, PERMISSION, IO }
class TransferException extends Exception {
public final Kind kind;
public TransferException(Kind kind, String message) {
super(message);
this.kind = kind;
}
}
}실무 구현체(SFTP·FTP)는 이 인터페이스만 구현하면 배치 로직은 그대로 재사용됩니다. 프로토콜을 바꿔도 Main 의 흐름 코드는 손댈 필요가 없습니다.
LocalTransfer — 로컬 폴더를 원격 서버처럼// 로컬 폴더를 "원격 루트"로 삼아 upload 를 스트리밍 복사로 구현
@Override
public void upload(String localPath, String remoteName) throws TransferException {
Path src = Paths.get(localPath);
Path dst = root.resolve(remoteName);
try (InputStream in = Files.newInputStream(src);
OutputStream out = Files.newOutputStream(dst)) {
byte[] buf = new byte[4096];
long sent = 0;
int n;
while ((n = in.read(buf)) != -1) {
// failAfterBytes 설정 시 중간 끊김을 인위로 재현
if (failAfterBytes >= 0 && sent + n > failAfterBytes) {
out.write(buf, 0, (int) Math.max(0, failAfterBytes - sent));
throw new TransferException(Kind.IO, "전송 중단: " + remoteName);
}
out.write(buf, 0, n);
sent += n;
}
} catch (IOException e) {
throw new TransferException(Kind.IO, "업로드 실패: " + remoteName, e);
}
}failAfterBytes 는 데모 전용 훅입니다. 실제 SFTP 구현체에는 없고, 여기서는 중간 끊김 상황을 재현소 검증하기 위해 넣었습니다.
.tmp → 크기 확인 → rename → .okprivate static void safeUpload(FileTransfer client, Path local, String remoteName)
throws FileTransfer.TransferException, IOException {
String tmpName = remoteName + ".tmp";
client.upload(local.toString(), tmpName);
long localSize = Files.size(local);
long remoteSize = client.size(tmpName);
if (remoteSize != localSize) {
throw new FileTransfer.TransferException(FileTransfer.Kind.IO,
"크기 불일치: local=" + localSize + " remote=" + remoteSize);
}
client.rename(tmpName, remoteName);
Path markerLocal = Files.createTempFile("marker-", ".ok");
client.upload(markerLocal.toString(), remoteName + ".ok");
Files.deleteIfExists(markerLocal);
}이 메서드 하나가 이 레슨의 핵심입니다. SFTP·FTP·로컬 폴더 어떤 구현체를 넣어도 같은 절차로 원자적 전송이 됩니다.
=== 1. 안전 업로드 (.tmp -> rename, .ok 마커) ===
업로드 완료: incoming/20260914_KFTC_001.dat (+.ok)
=== 2. 중간 끊김 재현 후 재시도 ===
전송 실패(IO): 전송 중단: 20260914_KFTC_002.dat.tmp
.tmp 잔존=true, 정식 파일 없음=true
재시도 성공: 63 / 63 bytes
=== 3. 수신 측 폴링: .ok 있는 파일만 처리, 중복 수신 건너뜀 ===
처리 완료 -> processed/20260914_KFTC_001.dat
처리 완료 -> processed/20260914_KFTC_002.dat
-- 같은 파일 재전송(중복) 시도 --
건너뜀(이미 처리 이력 있음): 20260914_KFTC_001.dat
=== 4. SHA-256 체크섬 생성·검증 ===
20260914_KFTC_001.dat sha256=53944aaefab8c340... 검증=true
=== 5. 감사 로그 ===
2026-09-14T10:58:39.539444900 UPLOAD user=batch01 file=20260914_KFTC_001.dat bytes=41 result=OK2단계에서 .tmp 만 남고 정식 파일은 생기지 않는 것이 원자적 전송의 핵심입니다. 재시도는 실패한 파일을 처음부터 다시 올려 성공했습니다. 3단계 재전송은 처리 이력에 남아 있어 건너뛰었습니다.