
제목이 '배치'와 관련된 문제를 겪은 것처럼 보이지만 SFTP를 사용하면서 발생한 ArrayIndexOutOfBoundsException에러에 대한 내용입니다.
SFTP는 SSH File Transfer Protocol로 SSL을 사용한 파일 전송 프로토콜입니다.
타 서버에 SFTP접속을 시도한 뒤 파일이 존재하면 이를 다운로드 받는 로직을 5분 주기 배치로 실행해야 하는 작업이 있었는데요.
저는 JSch라이브러리를 사용했고, 로컬 Docker에 SFTP서버를 띄워 통신하는 방식으로 기본적인 로직 개발 및 검증을 완료했습니다.
이후 실제 SFTP서버와 통신을 시도하니 Algorithm Negotiation Fail에러가 발생했는데요. 이는 보통 클라이언트와 서버 사이의 암호화 알고리즘이 일치하지 않아서 발생하는 에러입니다.
제가 사용하는 JSch라이브러리 버전에서 ssh-rsa 알고리즘이 기본으로 설정되어 있지 않아 이를 추가해줬어야 했는데요. 이를 다음과 같은 방법으로 추가하라는 내용의 글이 꽤 많이 보입니다.
JSch.setConfig("server_host_key", "ssh-rsa," + JSch.getConfig("server_host_key"));
JSch.setConfig("PubkeyAcceptedAlgorithms", "ssh-rsa," + JSch.getConfig("PubkeyAcceptedAlgorithms"));
비유적인 표현이 아니라 말 그대로 꽤 많이 보이는데요.
저 역시 이를 이용해 아래와 같이 구현했습니다. (실제 사용한 코드는 아니고 간략히 표현했습니다)
public SftpClient() implements AutoCloseable {
private final JSch jsch;
private ChannelSftp channel;
private Session session;
public SftpClient() {
this.jsch = new JSch();
JSch.setConfig("server_host_key", "ssh-rsa," + JSch.getConfig("server_host_key"));
JSch.setConfig("PubkeyAcceptedAlgorithms", "ssh-rsa," + JSch.getConfig("PubkeyAcceptedAlgorithms"));
}
public void connect(String host, int port, String username, String password) {
session = jsch.getSession(username,host,port);
session.setPassword(password);
session.connect();
channel = (ChannelSftp) session.openChannel("sftp");
channel.connect();
}
@Override
public void close() {
if(channel != null) {
channel.disconnect();
}
if(session != null) {
session.disconnect();
}
}
}
이를 통해 Algorithm Negotiation Fail을 해결하고 나머지 작업도 잘 마무리한 뒤 운영서버에 반영해 배치가 잘 동작하는것까지 확인했는데요. 매일 300번 가까이 정상 동작하던 로직이 배포 후 5일이 지나자 ArrayIndexOutOfBoundsException가 발생하며 실패하기 시작했습니다.
분명 테스트 과정에서 별 문제가 없었고, 배포 이후에도 문제가 없이 잘 동작하던 배치가 어느 순간을 기점으로 갑자기 실패하기 시작한 이유를 단숨에 찾아내기가 어려웠습니다.
더군다나 로직 어디에도 배열을 사용하지 않았는데 ArrayIndexOutOfBoundsException이 발생해 더욱 당황했는데요. 로그를 살펴보니 jsch라이브러리 내부에서 ArrayIndexOutOfBoundsException에러가 발생하고 있었습니다.

문제의 원인은 Algorithm Negotiation Fail을 해결하기 위해 추가한 코드에 있었습니다.
public SftpClient() {
this.jsch = new JSch();
JSch.setConfig("server_host_key", "ssh-rsa," + JSch.getConfig("server_host_key"));
JSch.setConfig("PubkeyAcceptedAlgorithms", "ssh-rsa," + JSch.getConfig("PubkeyAcceptedAlgorithms"));
}
원래 setConfig는 새로운 key-value값을 추가하기 위한 메서드입니다. 하지만 이미 server_host_key라는 key와 거기에 대응하는 value값이 존재하고 있었습니다. 기존의 value에 "ssh-rsa"만 추가하려다보니 결과적으로 set기능을 append기능처럼 사용하게 됐는데요. 이 자체로 메서드의 의도와 다르게 코드를 사용하는 위험한 상황이였다고 생각됩니다.
하지만 더 큰 문제는 setConfig가 정적 메서드라는 점입니다. setConfig가 정적 메서드가 아닌 인스턴스 메서드면서 다음과 같이 코드를 짰어야 원래의 의도대로 동작했을 겁니다.
public SftpClient() {
this.jsch = new JSch();
jsch // JSch가 아닌 jsch
.setConfig("server_host_key", "ssh-rsa," + JSch.getConfig("server_host_key"));
jsch // JSch가 아닌 jsch
.setConfig("PubkeyAcceptedAlgorithms", "ssh-rsa," + JSch.getConfig("PubkeyAcceptedAlgorithms"));
}
jsch.setConfig()와 JSch.setConfig는 동일합니다. 인텔리제이는 이와 같은 실수를 친절히 알려줍니다.
저는 setConfig메서드를 서버가 실행되는 시점에 한번만 호출하도록 코드를 변경해 문제를 해결했습니다.
SftpClient생성자가 호출될 때마다 JSch의 config속 "server_host_key"와 "PublicAcceptedAlgorithms"값에는 "ssh-rsa,"라는 문자열이 append형태로 계속해서 쌓이게 됩니다.
지금까지 제가 어떤 실수를 했는지 살펴봤는데요. 이제는 해당 실수가 어떻게 ArrayIndexOutOfBoundsException으로 이어지는지 살펴볼 차례입니다. 이는 코드를 따라가보면 쉽게 확인할 수 있습니다.
public void connect(String host, int port, String username, String password) {
session = jsch.getSession(username,host,port);
session.setPassword(password);
session.connect();
// ...
}
이 connect메서드는 send_kexinit이라는 메서드를 호출합니다.

Session의 send_kexinit메서드는 server_host_key값을 바이트로 변환해 buf.putString합니다.

Buffer의 putString은 putByte를 호출합니다. 이때 putByte메서드의 매개변수 length는 입력으로 들어온 바이트배열(foo)의 길이입니다.

Buffer의 putByte는 System.arraycopy를 호출합니다. 이는 foo배열을 buffer배열에 복사합니다.

이때 System.arraycopy의 3번째 인자로 넘겨지는 buffer는 Buffer객체의 인스턴스 변수입니다. size나 buffer를 전달하지 않았을 경우 20480크기로 생성됩니다.

앞서 System.arraycopy가 foo배열을 buffer배열에 복사하는 동작을 한다고 했습니다. foo배열은 server_host_key를 byte배열로 변환한 값으로 앞서 살펴봤듯이 잘못된 append로 SftpClient를 생성할 때마다 길이가 계속 늘어납니다. 이때 foo배열의 길이가 buffer배열의 기본 크기인 20480보다 커지면 복사하려는 대상(buffer배열)보다 복사 대상(foo배열)의 길이가 더 커 ArrayIndexOutOfBoundsException이 발생하게 됩니다.
