12.7 HTTPS 설정 및 SSL 인증서 검증 처리
실무 서버 환경에서 REST API와 데이터를 주고받을 때 HTTP 대신 HTTPS를 연동하는 것은 가장 기본적인 보안 규약입니다. Spring Boot에서 내장 톰캣(Tomcat)에 SSL 인증서를 씌우는 방법과, 반대로 외부의 (자체 서명된) HTTPS API를 호출할 때 인증서 검증을 처리하는 로직을 나눕니다.
1. Spring Boot 서버에 HTTPS (SSL 인증서) 적용하기
가장 대표적으로 .p12 (PKCS12) 형식의 KeyStore 파일을 발급받아 프로젝트의 src/main/resources 하위에 위치시킵니다 (보안상 실제로는 시스템 외부 경로를 참조하는 것이 좋습니다).
application.yml에 다음과 같이 선언하면, 기존 http://localhost:8080 포트는 닫히고 https://localhost:8443으로만 구동됩니다.
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12 # KeyStore 파일 경로
key-store-password: "보안비밀번호" # Jasypt 등으로 암호화 권장
key-store-type: PKCS12
key-alias: tomcat
💡 아키텍처 실무 팁 💡 실제 대규모 엔터프라이즈 환경에서는 톰캣(Tomcat) 서버 자체에 이처럼 SSL 인증서를 직접 세팅하지 않습니다. 앞단에 있는 Nginx, AWS ALB(Load Balancer) 등 리버스 프록시(Reverse Proxy) 레이어에 SSL 처리를 위임하는 "SSL Offloading (SSL Termination)" 아키텍처를 결합합니다. 백엔드 스프링 서버는 프록시와 평문 HTTP로 빠르게 통신하여 CPU 암복호화 병목을 방지하는 것이 정석입니다.
2. 외부 HTTPS API 호출 시 SSL 인증서 검증 (RestTemplate / WebClient)
만약 우리가 스프링 서버에서 WebClient 혹은 RestClient를 이용해 외부의 다른 서버(예: 파트너사 API)를 호출하려는데, 상대방 서버가 자체 서명된(Self-Signed) 인증서 또는 사내 인트라넷 전용 인증서를 사용 중이라면 PKIX path building failed 같은 SSL 핸드셰이크 예외가 발생합니다.
해결책 A: TrustStore에 인증서 추가 (운영 권장)
상대방 서버의 공개키(public.cer) 파일을 내려받아 실행 중인 Java(JDK) 환경의 cacerts 파일에 추가하거나, 별도의 TrustStore 파일을 구성하여 스프링 구동 시 파라미터로 넘겨주는 방식입니다.
# JVM 실행 시 인자 주입
java -Djavax.net.ssl.trustStore=/path/to/truststore.jks -Djavax.net.ssl.trustStorePassword=changeit -jar app.jar
해결책 B: SSL 검증을 강제로 무시하는 WebClient 튜닝 (개발용)
로컬 개발 망 등에서 매번 인증서를 등록하기 너무 번거로울 때 SSL 검증 자체를 스킵(Pass)하는 비보안 커넥션을 무력화하여 빈(Bean)으로 등록합니다. 이 방식은 중간자 공격(MITM)에 취약하므로 절대 실제 프로덕션(운영) 서버에 배포하면 안 됩니다.
@Configuration
public class WebClientConfig {
@Bean
public WebClient unsecureWebClient() throws SSLException {
// 1. SSL 인증서를 묻지도 따지지도 않고 통과시키는 Insecure TrustManager 생성
SslContext sslContext = SslContextBuilder.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE) // ★ 핵심 무력화 코드
.build();
// 2. HTTP 클라이언트에 주입
HttpClient httpClient = HttpClient.create()
.secure(t -> t.sslContext(sslContext));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
RestTemplate이나 RestClient 역시 SSLContext와 HostnameVerifier를 커스터마이징한 TrustStrategy를 맵핑하여 SSL 에러를 간단히 우회할 수 있습니다.
3. 고도화된 검증: EV 인증서, Serial Number 및 주체(Subject) 검증
금융권이나 초고도 보안 파트너사와 연동할 때는 단순히 "인증서가 시스템 TrustStore에 등록되어 있는가?(유효한가)"를 넘어, 상대방 펌웨어에서 내려준 X.509 인증서의 시리얼 번호(Serial Number) 또는 **조직 정보(O=Organization)**까지 엄격하게 검증하여 탈취된 인증서나 유사 도메인 인증서 공격을 원천 차단해야 합니다.
이러한 로직은 HostnameVerifier나 커스텀 TrustManager를 구현하여 달성할 수 있습니다.
import javax.net.ssl.*;
import java.security.cert.X509Certificate;
@Configuration
public class EvStrictSslConfig {
@Bean
public WebClient evStrictWebClient() throws Exception {
// 1. 커스텀 TrustManager 구현 (X.509 속성 검사)
TrustManager[] customTrustManager = new TrustManager[]{
new X509ExtendedTrustManager() {
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException {
X509Certificate serverCert = chain[0]; // 가장 앞단에 있는 서버 인증서
// [중요 로직] EV 인증서 검증 로직 (1) : 발급 주체(Subject)의 조직(O) 이름 확인
String subjectDn = serverCert.getSubjectX500Principal().getName();
if (!subjectDn.contains("O=Toss Payments") && !subjectDn.contains("O=Naver Financial")) {
throw new CertificateException("허가되지 않은 기관(Organization)의 인증서입니다: " + subjectDn);
}
// [중요 로직] EV 인증서 검증 로직 (2) : 특정 시리얼 번호(Serial Number) 대조
String serialNumber = serverCert.getSerialNumber().toString(16).toUpperCase(); // 16진수 변환
if (!serialNumber.equals("1A2B3C4D5E6F7A8B")) {
throw new CertificateException("사전에 합의된 EV 인증서 시리얼 넘버와 불일치합니다. (MITM 혹은 인증서 교체 의심)");
}
// 기본 만료기한 등 서명 자체의 최소 무결성은 추가로 검증해야 합니다.
serverCert.checkValidity();
}
// 이하 필수 오버라이드 메서드들은 기본 로직 혹은 생략...
@Override public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {}
@Override public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
@Override public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {}
@Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
@Override public void checkServerTrusted(X509Certificate[] chain, String authType) {}
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}
};
// 2. SSLContext에 우리가 만든 Strict TrustManager 주입
SSLContext sslContext = SSLContext.getInstance("TLSv1.3");
sslContext.init(null, customTrustManager, new java.security.SecureRandom());
HttpClient httpClient = HttpClient.create()
.secure(t -> t.sslContext(SslContextBuilder.forClient()
.trustManager(customTrustManager[0]) // Netty Reactor 호환 어댑터
.build()));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
이와 같이 SSL 핸드셰이크가 벌어지는 찰나의 순간에 인증서 배열(chain[0])을 까보고 그 안에 들어있는 Serial Number나 Subject 정보를 사전에 공유된 화이트리스트(DB/설정파일) 값과 비교하는 것이 EV(Extended Validation) 인증서 수준의 고도화된 REST API 통신 암호화 방어 기법입니다.