Private Network Access(PNA) 정책과 localhost API 호출 문제
이 글에서 다루는 내용
웹 사이트에서
localhost로 REST API를 호출했을 때 Chrome의 Private Network Access(PNA) 정책에 걸려 "내 로컬 네트워크에 연결하려고 합니다" 팝업이 보인다.
이 글에서는 PNA에 대해서 알아보고 DNS 레이턴시가 발생하더라도 https 도메인을 사용해서 REST API를 호출하는 이유에 대해서 알아본다.
PNA란?
Chrome은 네트워크 주소를 세 단계로 분류한다.
| 분류 | 예시 |
|---|---|
| Public | 일반 인터넷 (hometax.go.kr 등) |
| Private | 192.168.x.x, 10.x.x.x, 172.16.x.x |
| Local | localhost, 127.0.0.1, ::1 |
PNA 정책은 Public → Private/Local 방향의 요청을 차단하거나 사용자 허가를 요구한다. localhost도 예외가 아니며, 오히려 로컬 머신 자원에 접근하는 것이라 Chrome은 더 민감하게 취급한다.
localhost API 호출 시 발생하는 문제들
1. PNA 차단
공개 사이트에서 localhost로 API를 호출하면 Chrome이 preflight 요청을 보내 허가를 묻는다.
https://your-site.com → http://localhost:3000
↓
Chrome: OPTIONS preflight + Access-Control-Request-Private-Network: true 전송
↓
로컬 서버가 응답 헤더 없으면 → 사용자에게 팝업 표시
2. Mixed Content 차단
HTTPS 사이트에서 HTTP localhost를 호출하면 Mixed Content 문제도 발생한다.
https://your-site.com → http://localhost:3000
(HTTPS) (HTTP)
↓
Mixed Content 차단
다만 Chrome은 localhost를 Secure Context로 예외 처리하여 Mixed Content 차단을 하지 않는다. 그러나 이는 Chrome에서만 해당되며 브라우저마다 동작이 다르다.
두 문제 정리
| 문제 | localhost | 사설 IP |
|---|---|---|
| Mixed Content | ✅ Chrome 예외 허용 | ❌ 차단 |
| PNA | ❌ 팝업 발생 | ❌ 팝업 발생 |
PNA 해결 방법 (임시방편)
로컬 서버가 preflight 요청에 아래 헤더를 응답하면 팝업 없이 동작한다.
Access-Control-Allow-Origin: https://your-site.com
Access-Control-Allow-Private-Network: true
Spring Boot Filter로 구현
Spring의 CorsRegistry는 PNA 헤더를 공식 지원하지 않으므로 Filter로 직접 추가해야 한다.
@Component
public class PrivateNetworkAccessFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
response.setHeader("Access-Control-Allow-Origin", "https://your-site.com");
response.setHeader("Access-Control-Allow-Private-Network", "true");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "*");
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
chain.doFilter(req, res);
}
}
하지만 이 방법은 임시방편일 뿐이다. Chrome 정책이 바뀌면 또 대응해야 하고, localhost 구조 자체의 불안정성이 남아있다.
SPA vs SSR에서의 PNA 차이
PNA는 브라우저의 보안 정책이므로, API 호출 주체가 서버냐 브라우저냐에 따라 적용 여부가 달라진다.
| PNA 영향 | API 호출 주체 | |
|---|---|---|
| SPA | ❌ 받음 | 브라우저 |
| SSR | ✅ 안 받음 | 서버 |
| SSR + Client fetch | ❌ 받음 | 브라우저 |
SSR이라도 hydration 이후 클라이언트 사이드에서 fetch를 호출하면 동일하게 PNA가 적용된다.
// ✅ 서버에서 실행 → PNA 없음
export async function getServerSideProps() {
const data = await fetch('http://localhost:3000/api')
}
// ❌ 브라우저에서 실행 → PNA 뜸
useEffect(() => {
fetch('http://localhost:3000/api')
}, [])
근본적인 해결책: 도메인 기반 API 서버
DNS를 거쳐 도메인으로 API를 호출하면 PNA와 Mixed Content 문제가 모두 사라진다.
❌ 지옥의 길
https://your-site.com → http://localhost:3000
PNA 문제 + Mixed Content 문제 + 브라우저마다 다른 동작
Chrome 정책 바뀔 때마다 대응 + 사용자 팝업
✅ 정신건강의 길
https://your-site.com → https://api.your-site.com
문제 없음
레이턴시 걱정은?
localhost에 비해 외부 API 서버는 네트워크 레이턴시가 추가되지만 실제로는 체감하기 어렵다.
| 환경 | 레이턴시 |
|---|---|
| localhost | ~1ms 이하 |
| 한국 리전 클라우드 | ~10~30ms |
| 해외 클라우드 | ~100~200ms |
실제 응답 시간의 병목은 네트워크가 아니라 DB 쿼리와 비즈니스 로직이다. AWS Seoul, NCP 등 한국 리전을 사용하면 사용자는 레이턴시 차이를 전혀 느끼지 못한다.
결론
| 방법 | PNA | Mixed Content | 안정성 |
|---|---|---|---|
| localhost + PNA 헤더 | 🟡 임시 해결 | 🟡 Chrome만 예외 | ❌ 불안정 |
| 도메인 기반 HTTPS API | ✅ 없음 | ✅ 없음 | ✅ 안정 |
운영 환경에서 REST API는 반드시 HTTPS 도메인으로 제공하자. localhost API 호출은 PNA, Mixed Content, 브라우저 정책 변화 등 여러 문제를 안고 있으며, 도메인 기반 API 서버로 전환하는 것이 장기적으로 가장 깔끔한 해결책이다.