SSH journalctl 대신 어드민 페이지에서 실시간 로그 — Server-Sent Events 인증/재연결 + JVM/HTTP/DB 메트릭 — POP-SPOT v2.10 (+v2.10.1)
운영 모니터링을 SSH · xshell 에서 크롬 어드민 한 곳으로. JVM Heap/Thread + HTTP 요청수/p95/5xx + DB 접속 풀 + 자동수집 4종 메트릭 + LOGS 탭 (SSE, 정규식 필터, 다운로드). v2.10.1 은 배포 절차 5건 정정.
서버 상태를 볼 때마다 SSH 로 들어가서 journalctl -f 를 먹이던 관습을 어드민 페이지로 옮긴 버전이다. CPU·메모리 라인 차트만 있던 대시보드를 JVM·HTTP·DB·자동수집까지 확장.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| SSE | Server-Sent Events. 서버가 한 쪽으로 계속 데이터를 흠려보내는 단방향 스트림. 반향이 필요 없는 로그에 적합 |
| EventSource | 브라우저 표준 SSE 클라이언트. 자동 재연결 기능 내장, 단 헤더 조작 불가능 |
| keepalive | 조용하지만 고객이 아직 있다는 걸 알려주는 주기적 신호. 프록시/터널이 닫아버리는 걸 막음 |
| p95 | 추출된 응답 시간의 95 번째 백분위. "100 명 중 95 번째까지는 이 시간 안에 답함" |
| journalctl -f | systemd 의 로그를 실시간으로 계속 보여주는 리눅스 명령 |
무엇이 바뀌었나
| 항목 | v2.9 | v2.10 |
|---|---|---|
| 메트릭 | CPU + 메모리 (라인 차트) | • JVM Heap/Thread, HTTP 요청수/p95/5xx, DB 커넥션 풀, 자동수집 4종 |
| 새 메트릭 추가 | 컨트롤러 직접 수정 | "메트릭 스냅샷 제공자" 인터페이스 구현 클래스 추가 (자동 합성) |
| 실시간 로그 | SSH 로 <code>journalctl -f</code> | 어드민 LOGS 탭 (정규식 필터 · 일시정지 · 다운로드 · 색상화) |
| SSE 인증 | — | SSE 경로 한정 <code>?token=</code> 쿼리 폴백 (EventSource 이 헤더 못 보냄) |
| Keepalive | — | 30초마다 코멘트 프레임 프록시 잘림 방지 |
| 재연결 | — | 1→2→4초 지수 backoff |
왜 이렇게 했음
운영 편의 — SSH/xshell 로 들어가는 걸 하루 수십 번 하면 시간 소모가 크다. 특히 매일 새벽 04:00 자동수집이 잘 돌아갔는지 확인하는 일과 같은 경우. 어드민 안에서 바로 볼 수 있게 만들면 SSH 의 의존이 줄어들고, 경우에 따라서는 서버에 접속할 수 없는 상황에서도 확인 가능.
SSE 를 선택한 이유 — WebSocket 은 양방향이라 로그 같은 "서버에서 몇 시간 보내기" 용도에는 오버키. SSE 는 HTTP 그대로라 프록시 설정·방화벽·CORS 고민이 적다. 자동 재연결과 Last-Event-ID 까지 표준으로 가짐.
EventSource 의 헤더 제약 — 일반 fetch 처럼 Authorization 헤더를 쓸 수 없음. 그래서 SSE 전용 경로 (/api/admin/logs/stream) 에만 한정해서 ?token=... 쿼리를 세션 슌으로 허용. 다른 경로는 여전히 헤더만.
메트릭 제공자 인터페이스 — "DB 커넥션 풀 메트릭 추가" 같은 일은 컨트롤러를 고치는 게 아니라 자신의 스냅샷을 반환하는 구현 클래스를 쓰면 되도록. 스프링이 자동으로 수집해서 합쳐준다.
코드로는 어떻게 (필요한 부분만)
파일: popspot-backend/src/main/java/com/popspot/admin/metrics/MetricSnapshotProvider.java (인터페이스)
// v2.10 — 메트릭 제공자 계약 설계. 새 메트릭은 구현체 주입만 추가 — 컨트롤러 변경 없음
public interface MetricSnapshotProvider {
Map<String, Object> snapshot();
// ^^^^^^^^^^ 메트릭 스냅샷 반환. 키-값 쌍으로 자유롭게 구성
// 해설: Map<String, Object> 는 JSON 직렬화 시 자연스럽게 키-값 객체로 변환됨
}
// v2.10 — 새 메트릭 추가 예시: DB 풀 상태
@Component
//^^^^^^^ Spring 이 자동 등록. ApplicationContext 에 빈으로 올라가 List<MetricSnapshotProvider> 탐색 시 자동 포함
class DbPoolMetricProvider implements MetricSnapshotProvider {
@Override public Map<String, Object> snapshot() {
return Map.of("db.active", dataSource.getActiveCount(),
// ^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 키 이름 현재 사용 중인 커넥션 수 (HikariCP 메트릭)
"db.idle", dataSource.getIdleCount());
// ^^^^^^^^^^^^^^^^^^^^^^^^^ 대기 중인 커넥션 (풀에서 잘고 있는 상태)
}
}
// v2.10 — 컨트롤러는 모든 구현체를 리스트로 주입받고 합치
@GetMapping("/api/admin/metrics")
Map<String, Object> all() {
return providers.stream()
// ^^^^^^^^^ 등록된 모든 MetricSnapshotProvider 구현체. Spring 이 생성자 자동 주입
.map(MetricSnapshotProvider::snapshot)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 메서드 레퍼런스 — 각 구현체의 snapshot() 실행
// 결과: Stream<Map<String, Object>>
.reduce(new HashMap<>(), (a, b) -> { a.putAll(b); return a; });
// ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// | |
// | +-- 누적기에 (a) 다음 맵(b) 을 합침. 동일 키 있으면 덮어쓰기
// +-- 초기값: 빈 HashMap
// (Map.of 가 아닌 HashMap 인 이유 — putAll 여분적 수정 필요)
}SSE 로그 엔드포인트.
파일: 같은 AdminController.java 안의 SSE 핸들러 메서드
// v2.10 — SSE 로그 스트림 엔드포인트
@GetMapping(value = "/api/admin/logs/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ text/event-stream MIME. SSE 표준
// 그냥 JSON 이 아닌 이거로 설정해야 브라우저가 스트림 열어둔
chunked
public SseEmitter stream(@RequestParam String token) {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ ?token=xxx 쿼리 파라미터 —
// EventSource 는 Authorization 헤더 못 보냄 — 우회로 쿼리로
if (!adminTokenService.isValid(token)) throw new AccessDenied();
// ^^^^^^^ 토큰 검증 — 유효하지 않으면 연결 거절 (403)
// ^^^^^^^^^^^^^^^^ 권한 부족 시 표준 Spring Security 예외 타입
var emitter = new SseEmitter(0L);
// ^^ 타임아웃 0 = 무한. 긴 스트림에는 0 이 표준
logBroadcaster.register(emitter);
// ^^^^^^^^ 로그 메시지가 나올 때 이 emitter 로 푸시해주도록 등록
return emitter;
}Spring 이 여러 구현체를 자동으로 수집해 주므로, 실제로는 logback 에 appender 를 달아 결과를 머신의 broadcaster 에 푸쉬한다.
프론트 EventSource.
파일: popspot-frontend/src/components/admin/log/useSseStream.ts (v2.10 신규)
// v2.10 — useSseStream.ts. 자동 재연결 + 지수 backoff 구현
export function useSseStream(url: string, token: string) {
const [lines, setLines] = useState<string[]>([]);
// 위: 수신한 로그 한 줄씩 는적해 담는 상태 배열
useEffect(() => {
let es: EventSource | null = null;
// 위: 클로저 변수 (커넥션 인스턴스)
let retry = 1000;
// 위: 재시도 지연 초기값 1초 (1000ms)
const connect = () => {
es = new EventSource(`${url}?token=${token}`);
// 위: ?token=xxx 로 서버에 접속. 서버가 토큰 검증 후 스트림 시작
// EventSource 는 내장 재연결이 있으나 우리는 수동 관리 택함 (backoff 조절 용이)
es.onmessage = e => setLines(prev => [...prev, e.data]);
// 위: 서버가 보낸 이벤트마다 lines 배열에 그대로 추가
es.onerror = () => {
es?.close();
// 위: 끚어진 커넥션 닫음 — 좀비 세션 방지
setTimeout(connect, retry);
// 위: retry 밀리초 후 재연결 시도
retry = Math.min(retry * 2, 4000);
// 위: 지수 backoff — 1→2→4초 (4초에서 상한)
// 서버 도움 머림짓 시 다수 클라이언트가 동시에 채움 방지
};
es.onopen = () => { retry = 1000; };
// 위: 재연결 성공 시 retry 를 1초로 리셋 — 다음 재연결도 1초부터 시작
};
connect();
// 위: 최초 연결 시작
return () => es?.close();
// 위: 언마운트 — 연결 정리. useEffect 클린업에서 호출됨
}, [url, token]);
return lines;
}핵심 파일: popspot-backend/.../controller/AdminController.java, popspot-frontend/src/components/admin/log/useSseStream.ts, popspot-frontend/src/components/admin/log/LogViewer.tsx, popspot-frontend/src/components/admin/metrics/useDashboardMetrics.ts
v2.10.1 배포 절차 사고 5 건
| # | 증상 | 원인 | 수정 |
|---|---|---|---|
| 1 | 빌드 spotless 6 파일 실패 | 한국어 멀티라인 JavaDoc + 인라인 람다 | JavaDoc 80 컬럼 이내 재작성 + 람다 helper 추출 |
| 2 | 새 jar 배포 했는데 옛 jar 이 실행중 | scp 경로가 systemd 가 읽는 경로와 일치 안 함 | jar 이름 일치 + <code>unzip -l ...jar | grep AdminMetricsController</code> 로 검증 |
| 3 | SSE 클라이언트가 abort | 404 + HTML 응답 | jar 재배포 + <code>systemctl restart popspot</code> |
| 4 | LOGS 탭 "대기 중..." | <code>LOG_FILE_PATH</code> 누락 + 이도 없음 | /var/log/popspot/ 생성 + env 설정 + restart |
| 5 | popspot.env 의 644 권한 | 시크릿 파일 읽기 권한 너무 넓음 | <code>chmod 600</code> + systemd root → reo4321 흐름도 정상 |
직접 보는 법
어드민 계정으로 popspot.co.kr/admin 에 접속 → 메트릭 카드 4 장 + LOGS 탭. 정규식 입력 창에 예: WARN|ERROR 면 경고/에러만 필터링됨.