Pink Spider/boot3 에서 postgresql에 직접 접속이 아닌 ssh tunnel 로 접속하는 방법

Created Thu, 03 Jul 2025 08:44:24 +0900 Modified Mon, 08 Dec 2025 08:41:47 +0900
1346 Words 6 min

Spring Boot 3에서 SSH 터널을 통한 PostgreSQL 연결을 위한 설정 방법을 알려드리겠습니다.

1. application.yml 설정

spring:
  datasource:
    url: jdbc:postgresql://localhost:5433/your_database_name
    username: your_db_username
    password: your_db_password
    driver-class-name: org.postgresql.Driver
  
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true

# SSH 터널 설정 (커스텀 프로퍼티)
app:
    ssh:
      tunnel:
        host: your-ssh-server.com
        port: 22
        username: ssh_username
        private-key-path: ~/.ssh/id_rsa
        local-port: 5433
        remote-host: localhost  # SSH 서버에서 본 DB 호스트
        remote-port: 5432       # 실제 PostgreSQL 포트

2. SSH 터널 자동 연결을 위한 Configuration 클래스## 3. 필요한 의존성 추가 (build.gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.postgresql:postgresql'
    implementation 'com.github.mwiede:jsch:0.2.17'
    
    // 기타 필요한 의존성들...
}

3. ssh key 만들기 예시

ssh-keygen -t rsa -b 4096 -m PEM -f ~/.ssh/id_rsa -C "ceo@pink-spider.io"
  • DB가 설치된 서버에 ~/.ssh 등에 authorized_keys (권한 644) 파일에 id_rsa.pub 내용 추가해야 됩니다.
  • rsa가 아닌 OpenSSh로 keygen 했을 경우 com.github.mwiede:jsch 가 아닌 다른 라이브러리를 써야 됩니다.

SshTunnelConfig


@Configuration
@Slf4j
@Profile({"local", "dev", "stage", "prod"})
public class SshTunnelConfig {

    @Value("${app.ssh.tunnel.host}")
    private String sshHost;

    @Value("${app.ssh.tunnel.port}")
    private int sshPort;

    @Value("${app.ssh.tunnel.username}")
    private String sshUsername;

    @Value("${app.ssh.tunnel.private-key-path}")
    private String privateKeyPath;

    @Value("${app.ssh.tunnel.local-port}")
    private int localPort;

    @Value("${app.ssh.tunnel.remote-host}")
    private String remoteHost;

    @Value("${app.ssh.tunnel.remote-port}")
    private int remotePort;

    @Value("${app.ssh.tunnel.passphrase:}")
    private String passphrase;

    private Session session;

    @PostConstruct
    public void createSshTunnel() {
        try {
            log.info("JSch SSH 터널 연결 시작...");

            // 로컬 포트가 사용 가능한지 확인
            if (!isPortAvailable(localPort)) {
                throw new RuntimeException("로컬 포트 " + localPort + "가 이미 사용 중입니다.");
            }

            JSch jsch = new JSch();

            // 개인키 파일 경로 처리
            String keyPath = privateKeyPath;
            if (keyPath.startsWith("~/")) {
                keyPath = System.getProperty("user.home") + keyPath.substring(1);
            }

            // 파일 존재 및 읽기 권한 확인
            File keyFile = new File(keyPath);
            if (!keyFile.exists()) {
                throw new RuntimeException("SSH 개인키 파일을 찾을 수 없습니다: " + keyPath);
            }

            if (!keyFile.canRead()) {
                throw new RuntimeException("SSH 개인키 파일을 읽을 수 없습니다. 파일 권한을 확인하세요: " + keyPath);
            }

            log.info("SSH 개인키 파일: {}", keyPath);

            // 키 형식 확인 (RSA 형식인지 체크)
            try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(keyFile))) {
                String firstLine = reader.readLine();
                log.info("키 파일 형식: {}", firstLine);

                if (firstLine != null && !firstLine.contains("BEGIN RSA PRIVATE KEY")) {
                    log.warn("RSA 형식이 아닌 키가 감지되었습니다: {}", firstLine);
                    log.warn("RSA 형식 키를 생성하려면: ssh-keygen -t rsa -b 4096 -m PEM -f {}", keyPath);
                }
            }

            // 개인키 추가
            if (passphrase != null && !passphrase.trim().isEmpty()) {
                jsch.addIdentity(keyPath, passphrase);
                log.info("패스프레이즈와 함께 개인키 로드 완료");
            } else {
                jsch.addIdentity(keyPath);
                log.info("개인키 로드 완료");
            }

            // SSH 세션 생성
            session = jsch.getSession(sshUsername, sshHost, sshPort);
            session.setConfig("StrictHostKeyChecking", "no");
            session.setConfig("PreferredAuthentications", "publickey");
            session.setTimeout(30000);

            log.info("SSH 서버 연결 시도: {}@{}:{}", sshUsername, sshHost, sshPort);
            session.connect();
            log.info("SSH 연결 성공");

            // 포트 포워딩 설정
            session.setPortForwardingL(localPort, remoteHost, remotePort);

            // 포트가 열릴 때까지 대기
            waitForPortToOpen(localPort, 10000);

            log.info("SSH 터널이 성공적으로 생성되었습니다: localhost:{} -> {}:{}:{}",
                localPort, sshHost, remoteHost, remotePort);

        } catch (JSchException e) {
            log.error("SSH 터널 생성 실패: {}", e.getMessage(), e);

            // 구체적인 에러 메시지 제공
            if (e.getMessage().contains("invalid privatekey")) {
                log.error("개인키 형식이 잘못되었습니다. RSA 형식 키를 사용하세요.");
                log.error("새 RSA 키 생성: ssh-keygen -t rsa -b 4096 -m PEM -f ~/.ssh/level_up_rsa");
            } else if (e.getMessage().contains("Auth fail")) {
                log.error("인증에 실패했습니다. 공개키가 서버에 등록되어 있는지 확인하세요.");
                log.error("공개키 등록: ssh-copy-id -i {} {}@{}", privateKeyPath + ".pub", sshUsername, sshHost);
            } else if (e.getMessage().contains("Connection refused")) {
                log.error("서버 연결이 거부되었습니다. 호스트와 포트를 확인하세요.");
            }

            cleanupResources();
            throw new RuntimeException("SSH 터널 연결에 실패했습니다: " + e.getMessage(), e);
        } catch (Exception e) {
            log.error("예상치 못한 오류: {}", e.getMessage(), e);
            cleanupResources();
            throw new RuntimeException("SSH 터널 설정 중 오류가 발생했습니다: " + e.getMessage(), e);
        }
    }

    @PreDestroy
    @EventListener(ContextClosedEvent.class)
    public void closeSshTunnel() {
        log.info("SSH 터널 종료 시작...");
        cleanupResources();
        log.info("SSH 터널 종료 완료");
    }

    private void cleanupResources() {
        if (session != null && session.isConnected()) {
            try {
                session.disconnect();
                log.info("SSH 세션이 종료되었습니다.");
            } catch (Exception e) {
                log.warn("SSH 세션 종료 중 오류: {}", e.getMessage());
            }
        }
    }

    private boolean isPortAvailable(int port) {
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    private void waitForPortToOpen(int port, long timeoutMillis) throws InterruptedException {
        long startTime = System.currentTimeMillis();
        while (System.currentTimeMillis() - startTime < timeoutMillis) {
            try (java.net.Socket socket = new java.net.Socket()) {
                socket.connect(new java.net.InetSocketAddress("localhost", port), 1000);
                log.info("포트 {}가 성공적으로 열렸습니다.", port);
                return;
            } catch (Exception e) {
                // 포트가 아직 열리지 않음, 재시도
                Thread.sleep(500);
            }
        }
        log.warn("포트 {}가 {}ms 내에 열리지 않았습니다.", port, timeoutMillis);
    }
}