RSS듀오랩스
웹 인프라

Next.js 프리페치와 fail2ban: 앱이 자기 IP를 차단시키는 경로

작성자
듀오랩스 대표·9분 읽기

오후에 사내에서 서비스가 통째로 안 열린다는 이야기를 들었습니다. 한 곳이 아니라 여러 곳이었습니다. 업무 시스템 데모도, 웹 데스크톱도, 파일 전송망도 전부 응답이 없었습니다. 그런데 같은 주소가 바깥에서는 멀쩡히 열렸습니다.

응답이 없는 모양도 평소와 달랐습니다. 404도 500도 아니고 연결 자체가 되지 않았습니다.

$ curl -s -o /dev/null -w "%{http_code}\n" https://os.duolabs.co.kr/
000

000은 서버가 나쁜 응답을 줬다는 뜻이 아닙니다. 대화를 시작조차 하지 않았다는 뜻입니다. 우리 서비스들은 서울에 둔 엣지 서버 한 대를 거쳐 오리진으로 들어가는데, 그 엣지가 우리 사무실 IP를 방화벽에서 돌려보내고 있었습니다. fail2ban이 걸어 둔 차단이었습니다.

시각만 보고 범인을 잘못 지목한 20분

저는 곧바로 제가 원인이라고 판단했습니다. 그 직전에 형제 서비스 열한 곳의 응답 헤더를 확인한다고 짧은 시간에 요청을 스무 번쯤 보냈기 때문입니다. 시각이 맞아떨어졌고, 저는 그걸 인과로 읽었습니다. 사람에게 보고까지 했습니다.

틀렸습니다. 차단 규칙을 열어 보니 기준은 분당 수백 건이 아니라 네 자리였습니다. 요청 스무 번으로는 근처에도 못 갑니다.

로그를 봤어야 했습니다. 여는 데 1분도 안 걸리는 일이었는데, 짐작이 그럴듯해서 건너뛰었습니다. 이 글에서 기술적으로 가장 쓸모 있는 부분은 뒤에 나오지만, 저에게 가장 값비쌌던 교훈은 여기입니다. 시간 순서는 인과가 아닙니다.

1분에 1218건, 그중 1202건이 프리페치

엣지의 접근 로그를 IP와 분 단위로 세어 봤습니다.

14:04  296
14:09  1218   ← 차단
14:10  28
14:15  347

그리고 그 1분을 갈라 봤습니다.

구분 건수
브라우저가 만든 요청 1218
curl · 헤드리스 도구 0
대상 호스트 업무 시스템 데모 1217, 나머지 1
Next 링크 프리페치 1202
그 외 일반 요청 16

사람이 화면을 보는 동안 브라우저가 혼자 1202건을 보냈습니다. 그날 우리 IP를 뺀 전체 최고치는 분당 121건이었습니다. 열 배 차이입니다.

링크 하나가 요청 하나가 아닌 이유

원인은 몇 달 전 제가 직접 넣은 코드였습니다. 업무 시스템 데모는 좌측 메뉴가 아코디언으로 전부 펼쳐져 있고 스크롤이 깁니다. Next.js의 기본 프리페치는 뷰포트에 들어온 링크만 미리 받기 때문에, 아래쪽 메뉴를 누르면 위쪽 메뉴보다 한 박자 늦게 열렸습니다. 편차를 없애려고 셸이 뜰 때 모든 라우트를 한 번에 데우게 했습니다.

useEffect(() => {
  // 현재 페이지의 초기 데이터 로드를 방해하지 않도록 유휴 시점으로 미룬다.
  const warm = () => ROUTES.forEach((href) => router.prefetch(href));
  const w = window as IdleWindow;
  if (w.requestIdleCallback) {
    const id = w.requestIdleCallback(warm, { timeout: 2000 });
    return () => w.cancelIdleCallback?.(id);
  }
  const t = setTimeout(warm, 300);
  return () => clearTimeout(t);
}, [router]);

유휴 시점으로 미룬 것까지는 맞았습니다. 미룬 뒤에 한 줄로 다 부른다는 것이 문제였습니다. ROUTES는 메뉴 카탈로그에서 파생되는데, 그 사이에 모듈이 늘어 지금은 106개입니다. 코드는 그대로였고 숫자만 자랐습니다.

router.prefetch()는 각 라우트의 RSC 페이로드를 서버에 요청합니다. 106개를 동시에 부르면 오리진에서 서버 렌더가 106개 동시에 돕니다. 방문자 한 명이 화면 하나를 열었을 뿐인데 말입니다.

1202를 106으로 나누면 열한 번쯤 나옵니다. 그 1분에 셸이 그만큼 다시 떴다는 뜻입니다. 웹 데스크톱에서 앱 창을 여닫으면 그때마다 새로 뜨니, 창 몇 개를 열어 본 것으로 충분했습니다.

빠르게 하려던 코드가 서비스를 닫은 자리

차단이 아팠던 이유는 한 서비스가 막혀서가 아닙니다. 그 엣지 뒤에 우리 서비스가 열 개 있습니다. 업무 시스템, 자동화, 3D, 공간정보, 웹 데스크톱, 파일 전송망, 전자서명, 모델 게이트웨이, 백업, 인증. 한 화면의 프리페치가 선을 넘자 그 열 개가 한 시간 동안 그 IP에게 전부 닫혔습니다.

차단은 IP 단위이고 서비스를 가리지 않습니다. 이건 설정 실수가 아니라 구조입니다. 문 하나를 공유하면 그 문에서 막히는 것도 공유합니다.

고객에게도 같은 일이 일어날 수 있었다는 점이 더 불편했습니다. 데모를 열심히 눌러 본 사람일수록 빨리 막힙니다. 열심히 본 손님을 먼저 쫓아내는 셈입니다.

보내는 쪽을 먼저 고칩니다

데우는 것 자체는 값어치가 있으니 없애지 않았습니다. 대신 한 줄에 쏟아붓지 않고 나눠 보냅니다.

const CHUNK = 6;
const GAP_MS = 200;
let i = 0;
let timer = 0;
let idle = 0;

const step = () => {
  for (const href of ROUTES.slice(i, i + CHUNK)) router.prefetch(href);
  i += CHUNK;
  if (i < ROUTES.length) timer = window.setTimeout(step, GAP_MS);
};

const w = window as IdleWindow;
if (w.requestIdleCallback) idle = w.requestIdleCallback(step, { timeout: 2000 });
else timer = window.setTimeout(step, 300);

return () => {
  if (idle) w.cancelIdleCallback?.(idle);
  if (timer) clearTimeout(timer);
};

6개씩 200ms 간격이면 106개가 3.6초에 끝납니다. 데워지는 결과는 같습니다. 순서가 카탈로그 순서라, 먼저 눌릴 위쪽 항목이 먼저 준비된다는 점은 오히려 나아졌습니다. 사용자가 메뉴를 읽고 마우스를 옮기는 데 3.6초보다 오래 걸립니다.

형제 서비스에도 같은 코드가 있었습니다. 그쪽은 라우트가 62개라 아직 안 걸렸을 뿐이라서, 터지기 전에 같이 고쳤습니다.

헤더로 봐주지 않고 선을 올린 이유

엣지 쪽도 함께 손봤습니다. 기준을 정할 때 참고한 값이 예전 측정치였고, 그 사이 우리 앱이 그 값을 스스로 넘도록 자랐기 때문입니다. 바깥의 정상 트래픽(분당 121건)과는 여전히 열 배 이상 여유가 있는 자리로 선을 옮겼습니다.

프리페치 요청을 아예 세지 않는 길도 있었습니다. Next.js는 프리페치에 전용 헤더를 붙이니, 그 헤더가 있는 줄을 규칙에서 빼면 됩니다. 한 줄이면 끝나고 오늘 문제도 정확히 사라집니다.

안 골랐습니다. 그 헤더는 누구나 붙일 수 있습니다. 폭주 방지 규칙을 통과하는 방법을 헤더 한 줄로 공개하는 셈이고, 그러면 남는 것은 규칙이 아니라 규칙의 모양뿐입니다. 세되 선을 올리는 쪽을 골랐습니다. 검증할 수 없는 자기 신고를 근거로 예외를 주지 않는다는 원칙이, 이 경우에는 조금 둔한 규칙보다 낫다고 봤습니다.

SSH는 처음부터 열려 있었습니다

차단을 푸는 과정에서 하나 더 배웠습니다. 엣지에 접속하려는데 이렇게 나왔습니다.

root@...: Permission denied (publickey).

저는 이걸 "SSH까지 막혔다"로 읽었습니다. 또 틀렸습니다. 이 메시지는 TCP 연결이 맺어지고 인증까지 갔다는 뜻입니다. 정말 막혔다면 Connection refused가 나왔을 겁니다. 차단 규칙이 http와 https 포트에만 걸려 있었기 때문에 SSH는 내내 열려 있었고, 실패한 이유는 그냥 기본 키가 아닌 전용 키를 써야 했던 것뿐이었습니다.

거절 메시지는 종류마다 다른 사실을 말합니다. Connection refused는 문 앞에서 돌아선 것이고, Permission denied는 문 안에서 거절당한 것입니다. 이 둘을 같은 것으로 읽으면 엉뚱한 곳을 고치게 됩니다.

남은 것: 창을 여러 개 여는 화면

아직 다 풀지 못한 부분이 있습니다. 웹 데스크톱에서는 앱 창을 열 때마다 해당 서비스가 iframe으로 새로 뜨고, 그때마다 데우기가 처음부터 다시 돕니다. 창을 네 개 열면 네 번입니다. 나눠 보내기로 순간 최대치는 낮아졌지만 총량은 그대로입니다.

세션 단위로 한 번만 데우게 하거나(같은 탭에서 다시 뜬 셸은 건너뛰기), 데울 목록을 상위 20개로 줄이는 방법을 보고 있습니다. 어느 쪽이 나은지는 아직 결론이 없습니다. 총량이 실제로 문제가 되는지부터 며칠 재 보려고 합니다.

참고: Next.js 프리페치 문서 · fail2ban 매뉴얼

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.