이 글에서 다루는 내용

웹 사이트에서 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 서버로 전환하는 것이 장기적으로 가장 깔끔한 해결책이다.