Next.js LAN 접속과 개발 서버 Origin 허용
TL;DR
localhost로 접속하면 동작하던 로그인 화면이 LAN IP에서는 비활성 상태로 남았다.- DOM input value는 바뀌었지만 React state가 바뀌지 않았다. 앱 validation 문제가 아니라 hydration 문제였다.
- Next.js dev 서버는 허용되지 않은 origin의 dev resource 요청을 차단한다.
- LAN IP로 dev 서버를 열어야 한다면
allowedDevOrigins에 해당 host를 추가해야 한다.
같은 dev 서버를 두 주소로 열었을 때 로그인 화면이 다르게 동작했다.
http://localhost:3000/login
-> 아이디와 비밀번호 입력 후 로그인 버튼 활성
http://10.x.x.x:3000/login
-> 입력창에는 글자가 들어가지만 로그인 버튼은 비활성
로그인 버튼 조건은 단순했다.
const [loginId, setLoginId] = useState("");
const [password, setPassword] = useState("");
return (
<button type="submit" disabled={isPending || !loginId || !password}>
로그인
</button>
);
입력창의 값은 바뀌는데 버튼이 비활성이라면 validation보다 먼저 React state를 봐야 한다. DOM value가 바뀌어도 onChange가 붙지 않으면 state는 그대로다.
증상 분리
브라우저에서 값을 직접 넣고 버튼 상태를 확인했다.
{
origin: "http://10.x.x.x:3000",
inputValues: ["test", "test"],
buttonDisabled: true,
reactHydrated: false
}
결과는 명확했다.
| 항목 | 상태 |
|---|---|
| SSR HTML | 내려옴 |
| input DOM value | 변경됨 |
| React hydration | 안 붙음 |
onChange 기반 state |
변경 안 됨 |
| submit button | 비활성 유지 |
이 상태에서 폼 코드를 계속 고쳐도 해결되지 않는다. 앱 코드가 실행되기 전에 hydration이 실패한 것이다. 브라우저 콘솔에는 HMR WebSocket 오류가 있었다.
WebSocket connection to 'ws://10.x.x.x:3000/_next/webpack-hmr?id=...' failed:
Error during WebSocket handshake: net::ERR_INVALID_HTTP_RESPONSE
dev 서버 로그에는 원인이 더 직접적으로 남았다.
Blocked cross-origin request to Next.js dev resource /_next/webpack-hmr from "10.x.x.x".
Cross-origin access to Next.js dev resources is blocked by default for safety.
allowedDevOrigins
설치된 Next 16.2.9 문서의 allowedDevOrigins는 dev 서버를 요청할 수 있는 추가 origin을 설정하는 옵션이다.
module.exports = {
allowedDevOrigins: ["local-origin.dev", "*.local-origin.dev"],
};
이 설정은 일반적인 API CORS 설정이 아니다. development mode에서 Next dev resource에 접근할 수 있는 origin을 여는 설정이다. 설치본 소스에서도 차단 대상은 dev resource 경로에 한정되어 있다.
/_next/*
/__nextjs*
일부 정적 미디어 경로는 제외된다. 핵심은 application route가 아니라 dev 서버가 제공하는 개발용 리소스라는 점이다.
WebSocket 차단 경로
설치본 Next 소스에서는 같은 차단 함수가 두 경로에서 호출된다.
// 일반 요청
blockCrossSiteDEV(req, res, allowedDevOrigins, opts.hostname);
// WebSocket upgrade
blockCrossSiteDEV(req, socket, allowedDevOrigins, opts.hostname);
일반 요청에서는 res가 HTTP ServerResponse다. 상태 코드를 세팅하고 응답을 끝낼 수 있다. WebSocket upgrade 경로에서는 두 번째 인자가 raw socket이다. 이 객체에는 HTTP status code를 세팅하는 인터페이스가 없다. 차단 함수가 socket에 Unauthorized를 쓰고 닫으면 브라우저는 정상적인 HTTP handshake 응답을 받지 못한다. 그래서 콘솔에는 403보다 ERR_INVALID_HTTP_RESPONSE가 더 자연스럽게 나타난다. 브라우저는 101 Switching Protocols를 기대했지만, HTTP 응답 형태가 아닌 바이트를 받은 것이다.
동적 LAN host 설정
LAN IP를 직접 배열에 적으면 네트워크가 바뀔 때마다 설정을 고쳐야 한다. next.config.ts는 dev 서버 시작 시점에 Node.js에서 실행되므로 현재 머신의 IPv4 주소를 읽어 목록을 만들 수 있다.
import os from "node:os";
import type { NextConfig } from "next";
function lanHosts(): string[] { return Object.values(os.networkInterfaces()) .flat() .filter((network) => network?.family === "IPv4" && !network.internal) .map((network) => network.address); } const nextConfig: NextConfig = { allowedDevOrigins: lanHosts(), };
export default nextConfig;
이 설정은 dev 서버가 시작될 때 현재 LAN IP를 읽는다. IP가 바뀌면 dev 서버를 재시작해야 한다.
| 접속 방식 | 설정 필요 |
|---|---|
localhost:3000 |
보통 불필요 |
127.0.0.1:3000 |
보통 불필요 |
10.x.x.x:3000 |
allowedDevOrigins 필요 |
my-device.local:3000 |
해당 host를 별도 추가 |
| tunnel domain | 해당 도메인을 별도 추가 |
허용 목록에는 이 머신이 가진 LAN IP만 넣는다. 임의의 외부 origin을 넓게 열지 않는 편이 낫다.
검증 순서
설정 후 같은 LAN IP에서 다시 확인했다.
{
origin: "http://10.x.x.x:3000",
hydrated: true,
buttonDisabled: false,
buttonLabel: "로그인"
}
dev 서버 로그의 cross-origin 차단 경고도 사라졌다. 이 문제를 다시 만나면 순서는 다음이 맞다.
| 순서 | 확인 |
|---|---|
| 1 | dev 서버 로그에서 cross-origin dev resource 차단 여부 확인 |
| 2 | Network 패널에서 /_next/*, WebSocket 상태 확인 |
| 3 | hydration 여부 확인 |
| 4 | React state와 validation 확인 |
입력값이 안 들어가는 것처럼 보여도 항상 폼 문제가 아니다. hydration이 붙지 않으면 이벤트 핸들러가 없고, 이벤트 핸들러가 없으면 state도 바뀌지 않는다.
정리
LAN IP 접속에서 로그인 버튼이 비활성으로 남은 원인은 폼 validation이 아니라 Next.js dev 서버의 origin 차단이었다. allowedDevOrigins는 production 인증이나 API CORS 설정이 아니다. 개발 중 dev resource를 어떤 origin에서 요청할 수 있게 할지 정하는 설정이다. 개발 서버를 다른 기기에서 열어 테스트한다면 다음을 확인한다.
- 접속 host가 dev 서버가 허용한 origin인지.
/_next/webpack-hmrWebSocket이 성공하는지.- hydration이 붙어 React 이벤트가 실행되는지.
앱 코드는 이 세 조건이 통과한 뒤에야 의심할 수 있다.