로컬 개발 서버 노출 확인: lsof로 0.0.0.0 바인드를 찾고 127.0.0.1로 닫는 법
localhost로 띄운 개발 서버도 바인드 주소에 따라 같은 와이파이의 다른 기기에서 열립니다. 바인드 주소를 확인하는 명령, 고치는 코드, 127.0.0.1로도 막히지 않는 경로를 함께 적었습니다.
지난 7월 영상 렌더 파이프라인을 점검하다가, 제 컴퓨터에 띄워둔 개발 서버 3개(영상 편집 UI, 카드뉴스 UI, 유튜브 인증 서버)가 전부 0.0.0.0에 바인드돼 있는 걸 찾았습니다. 셋 다 인증이 없었습니다. 주소창에는 localhost가 찍혔지만, 같은 와이파이에 붙은 기기라면 제 컴퓨터 IP와 포트만 알면 그대로 열 수 있는 상태였습니다.
답부터 적으면, 서버를 띄울 때 주소를 안 적으면 대부분 모든 네트워크에서 요청을 받습니다. lsof로 바인드 주소를 확인하고, listen(PORT, '127.0.0.1')처럼 주소를 적어서 닫으면 됩니다. 다만 이것만으로는 브라우저를 거쳐 들어오는 경로가 남기 때문에, 시크릿을 응답에 싣지 않는 것까지 같이 해야 합니다.
실제로 누가 접근했다는 흔적은 없습니다. 아래는 “열려 있었다”까지의 이야기입니다.

localhost로 접속되는데 왜 다른 기기에서도 열리나
브라우저에서 localhost:3000으로 접속되는 것과, 서버가 어느 주소에서 요청을 받는지는 서로 다른 문제입니다. 서버가 0.0.0.0에 바인드돼 있으면 “이 컴퓨터의 모든 네트워크 인터페이스”에서 요청을 받습니다. 와이파이도 그중 하나입니다.
기본값이 이렇게 잡히는 경우가 많습니다.
- Node.js ·
server.listen(PORT)처럼 host를 생략하면, IPv6를 쓸 수 있을 때는::, 아니면0.0.0.0에서 연결을 받습니다. 대부분의 운영체제에서는::로 열면 IPv40.0.0.0도 같이 받는다고 Node.js 공식 문서에 적혀 있습니다. Express의app.listen(PORT)도 여기에 해당합니다. - Vite · 기본값은
localhost라서 처음에는 닫혀 있습니다. 그런데--host나--host 0.0.0.0을 붙이거나 설정에서server.host: true로 두면 LAN과 공인 주소를 포함한 모든 주소에서 받는다고 Vite 서버 옵션 문서에 나와 있습니다(2026년 10월 확인). 폰에서 화면을 보려고--host를 켰다가 그대로 두는 경우가 흔합니다.
제 서버에서 열려 있던 것
세 서버 중 영상 편집 UI가 제일 문제였습니다. 설정 화면이 현재 값을 채워 보여주려고 부르는 /api/settings API가 유튜브 OAuth의 Client Secret을 평문 JSON 그대로 돌려주고 있었습니다. 같은 설정 파일에 Pexels 키와 Google API 키도 들어 있었습니다. 개발할 때 흔히 짜는 편의 코드라서, “로컬은 나만 본다”는 가정 안에서는 문제가 없어 보였습니다.
들어올 수 있는 길은 두 가지였습니다.
- (a) 같은 와이파이에서 직접 호출 · 다른 기기가 제 IP와 포트로 API를 바로 부릅니다. 카페나 코워킹 스페이스 와이파이라면 옆자리 사람도 해당됩니다.
- (b) 브라우저를 거친 호출(CSRF) · 제 브라우저에 열린 악성 페이지가 로컬 주소로 요청을 보냅니다. 이 경우는 상대가 같은 네트워크에 있을 필요가 없습니다.
이 상태로 할 수 있었던 건 시크릿 조회, 설정 변조, 렌더 작업 임의 실행 세 가지였습니다.
바인드 주소 확인하는 법 (lsof, netstat, 다른 기기)
가장 확실한 방법은 다른 기기에서 직접 열어보는 것입니다. 같은 와이파이에 붙은 폰 브라우저에 컴퓨터 IP와 포트를 칩니다.
# 내 로컬 IP 확인 (macOS)
ipconfig getifaddr en0
# → 다른 기기 브라우저에서 http://192.168.0.x:3000 접속 시도
화면이 뜨면 네트워크에 열려 있는 것입니다. 컴퓨터 안에서 바인드 주소를 직접 볼 수도 있습니다.
lsof -i :3000
# 또는
netstat -an | grep 3000
LISTEN 줄의 주소는 이렇게 읽습니다.
| 보이는 주소 | 뜻 | 같은 와이파이에서 |
|---|---|---|
127.0.0.1:3000 | 루프백만 | 안 열림 |
*:3000 | 모든 인터페이스 | 열림 |
0.0.0.0:3000 | 모든 인터페이스 | 열림 |
lsof가 주소 대신 localhost:hbci처럼 이름으로 보여줄 때가 있습니다. 이럴 때는 lsof -nP -iTCP:3000 -sTCP:LISTEN으로 숫자 그대로 보면 헷갈리지 않습니다. 포트 번호를 모르면 lsof -nP -iTCP -sTCP:LISTEN으로 지금 열려 있는 포트를 전부 볼 수 있습니다.
127.0.0.1로 바인드 주소 고치기
고친 건 2026년 7월 5일입니다. 영상 편집 UI·유튜브 인증 서버와 카드뉴스 UI를 커밋 두 개로 나눠 고쳤습니다. 첫째는 세 서버 모두 바인드 주소를 적은 것입니다.
// before
server.listen(PORT)
// after
server.listen(PORT, '127.0.0.1')
이러면 서버가 루프백에서만 요청을 받아서 (a) 경로가 막힙니다. Express의 app.listen도 같은 자리에 주소를 적으면 됩니다. Vite라면 --host를 빼거나 server.host를 localhost로 두면 됩니다.
둘째는 영상 편집 UI의 설정 응답에서 시크릿을 가린 것입니다. GET은 값이 저장돼 있으면 ••••••로 바꿔서 보내고, POST로 ••••••가 그대로 돌아오면 기존 값을 유지하게 했습니다. 설정 화면은 “저장돼 있다”는 것만 알면 되고 실제 값은 필요 없습니다.
const SECRET_KEYS = ['youtubeClientSecret', 'pexelsKey', 'googleApiKey'];
const MASK = '••••••';
// GET: 저장된 시크릿은 마스크로 바꿔서 응답
const masked = { ...readConfig() };
for (const k of SECRET_KEYS) if (masked[k]) masked[k] = MASK;
// POST: 마스크가 그대로 오면 기존 값 유지
for (const k of SECRET_KEYS) if (incoming[k] === MASK) incoming[k] = existing[k] ?? '';
셋째로 영상 편집 UI와 카드뉴스 UI에 Host 헤더 검사를 넣었습니다. 요청의 Host가 localhost, 127.0.0.1, [::1]이 아니면 403을 돌려줍니다. 외부 도메인이 내 로컬 주소를 가리키게 만드는 DNS 리바인딩을 막는 용도입니다.
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
if (!LOCAL_HOST_RE.test(String(req.headers.host || ''))) {
return send(res, 403, { error: 'forbidden host' });
}
127.0.0.1 바인드로 막히지 않는 것
127.0.0.1 바인드는 다른 기기가 직접 부르는 (a)만 막습니다. 요청이 내 브라우저에서 출발하는 (b)는 출발지가 내 컴퓨터라서 그대로 닿습니다. Host 검사는 그중 DNS 리바인딩을 막지만, 악성 페이지가 http://localhost:3000으로 바로 보내는 요청은 Host가 정상이라 통과합니다. 그래서 시크릿을 응답에 싣지 않는 것이 바인드 설정과 별개로 필요합니다. 상태를 바꾸는 API가 있다면 인증이나 CSRF 토큰도 같이 고려해야 합니다.
개발 서버를 띄울 때 확인하는 순서는 이렇습니다.
lsof -nP -iTCP -sTCP:LISTEN으로 지금 열린 포트와 주소를 봅니다.*나0.0.0.0으로 열린 게 있으면 폰에서IP:포트로 열어봅니다.- 의도한 게 아니면
listen(PORT, '127.0.0.1')로 주소를 적습니다. - 설정·인증 API가 시크릿을 응답 본문에 담는지 봅니다.
일부러 바깥에 열어야 하는 서버라면 포트를 와이파이에 그냥 열어두기보다 Cloudflare Tunnel로 집 서버를 외부에 붙이는 방법이나 Tailscale로 내 기기끼리만 묶는 방법처럼 들어오는 길을 따로 정해두는 편이 관리하기 쉽습니다.
자주 묻는 질문
localhost로 접속되는데 왜 다른 기기에서도 열리나요?
브라우저에 localhost로 접속되는 것과 서버가 어느 주소에 바인드됐는지는 별개입니다. 서버가 0.0.0.0에 바인드돼 있으면 모든 네트워크 인터페이스에서 수신하기 때문에, 같은 와이파이의 다른 기기가 내 IP:포트로 직접 접근할 수 있습니다.
내 개발 서버가 어느 주소에 바인드됐는지 어떻게 확인하나요?
macOS·리눅스에서는 lsof -i :포트 또는 netstat -an 으로 LISTEN 상태의 바인드 주소를 확인할 수 있습니다. 127.0.0.1로 나오면 내 컴퓨터에서만, 0.0.0.0(또는 *)으로 나오면 네트워크 전체에서 접근 가능합니다. 다른 기기 브라우저에서 내 IP:포트를 직접 열어보는 것도 확실한 방법입니다.
127.0.0.1에만 바인드하면 CSRF도 막히나요?
아닙니다. 127.0.0.1 바인드는 같은 네트워크의 다른 기기가 직접 호출하는 경로를 막습니다. 하지만 내 브라우저에 열어둔 악성 페이지가 로컬 주소를 호출하는 CSRF 경로는 여전히 남으므로, 인증·CSRF 토큰·시크릿 마스킹을 함께 적용해야 합니다.
개발 서버 API가 시크릿을 응답에 담아도 되나요?
개발 환경이라도 시크릿을 응답 본문으로 내보내지 않는 것이 원칙입니다. 설정 조회 API는 저장 여부만 알려주고 값 자체는 마스킹해서 반환해야 합니다. 로컬은 안전하다는 가정이 깨지는 순간 그 응답이 그대로 노출되기 때문입니다.