1. 들어가며
기존에 dev 환경의 RDS와 연결되어 있던 대여기 서버를, 릴리즈를 위해 prod 환경의 RDS로 연결해야 하는 요구사항이 추가되었다.
하지만 평소와 같이 연결을 시도했으나 실패했고, 원인을 조사한 결과 prod 환경의 RDS는 직접적인 연결이 차단되어 있다는 사실을 알게 되었다.
💡 왜 prod 환경의 RDS는 직접 연결이 막혀 있을까?
- Prod 환경은 실제 서비스가 운영되는 환경으로, 데이터 손실이나 보안 사고가 발생할 경우 사용자 및 서비스에 심각한 영향을 미치기 때문이다.
- prod 환경의 데이터베이스에 접근해 실수할 수 있기 때문이다. (DDL 설정 변경, 잘못된 쿼리문)
이를 해결하기 위해, SSH Tunneling을 활용하여 RDS에 간접적으로 접근해보자.
2. 대여기 아키텍처
기존 아키텍처
WAS 서버(EC2) ↔ 대여기 서버(라즈베리파이) ↔ 대여기(아두이노)

- WAS 서버
- 기존 앱,웹 서버
- Prod 환경의 RDS와 연결
- 대여기 서버
- WAS 서버로부터 요청을 받아 개별 대여기의 상태를 관리한다.
- 대여기 서버와 대여기 간의 실시간 통신(WebSocket)을 담당한다.
- 테스트를 위해 Dev 환경의 RDS와 연결
- 대여기
- 실제 대여 장비로, 대여기의 각 슬롯의 잠금/해제 기능을 수행한다.
- 대여기 서버와 WebSocket 통신을 통해 요청을 주고받는다.
변경할 아키텍처

- 릴리즈를 위해 대여기 서버도 Prod 환경의 RDS와 연결해야 한다.
- 대여기 서버에서 EC2를 경유하여 Prod 환경의 RDS에 접근한다.
3. SSH Tunneling
SSH 프로토콜을 활용해 암호화된 연결을 설정하고, 이를 통해 외부 네트워크나 서버에 간접적으로 접근하는 기술이다.
📌 터널링 구성 요소
- SSH Host (중간 서버) → SSH 연결을 설정하는 대상 서버 (EC2)
- Local Port → 터널링을 통해 접속할 때 사용하는 포트
- Remote Host → 최종적으로 접근하려는 서버 (RDS)
- Remote Port → 원격 서버의 서비스 포트 (RDS의 3306)
4. 코드 구현
SSH 연결 설정
JSch jsch = new JSch();
session = jsch.getSession(sshUser, sshHost, sshPort);
session.setPassword(sshPassword);
session.connect();
- JSch 라이브러리를 사용하여 SSH 세션을 생성한다.
- EC2와의 연결을 설정하여 RDS 접근 준비를 한다.
SSH 터널링을 통한 RDS 접근
int forwardPort = session.setPortForwardingL(0, rdsHost, rdsPort);
log.info("Port forwarding created: local {} -> EC2 -> RDS {}:{}", forwardPort, rdsHost, rdsPort);
- setPortForwardingL()을 사용하여 로컬 머신에서 EC2를 경유해 RDS로의 포트 포워딩을 설정한다.
- 0을 전달하면 시스템이 사용 가능한 포트를 자동으로 할당한다.
- 할당된 로컬 포트를 리턴하여 이후 데이터베이스 연결에서 사용할 수 있도록 한다.
데이터베이스 연결 설정
Integer forwardPort = sshConnection.setupTunnel(sshHost, sshPort, sshUser, sshPassword, rdsHost, rdsPort);
String tunneledDbUrl = dbUrl.replaceFirst("//[^:]+:\\d+", "//localhost:" + forwardPort);
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setUrl(tunneledDbUrl);
dataSource.setUsername(dbUsername);
dataSource.setPassword(dbPassword);
- SSHConnection의 setupTunnel()메서드를 호출하여 EC2를 통해 RDS로 연결되는 SSH 터널링을 설정한다.
- SSH 터널링으로 할당된 로컬 포트를 데이터베이스 URL에 반영한다.
원래 RDS 주소 대신 localhost와 할당된 포트를 사용하도록 URL을 수정한다. - 수정된 URL과 데이터베이스 사용자 정보를 활용하여 DriverManagerDataSource를 생성한다.
💡 왜 localhost를 사용하는가?
- SSH 터널링은 로컬 포트를 대리 서버처럼 사용
- 로컬 머신에서 localhost:포트로 요청을 보내면, SSH 터널링을 통해 원격 서버(EC2)가 요청을 RDS로 전달한다.
- 즉, 로컬 포트와 RDS 사이의 연결을 중계하는 역할을 SSH가 담당한다.
5. DataSource 설정
다른 블로그에서 웹 이벤트 리스너를 사용해서 스프링부트가 시작되자 마자 ssh 연결을 시도하는 글을 보았는데 JPA를 사용하지 않을 때 가능한 방법이다. JPA를 사용하면 스프링부트 어플리케이션이 시작되기 전에 db연결을 먼저 확인하는데 이 과정에서 오류가 발생한다.
스프링 부트와 JPA 초기화 과정
📌 Spring Boot 초기화 단계
- 스프링 부트는 애플리케이션이 시작될 때, 설정된 빈(@Bean)들을 초기화하고 필요한 의존성을 주입한다.
- DataSource는 애플리케이션 컨텍스트가 초기화될 때 생성되며, 이 DataSource를 기반으로 JPA의 EntityManagerFactory를 설정한다.
📌 JPA 초기화
- JPA는 애플리케이션 시작 시 데이터베이스와 연결을 확인하고 메타데이터를 로드한다.
- 이 과정에서 DataSource가 올바르게 연결되어야 하며, 데이터베이스에 접근 가능해야 한다.
문제점
- JPA를 사용하면 스프링 부트가 시작되면서 데이터베이스와 연결을 시도한다.
- SSH 터널링을 통해 데이터베이스와 연결하려고 한다면, 터널링 설정이 DataSource 생성 전에 완료되어야 한다.
- 애플리케이션 초기화 단계에서 DataSource가 이미 생성되므로, SSH 터널링이 나중에 설정되면 데이터베이스 연결이 실패하게 된다.
JPA 초기화 전에 SSH 연결을 완료해야 한다.
6. JPA와 SSH 터널링
1️⃣ 애플리케이션 시작 전에 SSH 연결
public static void main(String[] args) throws Exception {
SSHConnection sshConnection = new SSHConnection();
sshConnection.setupTunnel(sshHost, sshPort, sshUser, sshPassword, rdsHost, rdsPort);
SpringApplication.run(Application.class, args);
}
2️⃣ 초기화 단계에서 SSH 연결 완료 후 DataSource 생성
@Bean
public DataSource dataSource(SSHConnection sshConnection) throws Exception {
sshConnection.setupTunnel(sshHost, sshPort, sshUser, sshPassword, rdsHost, rdsPort);
return new DriverManagerDataSource(...);
}
3️⃣ JPA 초기화 지연 설정
spring.jpa.defer-datasource-initialization=true
7. 결론
Prod 환경에서는 보안상의 이유로 직접적인 RDS 접근이 차단되어 있으며, 이는 데이터 유출 및 운영 실수 방지를 위한 필수적인 조치다.
그러나, 서비스의 원활한 운영을 위해서는 대여기 서버에서 Prod 환경의 RDS에 안전하게 접근할 수 있는 방법이 필요했다.
결과적으로, SSH 터널링을 통해
- 보안 문제 해결 → Prod 환경의 RDS 접근 정책을 준수하면서도 연결 가능
- 데이터베이스 접근 가능 → EC2를 중간 서버로 활용하여 RDS 접속 허용
- JPA 초기화 충돌 방지 → SSH 터널링을 애플리케이션 실행 전에 설정하여 문제 해결
라는 세 가지 목표를 달성할 수 있었다.
'삽질로그' 카테고리의 다른 글
| Spring Boot 비동기 처리 = 스레드풀? (0) | 2025.03.16 |
|---|---|
| 여러 요청이 동시에 들어오면, Spring Boot는 어떻게 처리할까? (2) | 2025.03.09 |
| 복잡한 웹소켓 핸들러, Event로 깔끔하게 해결하기 (0) | 2025.03.03 |
| Batch Insert로 API 응답 속도 175배 개선하기 (0) | 2025.03.03 |
| 내 API 응답시간은 왜 이렇게 오래 걸릴까? (0) | 2025.03.03 |
