백엔드프론트엔드트러블슈팅

SSH journalctl 대신 어드민 페이지에서 실시간 로그 — Server-Sent Events 인증/재연결 + JVM/HTTP/DB 메트릭 — POP-SPOT v2.10 (+v2.10.1)

김동현
··5분 읽기

운영 모니터링을 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 경로 한정 ?token= 쿼리 폴백)
  • 프록시 끊김 방지용 30 초 keepalive + 1→2→4 초 재연결
  • 새 메트릭 추가 비용을 줄이는 "메트릭 제공자" 인터페이스 패턴
  • v2.10.1 의 배포 절차 사고 5 건 — scp 경로·env 파일·로그 디렉토리·권한·Spotless 재포맷
  • 모르는 단어 한 줄로

    용어한 줄 설명
    SSEServer-Sent Events. 서버가 한 쪽으로 계속 데이터를 흠려보내는 단방향 스트림. 반향이 필요 없는 로그에 적합
    EventSource브라우저 표준 SSE 클라이언트. 자동 재연결 기능 내장, 단 헤더 조작 불가능
    keepalive조용하지만 고객이 아직 있다는 걸 알려주는 주기적 신호. 프록시/터널이 닫아버리는 걸 막음
    p95추출된 응답 시간의 95 번째 백분위. "100 명 중 95 번째까지는 이 시간 안에 답함"
    journalctl -fsystemd 의 로그를 실시간으로 계속 보여주는 리눅스 명령

    무엇이 바뀌었나

    항목v2.9v2.10
    메트릭CPU + 메모리 (라인 차트) • JVM Heap/Thread, HTTP 요청수/p95/5xx, DB 커넥션 풀, 자동수집 4종
    새 메트릭 추가컨트롤러 직접 수정"메트릭 스냅샷 제공자" 인터페이스 구현 클래스 추가 (자동 합성)
    실시간 로그SSH 로 <code>journalctl -f</code>어드민 LOGS 탭 (정규식 필터 · 일시정지 · 다운로드 · 색상화)
    SSE 인증SSE 경로 한정 <code>?token=</code> 쿼리 폴백 (EventSource 이 헤더 못 보냄)
    Keepalive30초마다 코멘트 프레임 프록시 잘림 방지
    재연결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 (인터페이스)

    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 핸들러 메서드

    java
    // 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 신규)

    typescript
    // 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> 로 검증
    3SSE 클라이언트가 abort404 + HTML 응답jar 재배포 + <code>systemctl restart popspot</code>
    4LOGS 탭 "대기 중..."<code>LOG_FILE_PATH</code> 누락 + 이도 없음/var/log/popspot/ 생성 + env 설정 + restart
    5popspot.env 의 644 권한시크릿 파일 읽기 권한 너무 넓음<code>chmod 600</code> + systemd root → reo4321 흐름도 정상

    직접 보는 법

    어드민 계정으로 popspot.co.kr/admin 에 접속 → 메트릭 카드 4 장 + LOGS 탭. 정규식 입력 창에 예: WARN|ERROR 면 경고/에러만 필터링됨.


    관련 글

  • 이전 — v2.9, IDOR + 권한 재검증
  • 다음 — v2.11, 의견 보내기 게시판
  • 공유

    댓글