“실시간 콘솔 로그” 붙이기 (웹소켓 + 1회용 토큰 구현기)
들어가며
운영 중인 서버에 문제가 생겼을 때 가장 답답한 순간은, 지금 서버 안에서 무슨 일이 벌어지고 있는지 눈으로 바로 확인할 방법이 없을 때입니다. SSH로 접속해서 로그 파일을 열어보거나, 파일을 계속 새로고침하면서 눈으로 스캔하는 방식은 매번 반복하기엔 번거롭습니다. 이번 글에서는 관리자 페이지 안에 웹소켓 기반 실시간 콘솔 로그 패널을 붙인 과정을 코드와 함께 정리해봤습니다. 톰캣(Tomcat) + 서블릿 환경을 기준으로 설명하지만, 구조 자체는 다른 자바 웹 서버 환경에도 그대로 적용할 수 있습니다.
전체 구조부터 보면
구현 방식을 한 문장으로 요약하면 이렇습니다. 서버는 로그 파일을 짧은 주기로 tail(꼬리 읽기)해서 새로 추가된 줄만 골라내고, 그 줄을 웹소켓으로 연결된 모든 브라우저에 그대로 뿌려줍니다. 브라우저는 그 줄을 받아서 화면에 한 줄씩 쌓기만 하면 됩니다.
구성 요소는 세 가지입니다.
- 로그 파일을 주기적으로 읽어 새 줄을 찾아내는 백그라운드 스레드
- 그 줄을 구독 중인 클라이언트에게 뿌려주는 웹소켓 엔드포인트
- 웹소켓에 접속해서 받은 줄을 화면에 그리는 브라우저 스크립트
여기까지는 특별할 게 없습니다. 실제로 구현하면서 가장 까다로웠던 부분은 따로 있었는데, 바로 웹소켓 연결에 인증을 어떻게 실을 것인가였습니다. 이 부분은 뒤에서 따로 다루겠습니다.
로그 파일을 실시간으로 tail하는 부분
먼저 서버가 뜰 때 백그라운드로 로그 파일을 감시하는 리스너를 하나 등록합니다. ServletContextListener를 구현해서 서버 시작과 동시에 스케줄러를 하나 띄우는 방식입니다.
@WebListener
public class LogTailListener implements ServletContextListener {
private ScheduledExecutorService scheduler;
private long lastPosition;
private final StringBuilder carryOver = new StringBuilder();
@Override
public void contextInitialized(ServletContextEvent event) {
File file = logFile();
// 서버가 뜬 시점 이후에 새로 찍히는 줄만 스트리밍합니다.
lastPosition = (file != null && file.exists()) ? file.length() : 0L;
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "console-log-tail");
t.setDaemon(true);
return t;
});
scheduler.scheduleWithFixedDelay(this::pollOnce, 700L, 700L, TimeUnit.MILLISECONDS);
}
private void pollOnce() {
try {
File file = logFile();
if (file == null || !file.exists()) return;
long len = file.length();
if (len < lastPosition) {
// 로그 파일이 롤오버(rollover)되어 새로 시작된 경우, 처음부터 다시 읽습니다.
lastPosition = 0L;
carryOver.setLength(0);
}
if (len == lastPosition) return;
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(lastPosition);
byte[] buf = new byte[(int) (len - lastPosition)];
raf.readFully(buf);
lastPosition = len;
carryOver.append(new String(buf, StandardCharsets.UTF_8));
int newlineIdx;
while ((newlineIdx = carryOver.indexOf("\n")) >= 0) {
String line = carryOver.substring(0, newlineIdx);
carryOver.delete(0, newlineIdx + 1);
if (!line.isEmpty()) LogTailSocket.broadcastLine(line);
}
}
} catch (Exception e) {
System.err.println("로그 파일 tail 실패: " + e.getMessage());
}
}
}
여기서 신경 쓴 부분이 세 가지 있습니다. 첫째, 매번 파일 전체를 읽지 않고 RandomAccessFile.seek()로 마지막으로 읽은 위치부터만 읽습니다. 둘째, 파일에서 읽은 바이트가 줄바꿈 문자로 딱 끝난다는 보장이 없기 때문에, 줄바꿈이 아직 나오지 않은 나머지 조각은 carryOver에 보관해뒀다가 다음 폴링 때 이어 붙입니다. 셋째, 로그 파일이 롤링(rolling)되어 크기가 갑자기 작아지면 파일이 새로 시작된 것으로 보고 처음부터 다시 읽습니다.
웹소켓 엔드포인트와 구독자 관리
다음은 실제로 브라우저와 연결을 유지하는 웹소켓 엔드포인트입니다. 자바 표준 스펙인 JSR-356(jakarta.websocket)을 그대로 사용했습니다.
@ServerEndpoint("/ws/consoleLog")
public class LogTailSocket {
private static final Set<Session> SUBSCRIBERS = new CopyOnWriteArraySet<>();
@OnOpen
public void onOpen(Session session) {
SUBSCRIBERS.add(session);
for (String line : recentBacklog()) {
try {
session.getBasicRemote().sendText(line);
} catch (Exception ignored) {
}
}
}
@OnClose
public void onClose(Session session) {
SUBSCRIBERS.remove(session);
}
static void broadcastLine(String line) {
for (Session s : SUBSCRIBERS) {
if (s == null || !s.isOpen()) continue;
try {
s.getBasicRemote().sendText(line);
} catch (Exception ignored) {
}
}
}
}
CopyOnWriteArraySet을 쓴 이유는 구독자 목록에 대한 읽기(브로드캐스트)는 매우 잦고 쓰기(접속/해제)는 상대적으로 드물기 때문입니다. 그리고 접속 직후에는 로그가 텅 비어 보이지 않도록, 파일 끝에서부터 일정 바이트(예를 들어 64KB)만큼 읽어 최근 로그를 먼저 내려주는 백로그 기능도 넣었습니다. 정확히 몇 줄을 보여줄지 계산하려고 파일 전체를 읽는 대신, 바이트 수 기준으로 잘라 읽고 그 안에서 완전한 줄만 추려내는 방식이라 파일 크기와 무관하게 항상 빠릅니다.
웹소켓은 인증 헤더를 못 싣는다는 문제
이 페이지는 관리자만 봐야 하는 화면입니다. 일반적인 페이지라면 서블릿 필터에서 Basic 인증이나 세션 검사를 하면 그만이지만, 브라우저의 네이티브 WebSocket API는 연결을 맺을 때 커스텀 헤더를 실어 보낼 방법을 제공하지 않습니다. Authorization 헤더를 직접 붙일 수 없고, 브라우저가 캐시해둔 인증 정보가 웹소켓 핸드셰이크에 자동으로 실리는지도 브라우저마다 동작이 달라 믿을 수 없습니다.
그래서 이미 인증을 통과한 요청에 얹어서 짧은 유효 시간의 토큰을 하나 발급하고, 웹소켓 접속 시에는 그 토큰을 쿼리 파라미터로 넘기는 방식을 택했습니다.
60초짜리 1회용 토큰 발급하기
관리자 모니터 페이지는 이미 인증 필터를 통과한 상태에서 5초마다 상태 정보를 폴링하고 있었습니다. 이 폴링 응답에 토큰 하나를 끼워 보내는 방식으로 별도의 로그인 절차 없이 문제를 해결했습니다.
private static final Map<String, Long> TOKENS = new ConcurrentHashMap<>();
private static final long TOKEN_TTL_MS = 60_000L;
// 이미 인증을 통과한 폴링 요청 처리 코드 안에서 호출합니다.
static String issueToken() {
cleanupExpired();
String token = UUID.randomUUID().toString();
TOKENS.put(token, System.currentTimeMillis() + TOKEN_TTL_MS);
return token;
}
private static boolean consumeToken(String token) {
if (token == null || token.isBlank()) return false;
Long expiresAt = TOKENS.remove(token); // 1회용 - 성공/실패와 무관하게 즉시 제거
return expiresAt != null && expiresAt >= System.currentTimeMillis();
}
@OnOpen
public void onOpen(Session session) {
List<String> tokenParam = session.getRequestParameterMap().get("token");
String token = (tokenParam != null && !tokenParam.isEmpty()) ? tokenParam.get(0) : null;
if (!consumeToken(token)) {
try {
session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "unauthorized"));
} catch (Exception ignored) {
}
return;
}
SUBSCRIBERS.add(session);
// ... 백로그 전송
}
핵심은 두 가지입니다. 토큰은 한 번 쓰이면 성공이든 실패든 즉시 제거되고, 유효 시간도 60초로 짧게 잡았습니다. 이렇게 하면 토큰 값이 브라우저 개발자 도구 네트워크 탭 등에 잠깐 노출되더라도 실제로 악용될 수 있는 시간과 횟수가 극히 제한됩니다. 토큰 발급 함수 자체는 인증 여부를 신경 쓰지 않지만, 이미 인증 필터를 통과한 요청 처리 로직 안에서만 호출되기 때문에 결과적으로 관리자만 토큰을 받을 수 있습니다.
브라우저 쪽 연결 코드
브라우저는 폴링 응답에서 받은 토큰으로 웹소켓을 엽니다.
var socket = null;
function connectConsoleLog(token) {
if (!token) return;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return; // 이미 연결돼 있거나 연결 시도 중이면 무시
}
var proto = (window.location.protocol === "https:") ? "wss://" : "ws://";
var url = proto + window.location.host + "/ws/consoleLog?token=" + encodeURIComponent(token);
var ws = new WebSocket(url);
ws.onopen = function () { setStatus("연결됨"); };
ws.onmessage = function (evt) { appendLogLine(evt.data); };
ws.onclose = function () {
setStatus("연결 끊김(다음 폴링 때 재연결)");
socket = null;
};
ws.onerror = function () { try { ws.close(); } catch (e) {} };
socket = ws;
}
화면에 줄을 그릴 때는 로그 레벨이 WARN이나 ERROR인 줄만 색을 다르게 표시해서, 로그가 빠르게 쌓여도 문제 될 만한 줄만 눈에 띄게 했습니다. 로그4j 계열의 %-5level 패턴처럼 레벨이 고정폭으로 찍히는 형식이라면 정규식 하나로 간단히 구분할 수 있습니다.
function appendLogLine(line) {
var div = document.createElement("div");
if (/ (ERROR|FATAL) /.test(line)) div.className = "cl-error";
else if (/ WARN /.test(line)) div.className = "cl-warn";
div.textContent = line;
logBox.appendChild(div);
while (logBox.childNodes.length > 500) logBox.removeChild(logBox.firstChild);
}
줄 개수를 500줄로 제한해서 오래 켜둬도 브라우저 메모리가 계속 불어나지 않도록 했습니다.
재연결은 어떻게 자연스럽게 되나
별도의 재연결 타이머나 지수 백오프(exponential backoff) 로직을 따로 만들지 않았습니다. 대신 이미 존재하던 5초 폴링 주기를 그대로 활용했습니다. 폴링 응답이 올 때마다 connectConsoleLog(token)을 호출하는데, 이 함수는 소켓이 이미 열려 있거나 연결 시도 중이면 아무 일도 하지 않고, 끊어져 있을 때만 그 시점에 받은 최신 토큰으로 새로 연결을 시도합니다. 결과적으로 네트워크가 잠깐 끊기거나 노트북을 덮었다 열어도, 최악의 경우 다음 폴링 주기(5초) 안에 자동으로 다시 연결됩니다. 별도의 재연결 코드를 새로 짜는 대신 기존 폴링 구조에 자연스럽게 얹은 셈입니다.
이 구조에서 주의할 점
로그 내용을 그대로 흘려보내는 채널이기 때문에, 이 웹소켓 엔드포인트는 관리자 인증과 동등한 수준으로 보호해야 합니다. 토큰 발급 지점이 인증되지 않은 곳에 노출되면 이 구조 전체가 무의미해지므로, 반드시 이미 인증을 통과한 요청 처리 로직 안에서만 토큰을 발급해야 합니다. 또한 로그 레벨을 너무 낮게(TRACE, DEBUG 등) 잡아두면 민감한 값이 로그에 그대로 찍혀 브라우저까지 그대로 전달될 수 있으니, 운영 환경에서는 로그 레벨과 마스킹 정책을 별도로 점검해두는 편이 안전합니다.
마무리
정리하면, 실시간 콘솔 로그 기능은 파일 tail, 웹소켓 브로드캐스트, 그리고 짧은 유효시간의 1회용 토큰이라는 세 가지 조각을 조합한 결과물입니다. 이미 인증된 요청 흐름에 토큰 발급을 얹는 방식은 웹소켓처럼 커스텀 헤더를 못 싣는 프로토콜에서 자주 쓸 수 있는 패턴이니, 비슷한 상황을 만나면 새로운 인증 체계를 따로 만들기보다 기존 인증된 폴링 요청에 토큰 발급을 얹을 수 있는지부터 검토해보시길 권합니다.
참고
- 실시간 전달에는 표준 웹소켓(JSR-356,
jakarta.websocket)을 사용했습니다. - 웹소켓 핸드셰이크가 커스텀 인증 헤더를 지원하지 않는 문제는, 이미 인증된 폴링 요청에 60초 유효 1회용 토큰 발급을 얹어 해결했습니다.
- 재연결은 별도 로직 없이 기존 폴링 주기(5초)에 편승시켜 자연스럽게 구현했습니다.
- 로그 파일 tail은
RandomAccessFile.seek()로 마지막 위치부터만 읽고, 잘린 줄은 버퍼에 보관했다가 다음 폴링에 이어 붙이는 방식으로 처리했습니다.
홍TV



![Eclipse에서 갑자기 "[m2e] Lifecycle Mapping" 오류? Maven 프로젝트가 빨간 줄 뜨는 이유 4 Eclipse에서 갑자기 [m2e] Lifecycle Mapping 오류? Maven 프로젝트가 빨간 줄 뜨는 진짜 이유](https://hongtv.co.kr/wp-content/uploads/2026/09/62edeb0f-aaf8-42cc-af31-1a91e54bc187-300x200.png)
댓글 0
첫 댓글을 남겨보세요.