
처음 회사에 입사했을 때, local 환경에선 ssh 터널링을 통해서만 디비 연결이 가능했다.
dev나 다른 환경에 있는 인스턴스는 DB가 같은 네트워크 내에 있으므로 직접적인 DB 접근이 가능했지만, local 환경에서는 직접적인 DB 접근이 불가능하고 연결하려면 ssh 터널링을 이용했어야 했다.
그때 당시엔 프로젝트를 실행시킬때마다 터미널을 통해 ssh 터널링을 하고 프로젝트를 시작하는 방식을 사용하고 있었다. 현재는 local에서 상용 DB에 연결이 불가능하지만 예전에는 가능했는데, 상용 DB에 터널링한줄 모르고 프로젝트를 실행하면 본인도 모르게 상용 DB를 수정할 수 있는 위험이 있었다.
또한, 프로젝트 실행때마다 터널링을 실행해야하는 불필요한 작업이 있어서 자동화 방안을 찾게 됐고, SSH 터널링을 SpringBoot 프로젝트 내에서 자동으로 할 수 있는 방법이 있다해서 프로젝트에 도입했다.
java 에서는 대표적으로 JSch를 이용해서 ssh 포트포워딩을 한다고 한다. 입사 당시에는 래퍼런스가 많은 JSch를 활용해서 ssh 포트 포워딩 기술을 적용했었다. 하지만, 현재 해당 라이브러리는 업데이트가 안되고 있는 상황이다.

때문에 해당 라이브러리 대신 향후 유지보수를 생각했을 때, 안정적으로 라이브러리를 지원해주는 SSHJ 를 사용한 방법으로 대체했다.
기존에 구현되어 있는 방식부터 알아보자.
implementation 'com.jcraft:jsch:0.1.55'
먼저 해당 종속성을 build.gradle에 추가해준다.
public class SshConnection {
private final static String HOST = "your-host-ip";
private final static Integer PORT = your-host-port;
private final static String USER = "your-host-username";
private final static String PASSWORD = "your-host-password";
private static final String REMOTE_HOST = "your-db-url";
private static final int LOCAL_PORT = your-local-port;
private static final int REMOTE_PORT = your-remote-port;
private final Session session;
public SshConnection() {
try {
final JSch jsch = new JSch();
session = jsch.getSession(USER, HOST, PORT);
final Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.setPassword(PASSWORD);
session.connect();
session.setPortForwardingL(LOCAL_PORT, REMOTE_HOST, REMOTE_PORT);
} catch (JSchException e) {
throw new RuntimeException(e);
}
}
public void closeSsh() {
session.disconnect();
}
}
해당 코드가 ssh 연결한 후 local port forwarding을 하는 로직이다. username, password를 통해 ssh를 연결하고, 데이터베이스로 포트포워딩을 해준다.
해당 코드는 정적분석 결과 password 정보가 내부에 코드로 존재하면 취약점이 된다고 해서 그 부분도 고쳐줄 것이다.
@WebListener
public class SshConnectListener implements ServletContextListener {
private SshConnection sshConnection;
@Override
public void contextInitialized(final ServletContextEvent sce) {
try {
sshConnection = new SshConnection();
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void contextDestroyed(final ServletContextEvent sce) {
sshConnection.closeSsh();
}
}
이렇게 세팅하면 서블릿 컨텍스트를 등록할 때, ssh 연결 및 로컬 포트포워딩을 하게되고 데이터베이스에 붙게 되는데 기존 레거시 코드를 sshj를 이용한 방법으로 리팩토링 할 것이다.
implementation 'com.hierynomus:sshj:0.39.0'
build.gradle에 해당 종속성을 추가해준다.
@Slf4j
@Profile("local")
@Configuration
public class SSHJConnection {
private static final String HOST = "your-host-ip";
private static final Integer PORT = your-host-port;
private static final String USER = "your-host-username";
private static final String REMOTE_HOST = "your-db-url";
private static final int REMOTE_PORT = your-remote-port;
private static final String LOCAL_HOST = "127.0.0.1";
private static final int LOCAL_PORT = your-local-port;
private static final SSHClient sshClient = new SSHClient();
@Value("${ssh.password}")
private String password;
@PostConstruct
public void init() throws IOException {
start();
}
@PreDestroy
public void shutdown() {
close();
}
public void start() throws IOException {
sshClient.addHostKeyVerifier(new PromiscuousVerifier());
sshClient.connect(HOST, PORT);
sshClient.authPassword(USER, password);
log.info("SSH is connected");
final Parameters parameters = new Parameters(LOCAL_HOST, LOCAL_PORT, REMOTE_HOST, REMOTE_PORT);
Thread thread = new Thread(() -> {
try (ServerSocket serverSocket = new ServerSocket()) {
serverSocket.setReuseAddress(true);
serverSocket.bind(new InetSocketAddress(parameters.getLocalHost(), LOCAL_PORT));
sshClient.newLocalPortForwarder(parameters, serverSocket).listen();
log.info("SSH PortForwarding is started");
} catch (Exception e) {
log.error("SSH Connection Exception", e);
} finally {
close();
}
});
thread.setDaemon(true);
thread.start();
}
public boolean isConnected() {
return sshClient.isConnected();
}
public void close() {
try {
if (sshClient.isConnected()) {
sshClient.disconnect();
sshClient.close();
log.error("SSH is closed");
}
} catch (IOException e) {
log.error("SSH Close Exception", e);
}
}
}
공식 Github 예제를 참조해 개발했다.
먼저 bean이 등록되고 프로그램이 종료될때마다 start, close을 수행시켜서 ssh 연결을 안전하게 열고 닫았다.
start는 ssh의 local portforwarding 기능을 구현한 것이다.
구현을 하나하나 알아보자.

sshClient.addHostKeyVerifier 는 서버의 호스트키를 검증하는 부분이라는데, 우리는 hostkey 인증이 아닌 username / password 방식이므로 항상 성공하게 해야한다.
PromiscuousVerifier는 내부 코드를 들여다보면 verify 부분이 항상 true를 반환하므로 사용했다.
나머지 파라미터를 만들고 서버 소켓을 만드는 부분은 공식 Github를 참고해 개발했고,
serverSocket.setReuseAddress(true) 이 부분은 서버가 종료된 후에 바로 다시 시작해도 포트에 바인딩될 수 있도록 하는 옵션이라 지정해줬다.

또한, Thread를 생성해서 사용한 것을 볼 수 있는데, 이는 sshClient.newLocalPortForwarder(parameters, serverSocket) 의 listen() 메소드를 보면 while 문으로 현재 Thread가 interrupt 될때까지 점유하므로, 메인에서 실행하게 되면 프로그램이 멈춰버린다.
이를 해결하기 위해 별도의 Thread에서 포트 포워딩을 해준다.
@RequiredArgsConstructor
public class DataSourceConfigure {
private final SSHJConnection sshjConnection;
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public DataSource dataSource(SecretsManagerClient secretsManagerClient) throws IOException {
if (!sshjConnection.isConnected()) {
sshjConnection.start();
}
...
//별도 DB 연결 설정
}
}
그리고 한가지 더 해주어야 할 일이 있는데, DB 연결전에 SSH 포트 포워딩이 제대로 실행 됐는지 확인해야한다.
bean으로 등록한 sshjConnection을 DI로 주입받아서 제대로 연결 됐는지 확인한 후에 데이터베이스 연결을 진행해준다.

기존에 개발할 당시 터미널을 이용해 SSH 포트 포워딩을 하는 것이 불편해서 프로젝트 내부에서 연결하는 방법을 서칭했고 적용하니 개발 효율이 늘었고, 실수할 수 있는 확률이 줄어들었다.
사소한 부분이지만 개발로 인해 팀원들의 편의성을 향상시켜서 뿌듯했었다.
현재는 기존 레거시인 JSch를 SSHJ 라이브러리로 리팩토링 했고 데이터베이스에 정상적으로 연결되는 것을 확인했다.
또한, 정적분석에서 나왔던 취약점 중 하나인 코드 내부에 password를 넣는 부분을 property에서 가져오도록 수정해서 개선했다.
리팩토링을 통해 라이브러리와 프로그램의 안전성 및 유지보수성을 높이는 경험이었고, 신뢰할만한 프로그램을 개발해나가고 싶다.