게스트 로그인을 지우고 status 페이지를 만들었다
대시보드를 보여주려고 게스트 권한 분기를 늘리다가, 보여줄 건 "살아있나" 하나였다는 걸 알고 공개 status 페이지를 따로 만든 이야기
#모니터링 #헬스체크 #React #Express #운영
내 서버 한 대에는 사이트가 열 개 올라가 있다. 포트폴리오, 블로그, 커플앱, 여행 플래너, 코인 트래커, 게임 같은 것들이다. 전부 PM2로 띄우고, Apache가 서브도메인별로 포트를 나눠준다. 이걸 관리하려고 6월에 모니터링 서버를 하나 만들었다. CPU, 메모리, 디스크 그래프에 프로세스 목록, /var/log 로그 뷰어, 웹 터미널, 그리고 HTTP 헬스체크까지 넣었다. 문제는 이게 전부 로그인 뒤에 있다는 거였다. 이 글에서는 게스트 로그인을 걷어내고 공개 status 페이지로 바꾸면서 정한 것들을 정리해본다. 게스트 로그인이 점점 지저분해졌다 로그인 없이도 볼 수 있게 처음엔 게스트 로그인을 만들었다. 버튼 하나 누르면 읽기 전용으로 들어오는 방식이다. 근데 읽기 전용이라고 다 보여줄 수는 없었다. 로그 뷰어엔 서버 경로가, 방문 통계엔 실제 URL이 나오고, 터미널은 말할 것도 없다. 그래서 화면마다 게스트면 가리는 분기가 붙었다. 서버 requireNonGuest 미들웨어 sysinfo, analytics, terminal, services 마스킹 분기 프론트 isGuest, GuestBlock, GuestBlur 예를 들어 방문 통계의 Top 페이지는 프론트에서 블러를 걸고, 백엔드에서도 topPages: [] 로 비워서 내려보내는 식으로 두 겹으로 막았다. 한쪽만 막으면 개발자 도구로 응답을 열어보는 순간 그대로 보이기 때문이다. 이런 식이다 보니 기능을 하나 추가할 때마다 "이건 게스트한테 보여도 되나"를 먼저 따져야 했다. 돌아보면 게스트한테 보여줄 만한 건 결국 서비스들이 살아있는지 , 그거 하나였다. 그래서 권한 분기를 더 정교하게 만드는 대신 로그인 없이 보는 화면을 아예 따로 만들기로 했다. status.claude.com 같은 status 페이지 형태다. 재료는 이미 쌓여 있었다 새로 수집할 건 없었다. 헬스체크 스케줄러가 6월 6일부터 health_history 테이블에 결과를 넣고 있었고, 석 달 사이에 25만 행이 넘게 쌓여 있었다. 이 글을 다시 고치는 9월 15일에 세어보니 271,360행이다. 스케줄러는 60초마다 깨어나서 체크할 때가 된 엔드포인트만 고른다. 간격은 엔드포인트마다 달라서 커플앱과 여행 API는 3분, 나머지는 5분이다. SELECT * FROM health_endpoints WHERE last_checked IS NULL OR last_checked = DATE_SUB(NOW(), INTERVAL interval_minutes MINUTE) 가동률은 이걸 서비스별, 날짜별로 세기만 하면 된다. SELECT endpoint_id, DATE_FORMAT(checked_at, '%Y-%m-%d') AS day, COUNT(*) AS total, SUM(is_healthy = 1) AS ok FROM health_history WHERE checked_at = DATE_SUB(CURDATE(), INTERVAL 89 DAY) GROUP BY endpoint_id, day 하루를 무슨 색으로 칠할까 90일짜리 막대를 그리려면 하루를 한 칸으로 요약해야 한다. 처음엔 99.9% 이상을 초록으로 잡았는데, 계산해보니 이러면 안 됐다. 5분 간격이면 하루에 288번 체크한다. 여기서 딱 한 번만 실패해도 99.65% 다. 99.9% 기준이면 타임아웃 한 번에 그날이 노란색이 된다. 실제 실패 기록도 대부분 10초 타임아웃 한 번 나고 다음 체크에서 바로 돌아온 경우라, 막대가 노란 점투성이가 된다. function dayStatus(uptime, total) { if (!total) return 'nodata'; if (uptime = 99.5) return 'operational'; if (uptime = 95) return 'degraded'; if (uptime 0) return 'partial'; return 'down'; } 그래서 99.5%로 내렸다. 5분 간격 서비스 기준으로 하루 한 번 삐끗한 건 초록, 두 번부터 노란색이다. 실제로 8월 14일 블로그는 285번 중 2번 실패해서 99.30%, 노란색으로 찍혀 있다. 솔직히 이 기준이 딱 맞는지는 모르겠다. 3분 간격인 커플앱은 하루 480번 체크라서 똑같이 두 번 실패해도 99.58%로 초록이다. 체크 간격이 다른 서비스를 같은 퍼센트로 자르는 거라 교과서적으로는 안 예쁘다. 지금은 서비스가 열 개뿐이라 그냥 두고 있다. 실패 기록을 장애 단위로 묶기 막대만 있으면 "노란색인데 그날 뭐가 있었냐"가 궁금해진다. 그래서 칸에 마우스를 올리면(모바일은 탭) 그날의 장애 구간이 나오게 했다. 실패 행을 그대로 나열하면 같은 장애가 여러 줄로 나온다. 그래서 같은 사유의 실패가 20분 안에 이어지면 한 건 으로 묶었다. 체크 간격이 5분이니 연속 실패 서너 번까지는 같은 장애로 본다. const INCIDENT_GAP_SEC = 20 * 60; for (const f of failures) { const reason = failureReason(f.status_code, f.error_message); const last = incidents[incidents.length - 1]; if (last last.reason === reason f.ts - last.endTs = INCIDENT_GAP_SEC) { last.end = f.time; last.endTs = f.ts; last.count += 1; continue; } incidents.push({ start: f.time, end: f.time, endTs: f.ts, count: 1, reason }); } 90일 동안 실패가 31건뿐이라 이걸 별도 API로 떼지 않고 /api/status 응답에 같이 실었다. 칸에 마우스를 올릴 때마다 요청을 보낼 필요가 없다. 공개 페이지에 에러 원문을 내보내도 되나 여기서 한 번 멈췄다. health_history 에 저장된 에러는 이런 식이다. getaddrinfo EAI_AGAIN blog.jaeyonging.com timeout of 10000ms exceeded connect ECONNREFUSED 127.0.0.1:59999 헬스체크 대상이 내부 주소일 수도 있고, 에러 원문엔 호스트나 포트가 그대로 들어간다. 로그인한 나한테는 유용하지만 공개 페이지에 내놓을 정보는 아니다. 그래서 공개 응답에는 분류한 사유만 보낸다. function failureReason(statusCode, errorMessage) { const err = (errorMessage || '').toLowerCase(); if (statusCode == null) { if (err.includes('timeout') || err.includes('etimedout')) return '응답 시간 초과'; if (err.includes('eai_again') || err.includes('enotfound')) return 'DNS 조회 실패'; if (err.includes('econnrefused')) return '연결 거부됨'; if (err.includes('econnreset') || err.includes('socket hang up')) return '연결 끊김'; if (err.includes('cert') || err.includes('ssl')) return '인증서 오류'; return '서버 응답 없음'; } if (statusCode = 500) return `서버 오류 (${statusCode})`; if (statusCode = 400) return `요청 오류 (${statusCode})`; return `예상과 다른 응답 (${statusCode})`; } 원문은 로그인한 경우에만 내려준다. 같은 데이터로 두 벌을 만들어두고, 토큰이 유효하면 원문이 든 쪽을, 아니면 정리한 쪽을 준다. router.get('/', async (req, res) = { if (!cache.full || Date.now() - cache.at = CACHE_TTL) { const full = await buildStatus(); cache = { at: Date.now(), full, publicData: sanitize(full) }; } res.json(isAuthenticated(req) ? cache.full : cache.publicData); }); 서비스 이름 아래에는 도메인도 같이 보여준다. "여행 API"라고만 쓰면 나도 어느 프로젝트인지 헷갈렸기 때문이다. 이것도 localhost 나 사설 IP 대역이면 null 로 걸러서 내부 주소는 안 나가게 했다. 로그인 없는 API는 캐시부터 공개 엔드포인트는 누가 얼마나 부를지 모른다. 25만 행이 넘는 이력을 매번 GROUP BY 하는 건 부담이라 30초 메모리 캐시를 붙였다. 프론트도 30초마다 폴링하니 갱신 주기와 맞다. 방금 재보니 캐시가 비었을 때 첫 요청이 0.75초, 그다음부터는 0.035초였다. $ curl -so /dev/null -w '%{http_code} %{time_total}s %{size_download}B\n' https://monitor.jaeyonging.com/api/status 200 0.745856s 87067B 200 0.037002s 87067B 200 0.034586s 87067B Redis 같은 걸 둘 필요는 없었다. 서버도 하나, 프로세스도 하나라 모듈 변수 하나면 충분하다. 화면 맨 위에 전체 상태 배너, 아래로 서비스별 90일 막대와 가동률, 현재 응답속도를 둔다. 폭에 따라 보여주는 일수를 줄였는데, 480px 미만이면 30일, 768px 미만이면 45일, 1100px 미만이면 60일이다. 90칸을 휴대폰 폭에 넣으면 한 칸이 2px 남짓이라 손가락으로 누를 수가 없다. 터치 기기에는 hover가 없어서 칸을 탭하면 툴팁이 열리고, 다시 탭하면 닫히게 했다. 그리고 게스트 로그인을 지웠다 status 페이지가 생긴 뒤 게스트 관련 코드를 전부 지웠다. POST /api/auth/guest , requireNonGuest , 서버 쪽 마스킹 분기 네 군데, 프론트의 isGuest , GuestBlock , GuestBlur 까지. 관리 화면은 /dashboard 아래로 옮기고 / 는 status 페이지가 됐다. 지우고 나니 기능을 추가할 때 권한 생각을 안 해도 된다. 로그인하는 사람은 나 하나고, 나머지는 status 페이지만 본다. 남는 생각 한 화면을 여러 권한에 맞춰 조금씩 가리는 것보다, 보여줄 것만 담은 화면을 따로 만드는 쪽 이 훨씬 단순했다. 게스트 모드는 "대시보드를 보여주자"에서 출발했는데, 정작 필요했던 건 대시보드가 아니었다. 그리고 이 페이지를 만들면서 쌓여 있던 기록을 처음으로 날짜별로 늘어놓고 봤다. 기록은 3달치가 있었는데, 정작 서비스가 죽었을 때 나한테 알려주는 경로는 없었다. 그 이야기는 다음 글 에 정리했다.