Vue·Spring Boot RSA 복호화 실패 추적기: 세션 쿠키부터 Vite Proxy·SSH 터널까지
로컬 Vue 화면에서 로컬 WAS를 호출할 때는 로그인이 정상적으로 처리됐습니다. 그런데 프런트엔드는 그대로 http://localhost:3030에서 실행하고 API 주소만 개발 WAS인 http://192.168.0.76:8060으로 바꾸자 이상한 일이 생겼습니다.
공개키 조회 API는 성공했습니다. 브라우저에서 비밀번호 암호화도 됐습니다. 로그인 요청도 서버까지 도착했습니다. 하지만 서버의 RSA 복호화 단계에서 BadPaddingException이 발생했습니다.
처음에는 암호화 알고리즘, 키 길이, 공개키 문자열의 줄바꿈부터 의심했습니다. 그 과정이 완전히 틀린 것은 아니었습니다. 다만 결정적인 단서는 브라우저 개발자 도구에 이미 나와 있었습니다.
This attempt to set a cookie via a Set-Cookie header was blocked due to user preferences.
공개키는 받았는데 그 키에 대응하는 개인키를 보관한 세션 쿠키가 유지되지 않은 것입니다. 서버 입장에서는 공개키를 발급한 요청과 암호문을 받은 요청이 서로 다른 세션처럼 보였습니다. 결국 복호화 실패는 RSA 코드 한 줄의 문제가 아니라, 브라우저·프록시·세션·암호화가 만나는 경계에서 생긴 문제였습니다.
이 글에서는 같은 증상을 만났을 때 무엇부터 확인해야 하는지, 직접 교차 출처 호출을 유지할 경우 어떤 조건이 필요한지, 개발 환경에서는 왜 Vite Proxy가 가장 현실적인지, SSH 터널과 127.0.0.1은 정확히 무엇을 바꾸는지까지 한 흐름으로 정리합니다.
이 글의 예시는 개발 환경 진단을 위한 것입니다. 비밀번호와 세션을 다루는 운영 서비스는 반드시 HTTPS를 기본 전제로 설계해야 합니다. 애플리케이션에서 RSA로 비밀번호를 한 번 더 암호화하더라도 TLS를 대체할 수 없습니다.
3줄 요약
- 공개키 조회는 성공하지만 복호화가 실패한다면, 키 자체보다 공개키를 발급한 세션과 복호화를 수행하는 세션이 같은지 먼저 확인합니다.
localhost:3030 → 192.168.0.76:8060은 교차 출처이면서 교차 사이트입니다.withCredentials와 CORS만 맞춰도 사설 IP의 HTTP 쿠키 정책에서 다시 막힐 수 있습니다.- 개발 환경의 가장 안정적인 구성은 브라우저가 상대 경로
/api만 호출하고, Vite Proxy가 개발 WAS 또는 SSH 로컬 포트로 전달하게 만드는 것입니다.
목차
- 증상에서 바로 읽어야 할 신호
- 공개키와 개인키는 왜 같은 세션에 묶이는가
- Origin·Site·Cookie를 분리해서 이해하기
- 10분 안에 원인을 좁히는 진단 순서
- 직접 호출을 유지할 때 필요한 설정
- Spring Boot·Spring Security CORS 설정
- 가장 실용적인 해법: Vite 개발 프록시
- Base64와 RSA 파라미터 무결성 점검
- SSH 터널과 127.0.0.1의 실제 의미
- 권장 조합: Vite Proxy + SSH 터널
- 단일 WAS인데도 복호화가 실패하는 다른 원인
- RSA를 쓰더라도 HTTPS가 필요한 이유
- 최종 체크리스트
- 자주 묻는 질문
- 마무리
1. 증상에서 바로 읽어야 할 신호
문제 상황을 요청 순서대로 놓으면 원인 후보가 빠르게 줄어듭니다.
- Vue가 공개키 조회 API를 호출합니다.
- Spring Boot가 RSA 키 쌍을 만들거나 준비된 키를 선택합니다.
- 서버는 공개키를 응답하고, 개인키 또는 개인키를 찾을 식별자를 세션에 보관합니다.
- 브라우저는 공개키로 비밀번호를 암호화합니다.
- Vue가 암호문을 로그인 API로 전송합니다.
- 서버는 같은 세션에서 개인키를 꺼내 복호화합니다.
여기서 1번부터 4번까지 성공했다는 사실은 “전체 암호화 흐름이 정상”이라는 뜻이 아닙니다. 공개키는 민감 정보가 아니므로 세션 쿠키가 없어도 응답을 받을 수 있습니다. 반면 6번은 앞선 요청과 연결된 상태가 필요합니다.
따라서 다음 조합은 세션 단절을 강하게 의심하게 합니다.
- 공개키 응답은
200 OK다. - 브라우저 암호화 함수도 오류 없이 결과를 만든다.
- 로그인 요청은 서버에 도착한다.
- 서버에서
BadPaddingException또는 복호화 실패가 발생한다. - 개발자 도구에는
Set-Cookie차단 경고가 있다. - 공개키 발급 요청과 로그인 요청의
JSESSIONID가 다르거나 한쪽에 없다.
다만 BadPaddingException 하나만으로 “다른 개인키를 사용했다”고 단정하면 안 됩니다. 잘못된 키 외에도 손상된 암호문, 다른 패딩 방식, OAEP 해시 불일치, Base64 변형 등 여러 원인이 같은 예외로 나타날 수 있습니다. 예외명은 결론이 아니라 출발점입니다.
가장 먼저 볼 곳
Chrome·Edge 계열 개발자 도구에서는 다음 세 곳을 함께 봅니다.
- Network → 공개키 응답 → Cookies:
Set-Cookie가 실제로 수락됐는지 확인합니다. - Application → Cookies: 어느 호스트 아래에 어떤 속성으로 저장됐는지 확인합니다.
- Network → 로그인 요청 → Request Headers:
Cookie: JSESSIONID=...가 전송됐는지 확인합니다.
브라우저 JavaScript에서 response.headers.get('Set-Cookie')로 확인하려 해서는 안 됩니다. Set-Cookie는 브라우저가 스크립트에 노출하지 않는 응답 헤더입니다. Access-Control-Expose-Headers에 추가해도 읽을 수 없습니다.
2. 공개키와 개인키는 왜 같은 세션에 묶이는가
세션에 RSA 개인키를 임시 저장하는 구현은 대체로 아래와 비슷합니다.
@GetMapping("/auth/public-key")
public PublicKeyResponse issuePublicKey(HttpSession session) {
KeyPair keyPair = rsaKeyService.generateKeyPair();
session.setAttribute("LOGIN_PRIVATE_KEY", keyPair.getPrivate());
return new PublicKeyResponse(
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded())
);
}
로그인 요청에서는 같은 세션에서 개인키를 꺼냅니다.
@PostMapping("/auth/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest request,
HttpSession session) {
PrivateKey privateKey =
(PrivateKey) session.getAttribute("LOGIN_PRIVATE_KEY");
if (privateKey == null) {
return ResponseEntity.status(400)
.body(Map.of("code", "LOGIN_KEY_EXPIRED"));
}
String password = rsaService.decrypt(request.cipherText(), privateKey);
// 재사용 방지를 위해 성공 여부와 관계없이 수명 정책을 명확히 정한다.
session.removeAttribute("LOGIN_PRIVATE_KEY");
return authenticate(request.username(), password);
}
브라우저가 첫 응답의 세션 쿠키를 저장하지 않거나 두 번째 요청에 싣지 않으면 Spring은 새 세션을 만들 수 있습니다. 그러면 새 세션에는 LOGIN_PRIVATE_KEY가 없습니다. 구현에 따라 키가 없다는 오류가 나기도 하고, 새 키를 잘못 참조해 패딩 오류처럼 보이기도 합니다.
핵심은 공개키 문자열이 같아 보이는가가 아닙니다. 다음 세 가지가 하나의 요청 흐름에 속하는지 확인해야 합니다.
- 공개키 발급 당시 세션 ID
- 서버가 보관한 개인키의 식별자 또는 지문
- 로그인 요청 당시 세션 ID
로그에는 무엇을 남겨야 하나
원인을 찾겠다고 비밀을 그대로 로그에 남기면 더 큰 문제가 생깁니다. 다음 정도면 충분합니다.
requestId=8d2f... event=public_key_issued sessionPresent=true keyId=sha256:93ab12...
requestId=b71c... event=login_decrypt sessionPresent=true keyPresent=false cipherBytes=256
남겨도 되는 항목은 요청 ID, 세션 존재 여부, 키 존재 여부, 공개키의 짧은 비가역 지문, Base64 문자열 길이, 디코딩 후 바이트 길이, 알고리즘 이름입니다.
반대로 다음 값은 남기지 않습니다.
- 전체
JSESSIONID - 개인키 또는 개인키 직렬화 값
- 평문 비밀번호
- 전체 암호문
- 사용자가 입력한 인증 정보
세션 ID가 꼭 필요하다면 원문 대신 서버 전용 비밀키를 이용한 HMAC의 짧은 앞부분을 비교용으로 사용할 수 있습니다. 단순 SHA-256만 적용한 세션 ID도 유출 시 사전 대입의 대상이 될 수 있어 권장하지 않습니다.
3. Origin·Site·Cookie를 분리해서 이해하기
이 문제를 어렵게 만드는 이유는 CORS와 쿠키가 비슷한 시점에 등장하지만 서로 다른 기준으로 판단되기 때문입니다.
Origin은 스킴·호스트·포트의 조합이다
다음 두 주소는 서로 다른 출처입니다.
프런트엔드: http://localhost:3030
개발 API: http://192.168.0.76:8060
호스트와 포트가 모두 다릅니다. 브라우저가 JavaScript의 API 요청을 허용할지 판단할 때 CORS가 적용됩니다.
포트만 달라도 출처는 다릅니다.
http://localhost:3030
http://localhost:8060
이 조합도 교차 출처이므로 CORS는 필요합니다.
Site는 Origin과 다르다
SameSite 쿠키의 “site”는 Origin과 같은 개념이 아닙니다. 포트가 다르더라도 스킴과 사이트 호스트가 같으면 같은 사이트로 판단될 수 있습니다.
따라서 localhost:3030 → localhost:8060은 교차 출처이지만 같은 사이트일 수 있습니다. 반면 localhost:3030 → 192.168.0.76:8060은 호스트 계열이 달라 교차 사이트입니다.
이 차이 때문에 “출처가 다르면 무조건 SameSite=None”이라고 외우면 틀립니다. 정확한 순서는 다음과 같습니다.
- CORS는 Origin을 기준으로 본다.
- 쿠키의
SameSite는 Site를 기준으로 본다. - 브라우저의 제3자 쿠키 정책은 그 위에서 또 별도로 적용될 수 있다.
Cookie는 포트별로 분리되지 않는다
쿠키는 Origin처럼 포트 번호로 격리되지 않습니다. localhost:3030에서 저장된 host-only 쿠키는 속성과 경로 조건이 맞으면 다른 localhost 포트 요청에도 포함될 수 있습니다. 이것이 CORS의 포트 구분과 자주 혼동되는 지점입니다.
Domain을 생략하면 응답 호스트에만 적용되는 host-only 쿠키가 됩니다. 개발 환경에서는 대체로 이것이 가장 단순하고 안전합니다. 192.168.0.76이 보낸 응답에서 Domain=localhost를 지정해도 도메인이 일치하지 않아 브라우저가 거부합니다.
SameSite=None에는 Secure가 필수다
교차 사이트 fetch/XHR에서 세션 쿠키를 쓰려면 일반적으로 SameSite=None이 필요합니다. 그런데 SameSite=None에는 Secure가 필수입니다. Secure 쿠키는 HTTPS 응답에서 설정하는 것이 원칙입니다.
브라우저는 개발 편의를 위해 http://localhost 같은 루프백 주소를 특별 취급할 수 있지만, 그 예외가 http://192.168.0.76 같은 일반 사설 IP까지 확장되지는 않습니다. 그래서 아래 조합은 안정적인 해법이 아닙니다.
SameSite=None; Secure=false
withCredentials와 CORS 헤더를 완벽히 설정해도 쿠키 저장 단계에서 막힐 수 있는 이유입니다.
CORS와 쿠키 정책은 별도의 관문이다
CORS가 통과했다고 쿠키가 자동으로 저장·전송되는 것은 아닙니다. 반대로 쿠키가 저장됐더라도 JavaScript가 응답을 읽는 단계에서 CORS 오류가 날 수 있습니다.
특히 두 경우를 구분해야 합니다.
- preflight 실패: 실제 로그인 요청 자체가 전송되지 않습니다.
- 실제 응답의 CORS 검사 실패: 실제 요청은 전송됐지만 JavaScript가 응답을 받지 못합니다. 세부적인 쿠키 처리는 브라우저 구현과 Fetch 규칙에 따라 별도로 이뤄지므로, “CORS 오류가 보이면 쿠키는 절대 저장되지 않았다”고 단정하지 않습니다.
개발자 도구의 Network·Cookies·Issues와 Application 저장소를 함께 봐야 하는 이유입니다.
4. 10분 안에 원인을 좁히는 진단 순서
암호화 코드를 처음부터 다시 쓰기 전에 아래 순서를 그대로 따라가면 불필요한 시행착오를 줄일 수 있습니다.
1단계: 공개키 응답의 쿠키를 확인한다
Network에서 공개키 조회 요청을 선택한 뒤 응답의 Cookies 탭을 봅니다.
JSESSIONID가 설정됐는가?- 차단 사유가 표시되는가?
Domain,Path,SameSite,Secure,HttpOnly값은 무엇인가?- 쿠키가
localhost가 아니라192.168.0.76저장소에 생긴 것은 아닌가?
백엔드 IP가 발급한 host-only 쿠키는 해당 IP의 쿠키 저장소에 보이는 것이 정상입니다. localhost 아래에서 찾고 “쿠키가 없다”고 판단하면 안 됩니다.
2단계: 로그인 요청의 Cookie 헤더를 확인한다
로그인 요청의 Request Headers에서 같은 JSESSIONID가 전송되는지 확인합니다. 없다면 RSA 코드를 보기 전에 쿠키와 요청 설정을 해결합니다.
3단계: preflight를 분리한다
JSON POST, Authorization, 사용자 정의 헤더, PUT·DELETE 등은 OPTIONS 사전 요청을 만들 수 있습니다.
OPTIONS가 200 또는 204로 끝나는가?Access-Control-Allow-Origin이 정확히http://localhost:3030인가?Access-Control-Allow-Credentials: true가 있는가?- 요청한 메서드와 헤더가 허용됐는가?
- Spring Security가
OPTIONS를 미인증 요청으로 401·403 처리하지 않는가?
preflight에는 세션 쿠키가 없으므로 CORS가 Security 인증보다 먼저 처리돼야 합니다.
4단계: 서버 세션과 키 존재 여부를 확인한다
공개키 발급과 로그인 요청에 같은 비교용 세션 지문을 남깁니다. 개인키가 세션에 있는지, 키 식별자가 일치하는지도 함께 봅니다.
PrivateKey privateKey =
(PrivateKey) session.getAttribute("LOGIN_PRIVATE_KEY");
if (privateKey == null) {
log.warn("login key missing requestId={} sessionPresent={}",
requestId,
!session.isNew());
throw new LoginKeyExpiredException();
}
키가 없을 때 곧바로 새 키를 생성해 복호화를 시도하면 원인이 패딩 오류로 흐려질 수 있습니다. “키 없음”과 “복호화 실패”를 서로 다른 오류 코드로 관리하는 편이 좋습니다.
5단계: 암호문이 전송 중 변하지 않았는지 본다
브라우저에서 만든 Base64 길이와 서버가 받은 문자열 길이, 디코딩 후 바이트 길이를 비교합니다. 2048비트 RSA 암호문은 정상적으로 생성됐다면 디코딩 후 보통 256바이트입니다.
+가 공백으로 바뀌거나, URL 디코딩이 두 번 적용되거나, 앞뒤 공백이 붙거나, 줄바꿈이 섞이면 같은 키로도 복호화할 수 없습니다.
6단계: 알고리즘 파라미터를 문자 단위로 맞춘다
프런트와 백엔드에서 다음 값을 모두 비교합니다.
- RSA 패딩: PKCS#1 v1.5인가 OAEP인가
- OAEP 메시지 다이제스트: SHA-1인가 SHA-256인가
- MGF1 다이제스트: SHA-1인가 SHA-256인가
- 공개키 형식: SPKI인가 다른 컨테이너인가
- 암호문 인코딩: Base64인가 Base64url인가
- 입력 문자열의 문자 인코딩: UTF-8인가
여기까지 확인한 뒤에야 키 파일과 암호화 라이브러리 자체를 의심합니다.
5. 직접 호출을 유지할 때 필요한 설정
프런트에서 개발 WAS의 절대 주소를 직접 호출해야 한다면 Axios와 서버 양쪽을 모두 맞춰야 합니다.
import axios from 'axios'
export const devApi = axios.create({
baseURL: 'http://192.168.0.76:8060',
timeout: 10_000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
Axios의 withCredentials 기본값은 false입니다. 교차 출처 요청에 쿠키를 포함하려면 전용 인스턴스에서 true로 설정해야 합니다.
전역 기본값을 무조건 바꾸는 방식은 피하는 편이 좋습니다.
// 범위가 너무 넓어질 수 있다.
axios.defaults.withCredentials = true
애플리케이션이 외부 API도 호출한다면 의도하지 않은 출처에 자격 증명을 포함시키는 실수로 이어질 수 있습니다. 인증이 필요한 API 전용 인스턴스를 만들고 허용된 baseURL만 사용합니다.
Fetch를 사용한다면 대응 옵션은 다음과 같습니다.
const response = await fetch(
'http://192.168.0.76:8060/api/auth/public-key',
{
method: 'GET',
credentials: 'include',
},
)
하지만 이 옵션 하나만으로 해결되지는 않습니다. 서버는 정확한 출처를 허용하고 credentials를 승인해야 하며, 쿠키의 SameSite·Secure 정책과 브라우저의 제3자 쿠키 정책도 통과해야 합니다.
이 사례처럼 HTTP localhost에서 HTTP 사설 IP로 호출하는 구성은 SameSite=None; Secure를 안정적으로 만족시키기 어렵습니다. 직접 호출 설정은 진단에는 도움이 되지만, 최종 개발 구조로는 Vite Proxy가 더 단순합니다.
6. Spring Boot·Spring Security CORS 설정
직접 교차 출처 호출을 유지한다면 서버는 최소한 다음 조건을 만족해야 합니다.
- 허용 Origin은
http://localhost:3030처럼 정확히 지정합니다. - 끝에
/를 붙이거나 경로를 넣지 않습니다. - credentialed 요청에서는
*를 Origin으로 사용할 수 없습니다. allowCredentials(true)를 켭니다.- 실제 필요한 메서드와 헤더만 허용합니다.
- CORS가 Spring Security보다 먼저 처리되도록 연결합니다.
한 곳에서 재사용할 CorsConfigurationSource를 만드는 방식은 다음과 같습니다.
import java.time.Duration;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
class CorsConfig {
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3030"));
config.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
config.setAllowedHeaders(List.of(
"Content-Type",
"X-XSRF-TOKEN"
));
config.setAllowCredentials(true);
config.setMaxAge(Duration.ofHours(1));
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Spring Security에서도 같은 설정을 명시적으로 연결합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http,
CorsConfigurationSource corsConfigurationSource) throws Exception {
http.cors(cors ->
cors.configurationSource(corsConfigurationSource)
);
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/public-key").permitAll()
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated()
);
// 세션·쿠키 인증이라면 CSRF 정책을 별도로 설계한다.
// CORS 오류 해결을 이유로 CSRF를 무조건 끄지 않는다.
return http.build();
}
}
프로젝트에 CorsConfigurationSource 빈이 여러 개라면 Spring Security가 자동으로 어느 것을 사용할지 정하지 못할 수 있습니다. 각 SecurityFilterChain에 사용할 설정을 직접 지정합니다.
OPTIONS /**를 무조건 열면 해결될까
정상적인 CORS 필터가 Security보다 먼저 동작하면 모든 OPTIONS를 광범위하게 permitAll()로 여는 우회 설정이 일반적으로 필요하지 않습니다. preflight는 요청 Origin·메서드·헤더를 CORS 정책에 따라 검증해야 합니다.
CSRF를 끄면 안 되는 이유
CORS와 CSRF는 목적이 다릅니다. CORS는 브라우저가 다른 출처의 응답을 JavaScript에 공개할지 통제하고, CSRF 보호는 사용자의 쿠키를 악용한 상태 변경 요청을 막습니다.
세션 쿠키 인증을 쓰면서 CORS 오류를 해결하려고 다음 설정을 습관적으로 넣어서는 안 됩니다.
// 문제 해결용 복사·붙여넣기로 사용하지 않는다.
http.csrf(csrf -> csrf.disable());
정말 stateless Bearer 토큰만 사용하는지, 브라우저가 쿠키를 자동 전송하는 경로는 없는지 확인한 뒤 별도의 위협 모델에 따라 결정해야 합니다.
server.servlet.session.cookie.same-site의 범위
Spring Boot 속성으로 세션 쿠키의 SameSite를 조정할 수 있지만, 모든 배포 방식에서 같은 효과를 낸다고 단정하면 안 됩니다. 내장 Tomcat·Jetty와 Spring Session에서는 Boot 설정 범위를 확인할 수 있지만, 외부 WAS에 WAR로 배포해 컨테이너가 JSESSIONID를 발급한다면 해당 WAS의 쿠키 설정도 별도로 확인해야 합니다.
7. 가장 실용적인 해법: Vite 개발 프록시
개발 중 브라우저가 백엔드 IP를 직접 호출하지 않게 만들면 문제의 절반이 사라집니다.
브라우저는 다음 주소만 호출합니다.
http://localhost:3030/api/auth/public-key
http://localhost:3030/api/auth/login
Vite 개발 서버가 /api 요청을 받아 개발 WAS로 전달합니다.
브라우저
→ http://localhost:3030/api/...
→ Vite 개발 프록시
→ http://192.168.0.76:8060/api/...
브라우저 관점에서는 페이지와 API 요청이 같은 출처입니다. 개발 WAS와 통신하는 주체는 브라우저가 아니라 Vite의 서버 측 프록시이므로 브라우저의 교차 사이트 쿠키 문제를 피할 수 있습니다.
vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
server: {
port: 3030,
strictPort: true,
proxy: {
'/api': {
target:
env.DEV_WAS_TARGET ?? 'http://192.168.0.76:8060',
changeOrigin: true,
cookieDomainRewrite: '',
// 백엔드 주소에 /api가 없다면 그때만 활성화한다.
// rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
}
})
로컬 전용 값은 저장소에 커밋하지 않는 파일에 둡니다.
# .env.development.local
DEV_WAS_TARGET=http://192.168.0.76:8060
프런트의 Axios 인스턴스는 절대 주소 대신 상대 경로를 사용합니다.
import axios from 'axios'
export const api = axios.create({
baseURL: '/api',
timeout: 10_000,
headers: {
'Content-Type': 'application/json',
},
})
const { data: key } = await api.get('/auth/public-key')
await api.post('/auth/login', {
username,
cipherText: encryptPassword(password, key.publicKey),
})
가장 흔한 실패는 Vite Proxy를 설정해 놓고도 Axios의 baseURL을 계속 http://192.168.0.76:8060으로 두는 것입니다. 이 경우 브라우저가 프록시를 우회하므로 아무것도 달라지지 않습니다. Network 탭의 Request URL이 반드시 http://localhost:3030/api/...로 보여야 합니다.
changeOrigin은 CORS 해제 옵션이 아니다
changeOrigin: true는 프록시가 업스트림으로 보내는 Host 헤더를 대상 서버에 맞게 바꾸는 옵션입니다. 가상 호스트 라우팅 등에 도움이 되지만 CORS를 끄는 기능은 아닙니다.
Vite Proxy가 유효한 이유는 changeOrigin 때문이 아니라 브라우저가 같은 출처의 /api를 호출하도록 요청 구조를 바꿨기 때문입니다.
여기서 한 가지는 남습니다. 브라우저가 POST 요청에 넣은 Origin: http://localhost:3030을 Vite가 업스트림으로 전달하면 Spring의 CORS 필터가 여전히 그 헤더를 검사할 수 있습니다. 이때는 개발 Origin을 Spring 허용 목록에 유지해야 합니다. 즉 프록시는 브라우저의 교차 출처·교차 사이트 구조를 단순화하지만, 백엔드에 도착한 Origin 헤더와 CORS 필터의 존재 자체를 자동으로 지우지는 않습니다.
cookieDomainRewrite: ''의 의미
백엔드가 다음처럼 자신의 IP나 도메인을 Domain에 박아 쿠키를 보낸다면 localhost 페이지에서는 사용할 수 없습니다.
Set-Cookie: JSESSIONID=...; Domain=192.168.0.76; Path=/; HttpOnly
cookieDomainRewrite: ''는 프록시 응답에서 Domain 속성을 제거해 localhost의 host-only 쿠키로 저장되도록 돕습니다. 백엔드가 애초에 Domain을 생략했다면 이 옵션이 바꿀 것은 없습니다.
Secure, SameSite, Path는 별도 속성입니다. Domain만 제거한다고 모든 쿠키 문제가 해결되는 것은 아닙니다. 특히 백엔드가 개발 HTTP 환경에서도 Secure를 강제로 붙이면 브라우저 저장 여부를 다시 확인해야 합니다.
프록시는 개발 편의 기능이지 운영 배치가 아니다
Vite 개발 프록시는 로컬 개발에 적합합니다. 운영에서는 Nginx, API Gateway, BFF 같은 정식 리버스 프록시를 통해 같은 사이트 구조를 만들고 HTTPS, 보안 헤더, 타임아웃, 로깅, 요청 크기 제한을 함께 관리해야 합니다.
8. Base64와 RSA 파라미터 무결성 점검
세션이 정상인데도 복호화가 실패한다면 전송 문자열과 알고리즘 파라미터를 확인합니다.
JSON으로 보내는 것이 가장 단순하다
Base64에는 +, /, = 문자가 들어갈 수 있습니다. JSON 문자열로 전송하면 HTTP 폼 인코딩의 + 해석 문제를 피하기 쉽습니다.
await api.post('/auth/login', {
username,
cipherText,
})
public record LoginRequest(
String username,
String cipherText
) {}
form-urlencoded가 필요하다면 직접 문자열을 만들지 않는다
아래처럼 보간한 문자열을 직접 만들면 Base64의 +가 폼 디코딩 과정에서 공백으로 바뀔 수 있습니다.
// 피한다.
const body = `cipherText=${cipherText}`
브라우저 API에 값을 맡깁니다.
const body = new URLSearchParams()
body.append('username', username)
body.append('cipherText', cipherText)
await api.post('/auth/login', body, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
서버에서 replace(" ", "+")로 복구하는 방식은 임시 흔적을 숨길 뿐입니다. 실제 공백과 손상된 +를 구분할 수 없고, 이중 디코딩이나 다른 변형을 놓칩니다. JSON 또는 양쪽이 합의한 Base64url을 사용해 원인을 제거합니다.
디코딩 전후 길이를 확인한다
byte[] encrypted;
try {
encrypted = Base64.getDecoder().decode(request.cipherText());
} catch (IllegalArgumentException e) {
throw new InvalidCipherTextException("ciphertext is not valid Base64");
}
log.debug("decrypt input requestId={} base64Chars={} cipherBytes={}",
requestId,
request.cipherText().length(),
encrypted.length);
2048비트 RSA라면 암호문은 256바이트여야 합니다. 이 값이 다르면 키보다 전송 과정부터 의심합니다. 단, 이 로그에도 암호문 원문은 남기지 않습니다.
OAEP는 해시 두 곳을 맞춰야 한다
신규 설계라면 PKCS#1 v1.5보다 RSA-OAEP를 우선 고려합니다. 다만 “OAEPWithSHA-256”이라는 이름만 같다고 상호 운용이 보장되지는 않습니다.
OAEP에는 메시지 다이제스트와 MGF1 다이제스트가 있습니다. Web Crypto에서 SHA-256 기반 RSA-OAEP를 사용한다면 Java에서도 MGF1 SHA-256을 명시하는 편이 안전합니다.
import java.security.PrivateKey;
import java.security.spec.MGF1ParameterSpec;
import javax.crypto.Cipher;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
OAEPParameterSpec oaepSpec = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT
);
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepSpec);
byte[] plainBytes = cipher.doFinal(encrypted);
프런트의 Web Crypto도 같은 해시를 사용합니다.
const encrypted = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
new TextEncoder().encode(password),
)
공개키를 import할 때 알고리즘과 해시를 명시합니다.
const publicKey = await crypto.subtle.importKey(
'spki',
publicKeyBytes,
{
name: 'RSA-OAEP',
hash: 'SHA-256',
},
false,
['encrypt'],
)
Java 프로바이더에 따라 RSA/ECB/OAEPWithSHA-256AndMGF1Padding의 MGF1 기본 해시가 기대와 다를 수 있습니다. 이름에만 의존하지 않고 OAEPParameterSpec을 명시하면 이 모호함을 줄일 수 있습니다.
RSA로 큰 JSON을 직접 암호화하지 않는다
RSA는 큰 본문을 암호화하는 도구가 아닙니다. 2048비트 키와 OAEP SHA-256 조합에서 직접 암호화할 수 있는 평문은 최대 190바이트입니다.
비밀번호처럼 짧은 값도 실제 UTF-8 바이트 길이를 확인해야 합니다. 여러 필드를 JSON으로 묶어 한 번에 암호화하다 한도를 넘기면 IllegalBlockSizeException이 발생할 수 있습니다.
큰 데이터를 보호해야 한다면 다음과 같은 하이브리드 방식을 사용합니다.
- 임의의 AES 키를 생성합니다.
- 본문은 AES-GCM으로 암호화합니다.
- AES 키만 RSA-OAEP로 감쌉니다.
- nonce, 암호문, 인증 태그, 감싼 키를 함께 보냅니다.
하지만 로그인 비밀번호 전송의 기본 보호 수단은 여전히 HTTPS입니다. 애플리케이션 RSA 계층을 추가해야 하는 명확한 위협 모델이 없다면 복잡성만 늘어날 수 있습니다.
9. SSH 터널과 127.0.0.1의 실제 의미
개발 WAS가 외부에 열려 있지 않거나 방화벽 때문에 직접 접근할 수 없다면 SSH 로컬 포워딩이 유용합니다.
ssh -N -T -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 127.0.0.1:9999:127.0.0.1:8060 devuser@192.168.0.76
필요할 때만 SSH 포트와 키 파일을 추가합니다.
ssh -p 2222 -i ~/.ssh/dev_ed25519 -N -T -o ExitOnForwardFailure=yes -L 127.0.0.1:9999:127.0.0.1:8060 devuser@192.168.0.76
각 옵션의 의미는 다음과 같습니다.
-L 127.0.0.1:9999:127.0.0.1:8060: 내 PC의 루프백 9999 포트를 원격 호스트 관점의 127.0.0.1:8060으로 전달합니다.-N: 원격 명령을 실행하지 않고 포워딩만 유지합니다.-T: 불필요한 가상 터미널 할당을 하지 않습니다.ExitOnForwardFailure=yes: 로컬 포트 바인딩 같은 포워딩 설정 실패를 즉시 알립니다.ServerAliveInterval과ServerAliveCountMax: 끊어진 연결을 무한히 살아 있는 것처럼 두지 않도록 확인합니다.
ExitOnForwardFailure=yes가 최종 목적지 API의 정상 응답까지 보장하는 것은 아닙니다. 포워딩 리스너를 만들었다는 것과 WAS가 정상 동작한다는 것은 별개이므로 curl이나 헬스 체크로 확인합니다.
curl -i http://127.0.0.1:9999/actuator/health
브라우저가 직접 9999 포트를 호출하면 CORS는 남는다
다음 두 주소는 포트가 다릅니다.
페이지: http://localhost:3030
API: http://localhost:9999
둘은 같은 사이트일 수 있지만 서로 다른 출처입니다. 따라서 Vue가 http://localhost:9999를 직접 호출하면 CORS는 여전히 필요합니다. SSH 터널은 TCP 경로를 바꿀 뿐 브라우저의 Origin 정책을 바꾸지 않습니다.
WAS가 보는 클라이언트 IP는 무엇인가
SSH 로컬 포워딩에서는 원격 SSH 서버가 목적지로 새 TCP 연결을 엽니다.
- SSH 서버와 WAS가 같은 호스트이고 목적지가
127.0.0.1:8060이면 WAS는 대개 루프백 연결로 봅니다. - SSH 서버와 WAS가 다른 호스트라면 WAS는 원격 PC가 아니라 SSH 서버 또는 배스천의 내부 주소를 봅니다.
- 컨테이너·NAT·서비스 메시가 있으면 브리지나 프록시 주소가 보일 수 있습니다.
SSH가 HTTP의 X-Forwarded-For를 자동 추가하지는 않습니다. 그것은 HTTP 리버스 프록시의 기능입니다. 또한 브라우저가 만든 Origin 헤더를 SSH가 변경하지도 않습니다.
바인드 주소를 생략하지 않는 이유
-L 9999:...처럼 바인드 주소를 생략해도 기본값이 루프백인 환경이 많습니다. 그래도 127.0.0.1:9999를 명시하면 개발 API 포트가 실수로 LAN 전체에 노출되는 것을 예방하고 의도를 분명하게 만들 수 있습니다.
10. 권장 조합: Vite Proxy + SSH 터널
Vite Proxy와 SSH 터널은 서로 대체 관계가 아닙니다. 해결하는 층이 다릅니다.
- Vite Proxy는 브라우저의 Origin·쿠키 경계를 단순화합니다.
- SSH 터널은 로컬 PC에서 개발 WAS까지의 네트워크 경로를 안전하게 엽니다.
두 가지가 모두 필요하다면 아래처럼 조합합니다.
브라우저
→ http://localhost:3030/api/...
→ Vite Proxy
→ http://127.0.0.1:9999
→ SSH 암호화 터널
→ 개발 서버의 127.0.0.1:8060
→ Spring Boot WAS
SSH 터널을 먼저 실행하고 Vite 대상만 로컬 포트로 바꿉니다.
# .env.development.local
DEV_WAS_TARGET=http://127.0.0.1:9999
vite.config.ts의 프록시 설정은 그대로 둡니다.
proxy: {
'/api': {
target: env.DEV_WAS_TARGET,
changeOrigin: true,
cookieDomainRewrite: '',
},
}
Vue는 끝까지 상대 경로만 호출합니다.
await api.get('/auth/public-key')
await api.post('/auth/login', payload)
이 구성에서 브라우저가 보는 요청 출처는 계속 localhost:3030입니다. 브라우저는 SSH 포트의 존재를 몰라도 됩니다. Vite가 서버 측에서 127.0.0.1:9999로 연결하고, SSH가 그 TCP 연결을 개발 서버로 전달합니다.
단계별 확인 방법
한꺼번에 실행하고 실패 원인을 섞지 말고 아래 순서로 확인합니다.
- 개발 서버에서 WAS가
127.0.0.1:8060에 실제로 리슨하는지 확인합니다. - SSH 터널을 실행합니다.
- 로컬에서
curl http://127.0.0.1:9999/...로 API 도달 여부를 확인합니다. - Vite의
DEV_WAS_TARGET을 9999 포트로 설정하고 재시작합니다. - 브라우저에서
http://localhost:3030/api/...가 성공하는지 봅니다. - 공개키 응답의 쿠키 저장과 로그인 요청의 쿠키 전송을 확인합니다.
- 같은 세션에서 키가 조회되는지 서버 로그의 비밀 없는 지문으로 대조합니다.
이 순서를 지키면 네트워크 실패, 프록시 경로 실패, 쿠키 실패, 암호화 실패를 서로 분리할 수 있습니다.
이 구성이 성공한 이유
처음에는 withCredentials만 켜거나 CORS Origin을 추가하는 방식으로 해결하려 했습니다. 일부 요청은 성공했지만 쿠키 경고가 남았고, 브라우저 정책에 따라 결과도 달라질 수 있었습니다.
Vite Proxy를 적용한 뒤에는 브라우저가 백엔드 사설 IP를 직접 보지 않았습니다. 공개키 발급과 로그인 요청이 동일한 localhost 쿠키 문맥을 공유했고, 세션에 저장한 개인키를 다시 찾을 수 있었습니다.
SSH가 필요한 환경에서는 Vite의 target을 SSH 로컬 포트로 바꾸기만 하면 됐습니다. 브라우저 문제와 네트워크 접근 문제를 각각 한 계층에서 해결한 것이 핵심이었습니다.
11. 단일 WAS인데도 복호화가 실패하는 다른 원인
WAS가 한 대라고 해서 키 불일치 가능성이 사라지는 것은 아닙니다. 로드밸런서나 세션 클러스터가 없어도 다음 원인은 남습니다.
키를 요청마다 새로 생성한다
공개키 조회 뒤 로그인 요청 사이에 같은 세션 속성을 덮어쓰는 API가 호출될 수 있습니다. 화면 초기화가 두 번 실행되거나 Vue 개발 모드의 생명주기, 중복 클릭, 재시도 로직 때문에 공개키 API가 예상보다 여러 번 호출되는지도 확인합니다.
공개키 발급 API는 다음 정책을 명확히 가져야 합니다.
- 키를 한 번 발급한 뒤 얼마 동안 유효한가
- 재발급 시 기존 키를 즉시 폐기하는가
- 로그인 성공·실패 후 키를 제거하는가
- 동일 키로 몇 번까지 시도할 수 있는가
- 여러 탭에서 동시에 로그인하면 어떻게 되는가
세션이 중간에 재생성된다
Spring Security는 세션 고정 공격 방어를 위해 인증 시 세션 ID를 바꿀 수 있습니다. 키를 어느 시점에 세션에 넣고 어느 시점에 읽는지에 따라 영향이 생길 수 있습니다.
또한 다음 설정도 확인합니다.
- 세션 만료 시간이 지나지 않았는가
- 로그인 요청 직전
invalidate()가 호출되지 않는가 - 개발 중 서버 재시작으로 메모리 세션이 사라지지 않았는가
- 공개키 API와 로그인 API가 실제로 같은 애플리케이션 컨텍스트로 들어가는가
- reverse proxy의 경로 재작성으로 다른 앱에 도달하지 않는가
키 직렬화 형식을 혼동한다
브라우저가 import하는 공개키는 보통 SPKI 형식입니다. PEM 헤더를 제거하고 Base64를 디코딩할 때 다음 문제가 자주 생깁니다.
PUBLIC KEY와RSA PUBLIC KEY를 같은 형식으로 취급한다.- PEM 헤더·푸터 또는 줄바꿈을 잘못 제거한다.
- 문자열을 UTF-16 코드 단위로 바이트 변환한다.
- URL-safe Base64와 표준 Base64를 섞는다.
공개키의 전체 문자열을 로그에 남기기보다, 디코딩 성공 여부와 형식, 키 크기, SHA-256 지문을 비교합니다.
PKCS#1 v1.5와 OAEP를 섞는다
프런트 라이브러리가 RSA/ECB/PKCS1Padding 방식으로 암호화하는데 백엔드는 OAEP로 복호화하거나 그 반대라면 당연히 실패합니다. “RSA를 사용한다”는 말만으로는 충분하지 않습니다.
레거시 호환 때문에 PKCS#1 v1.5를 유지해야 한다면 양쪽이 정확히 같은 방식을 사용하도록 고정하고, 자세한 복호화 오류나 처리 시간 차이를 외부에 노출하지 않습니다. 신규 기능은 OAEP를 우선 검토합니다.
문자 인코딩과 입력 정규화가 다르다
프런트는 UTF-8로 암호화했는데 서버가 다른 문자셋으로 평문을 해석하면 복호화 자체는 성공해도 인증이 실패할 수 있습니다. 한글·이모지·결합 문자가 포함된 비밀번호를 테스트하고, 암호화 전 임의의 Unicode 정규화를 적용하지 않습니다. 비밀번호는 사용자가 입력한 바이트 의미를 보존해야 합니다.
예외를 하나로 뭉개 원인을 잃는다
외부 응답은 정보 노출을 막기 위해 일반화하되, 내부에서는 단계를 구분합니다.
LOGIN_KEY_MISSING
INVALID_BASE64
INVALID_CIPHER_LENGTH
RSA_PARAMETER_MISMATCH
RSA_DECRYPTION_FAILED
AUTHENTICATION_FAILED
클라이언트에는 LOGIN_KEY_EXPIRED처럼 재발급 가능한 오류와 일반 로그인 실패 정도만 노출하고, 내부 원인은 요청 ID로 추적합니다. 암호문 유효성에 따른 상세 오류나 미세한 시간 차이는 공격자에게 오라클이 될 수 있으므로 주의합니다.
12. RSA를 쓰더라도 HTTPS가 필요한 이유
“비밀번호를 RSA로 암호화해 보내니 HTTP여도 괜찮다”는 결론은 위험합니다.
애플리케이션 수준 RSA가 보호하는 것은 잘 설계됐다는 전제 아래 특정 필드의 기밀성 일부뿐입니다. 다음 문제는 그대로 남습니다.
- 공격자가 공개키 응답을 자신의 키로 바꿀 수 있습니다.
- JavaScript 번들이 변조돼 암호화 전 비밀번호를 빼낼 수 있습니다.
- 사용자명, URL, 헤더, 세션 쿠키와 다른 요청 본문이 노출될 수 있습니다.
- 메시지가 캡처돼 그대로 재전송될 수 있습니다.
- 서버가 진짜 서버인지 브라우저가 인증할 방법이 없습니다.
TLS는 서버 인증, 전송 구간 기밀성, 무결성을 함께 제공합니다. RSA 필드 암호화는 TLS 위에 추가할 수 있는 애플리케이션 요구사항이지 TLS 대체재가 아닙니다.
재전송 방지가 목적이라면
비밀번호 암호문을 재사용하지 못하게 해야 한다면 키 쌍을 세션에 넣는 것만으로는 부족할 수 있습니다. 서버가 짧은 수명의 challenge를 발급하고 다음 값을 로그인 요청에 묶는 방식이 필요합니다.
- 일회용 nonce
- 발급 시각과 짧은 만료 시간
- 사용자 세션 또는 로그인 시도 식별자
- 사용 후 즉시 폐기되는 서버 상태
동일 암호문의 재전송을 서버에서 거절하고, 세부 오류는 외부에 구분해 주지 않습니다. 그래도 모든 통신은 HTTPS 위에서 이뤄져야 합니다.
운영 환경의 권장 형태
운영에서는 프런트와 API를 같은 등록 도메인 계열의 HTTPS로 제공하는 편이 좋습니다.
https://app.example.com
https://api.example.com
또는 브라우저에는 하나의 출처만 노출하고 리버스 프록시가 /api를 백엔드로 전달합니다.
https://app.example.com/
https://app.example.com/api/
두 번째 형태는 쿠키 경계를 단순하게 만들지만, 리버스 프록시 설정·CSRF·보안 헤더·세션 수명·로그 마스킹은 여전히 필요합니다.
13. 최종 체크리스트
아래 순서대로 확인하면 같은 장애를 재현하고 해결 여부까지 검증할 수 있습니다.
브라우저
- [ ] 공개키 응답이 실제로 도착한다.
- [ ] 개발자 도구 Cookies 탭에
Set-Cookie차단 이유가 없다. - [ ] Application 저장소에서 쿠키의 실제 호스트를 확인했다.
- [ ] 로그인 요청에 공개키 요청과 같은 세션 쿠키가 포함된다.
- [ ] 프록시 사용 시 Request URL이 백엔드 절대 주소가 아니라
/api경로다. - [ ]
Set-Cookie를 JavaScript로 읽어 검증하려 하지 않는다. - [ ] 테스트 중 제3자 쿠키 허용을 임시로 바꿨다면 원래 정책으로 되돌렸다.
Axios·Fetch
- [ ] 직접 교차 출처 호출에만
withCredentials: true또는credentials: 'include'를 적용했다. - [ ] 자격 증명 옵션을 전역으로 무분별하게 설정하지 않았다.
- [ ] Base64 암호문은 JSON 또는 올바른 인코더로 전송한다.
- [ ] 문자열 보간으로 form-urlencoded 본문을 만들지 않는다.
- [ ] 요청 재시도가 공개키 재발급이나 키 덮어쓰기를 만들지 않는다.
Spring Boot·Spring Security
- [ ] 허용 Origin은 스킴·호스트·포트까지 정확히 적었다.
- [ ] credentialed CORS에서 Origin
*를 사용하지 않는다. - [ ]
allowCredentials(true)가 필요한 직접 호출에 설정돼 있다. - [ ] preflight가 Spring Security 인증보다 먼저 처리된다.
- [ ] CORS 해결을 이유로 CSRF를 무조건 비활성화하지 않았다.
- [ ] 공개키 발급과 복호화 요청의 세션·키 지문을 안전하게 비교할 수 있다.
- [ ] 키 없음, Base64 오류, 길이 오류, 복호화 오류를 내부적으로 구분한다.
- [ ] 민감한 키·비밀번호·세션 ID·암호문 원문을 로그에 남기지 않는다.
쿠키
- [ ] 개발 프록시에서는
Domain을 생략한 host-only 쿠키를 우선 사용한다. - [ ] 백엔드가 Domain을 고정하면 프록시에서 제거 또는 올바르게 재작성한다.
- [ ]
Path가 로그인 API 경로를 포함한다. - [ ]
HttpOnly와Secure의 역할을 혼동하지 않는다. - [ ]
SameSite=None에는 반드시Secure가 필요하다는 점을 반영했다. - [ ] 외부 WAS 컨테이너가 세션 쿠키를 발급한다면 그 컨테이너 설정도 확인했다.
RSA
- [ ] 공개키 형식과 키 크기가 양쪽에서 일치한다.
- [ ] Base64 디코딩 후 암호문 길이가 키 크기와 맞는다.
- [ ] OAEP 메시지 해시와 MGF1 해시를 모두 맞췄다.
- [ ] 큰 JSON을 RSA로 직접 암호화하지 않는다.
- [ ]
BadPaddingException을 개인키 오류 하나로 단정하지 않는다. - [ ] 복호화 상세 오류와 시간 차이가 외부 오라클이 되지 않게 한다.
Vite·SSH
- [ ] Vite Proxy의 target은 환경 변수로 분리했다.
- [ ] Vue는 백엔드 절대 주소가 아니라
/api를 호출한다. - [ ]
changeOrigin을 CORS 해제 옵션으로 오해하지 않는다. - [ ] SSH 로컬 포워드의 바인드 주소를
127.0.0.1로 제한했다. - [ ] 터널 생성 성공과 최종 API 정상 응답을 별도로 확인했다.
- [ ] 브라우저가 SSH 포트를 직접 호출하면 여전히 CORS가 필요함을 이해했다.
- [ ] Vite target을 SSH 로컬 포트로 지정해 두 기능을 계층별로 조합했다.
14. 자주 묻는 질문
Q1. 공개키 API가 성공했으면 CORS는 정상 아닌가요?
아닙니다. 공개키 응답 본문을 읽는 것과 세션 쿠키를 저장하고 다음 요청에 보내는 것은 별도 조건입니다. 단순 GET은 성공했더라도 credential 설정, SameSite, Secure, 제3자 쿠키 정책에서 세션이 끊길 수 있습니다.
Q2. withCredentials: true만 넣으면 되나요?
아닙니다. 클라이언트 옵션과 함께 서버의 정확한 Access-Control-Allow-Origin, Access-Control-Allow-Credentials: true, 쿠키 속성, 브라우저 정책이 모두 맞아야 합니다. 현재 구조가 HTTP localhost에서 HTTP 사설 IP로 가는 교차 사이트 호출이라면 Vite Proxy가 더 안정적입니다.
Q3. localhost:3030과 localhost:9999도 다른 사이트인가요?
포트가 달라 서로 다른 Origin이므로 CORS는 필요합니다. 그러나 같은 스킴과 localhost를 사용하므로 SameSite 관점에서는 같은 사이트일 수 있습니다. Origin과 Site를 구분해야 합니다.
Q4. SSH 터널을 쓰면 CORS가 없어지나요?
아닙니다. SSH는 TCP 연결 경로만 만듭니다. 브라우저가 localhost:9999를 직접 호출하면 localhost:3030과 포트가 달라 CORS가 적용됩니다. 브라우저는 /api만 호출하고 Vite Proxy가 9999 포트로 연결하게 구성해야 합니다.
Q5. Vite Proxy의 changeOrigin: true가 CORS를 해결하나요?
직접적인 이유는 아닙니다. changeOrigin은 업스트림 요청의 Host 헤더를 대상에 맞추는 옵션입니다. CORS 문제가 줄어드는 이유는 브라우저가 백엔드 주소 대신 같은 출처의 /api를 호출하기 때문입니다.
Q6. WAS가 127.0.0.1에서 온 요청으로 보게 만들 수 있나요?
SSH 서버와 WAS가 같은 호스트이고 터널 목적지를 127.0.0.1:8060으로 지정하면 WAS는 대개 루프백 연결로 봅니다. SSH 서버와 WAS가 다른 서버라면 WAS는 배스천이나 중간 서버의 주소를 봅니다. Vite Proxy만 사용하는 경우에는 일반적으로 로컬 개발 PC의 네트워크 주소가 보이지, WAS의 루프백으로 보이는 것은 아닙니다.
Q7. Base64의 공백을 +로 바꾸면 해결되지 않나요?
증상만 가릴 수 있습니다. form-urlencoded에서 literal +가 공백으로 해석된 원인을 해결해야 합니다. JSON, URLSearchParams.append, 또는 합의된 Base64url을 사용하세요.
Q8. BadPaddingException이면 개인키가 다른 것 아닌가요?
가능한 원인 중 하나일 뿐입니다. 암호문 손상, 다른 패딩, OAEP 해시·MGF1 불일치도 같은 계열의 실패를 만들 수 있습니다. 세션, 디코딩 길이, 알고리즘 파라미터 순으로 확인합니다.
Q9. 개발 편의를 위해 CSRF를 꺼도 되나요?
CORS 문제 해결책으로 끄면 안 됩니다. 세션 쿠키 인증은 CSRF 위협을 남깁니다. 토큰 전달 방식과 변경 요청 보호 정책을 별도로 설계해야 합니다.
Q10. RSA로 암호화했으니 HTTPS 없이 테스트해도 되나요?
로컬 진단을 위한 제한된 테스트와 운영 보안은 구분해야 합니다. 애플리케이션 RSA는 서버 인증, 전체 요청 무결성, 쿠키 보호, JavaScript 변조 방지, 재전송 방지를 제공하지 않습니다. 운영에서는 HTTPS가 필수입니다.
15. 마무리
이번 문제의 가장 큰 교훈은 오류가 발생한 줄과 원인이 생긴 줄이 다를 수 있다는 점이었습니다.
서버에서는 RSA 복호화가 실패했습니다. 하지만 출발점은 브라우저가 공개키 응답의 세션 쿠키를 유지하지 못한 것이었습니다. CORS를 허용했다는 사실만 보고 네트워크 경계를 통과했다고 생각했고, BadPaddingException을 보고 암호화 라이브러리부터 의심하면서 진단 시간이 길어졌습니다.
문제를 해결한 순서는 단순했습니다.
- 공개키 발급과 로그인 요청의 세션 연속성을 확인했습니다.
- Origin, Site, 쿠키 정책을 서로 다른 관문으로 분리했습니다.
- 암호문이 Base64 전송 과정에서 변하지 않는지 확인했습니다.
- OAEP 해시와 MGF1 파라미터를 명시적으로 맞췄습니다.
- 브라우저는 상대 경로
/api만 호출하도록 바꿨습니다. - Vite Proxy가 개발 WAS로 전달하게 만들었습니다.
- 네트워크 접근이 제한된 환경에서는 그 대상만 SSH 로컬 포트로 교체했습니다.
결과적으로 프런트 코드에서는 백엔드의 실제 주소를 제거했고, 브라우저의 세션 경계는 단순해졌으며, SSH는 네트워크 접근이라는 자신의 역할만 맡게 됐습니다.
비슷한 장애를 만났다면 암호화 코드를 먼저 뜯어고치기보다 다음 질문부터 시작해 보세요.
이 암호문을 복호화하는 서버가, 공개키를 발급했던 바로 그 세션과 바로 그 키를 다시 보고 있는가?
이 질문 하나가 RSA 오류처럼 보이는 세션·쿠키 문제를 가장 빠르게 드러낼 수 있습니다.
참고한 공식 문서
- MDN: Same-origin policy
- WHATWG HTML: Site 정의
- MDN: CORS 가이드
- MDN: Set-Cookie
- WHATWG Fetch: CORS와 credentials
- Vite: server.proxy
- Spring Framework: CORS
- Spring Security: CORS
- Spring Security: CSRF
- OpenSSH: ssh 로컬 포워딩
- Java: BadPaddingException
- Java: OAEPParameterSpec
- RFC 8017: RSAES-OAEP
- OWASP: Transport Layer Security Cheat Sheet