
로그 보기 너무 힘들어요 (?_⊙)?
배포한 서비스의 에러로그를 보기 위해선 E2C 우분투 서버에 들어가 직접 명령어로 로그를 확인해야했고 다음과 같은 문제가 발생했다.
따라서 서버에 접속하지 않고도 로그를 확인할 수 있는 방법을 도입하기로 했다.
이런 기능을 도입한다면, 추가적으로 사용자 행동 분석, 보안 모니터링등의 이점을 얻을 수도 있다고 한다!
먼저 어떤 기술 스택을 사용할지 고민했다.
여러가지 기술이 있었지만 나는 다음의 세가지 조건을 고려했다.
1. 무료일 것
2. 프로젝트 규모에 적합할 것
3. 취업에 의미 있는 기술일 것
지금 배포 준비 중인 서비스는 수익이 나는 구조가 아니고, 이후 수익구조로 바꾸더라도 필요이상의 성능으로 인한 지출은 의미없다고 생각했다.
따라서 2번과 3번의 기준에서 많이 생각해봤고 다음의 두 가지를 고민하게되었다.
EFK Stack는 Elasticsearch, Fluentd, Kibana의 약자이다.
EFK Stack는 세가지의 기술로 구성되어있다.
Elasticsearch는 대규모 데이터 저장/검색에 특화된 검색 엔진이다. 대규모의 로그를 저장해야할 때 유용하다.
Fluentd는 데이터 수집 파이프라인을 구축하는 도구로 다양한 데이터의 수집,전송을 지원한다. 또한 고마운 오픈 소스 플랫폼이다.
Kibana는 Elasticsearch의 데이터를 시각화하는 도구로, 그래프, 차트, 로그 분석 등의 기능을 지원한다.
이런 EFK Stack는 대규모 로그 수집이 필요한 상황에서도 유용하다.
또한 여러개의 서버가 돌아가는 분산환경에도 적합하다. 각 서버에 설치된Fluentd가 중앙서버로 로그를 전송해주기 때문이다.
먼저 현재 프로젝트 실행 환경에 대해서 설명하자면,AWS EC2에서 배포되고 있으며, 애플리케이션의 로그는 우분투 서버의 지정된 파일에 저장되고 있다.
로그를 확인할 수 있는 페이지를 보기 위해서 백엔드 서버는 백엔드 서버는 Java의 File API를 사용하여 해당 파일들에 직접 접근하고 내용을 읽어오고 출력할 수 있다.
이런 구조는 외부 api를 사용하지 않고 간단하게 구현할 수 있다는 장점이 있다.
그러나 여러대의 서버가 돌아가는 환경이라면 로그 수집에서 복잡성이 올라갈 수 있고, 실시간성을 보장할 수 없다는 단점도 있다.
또한 만약 그래프나, 여러 분석 UI를 원한다면 구현의 어려움이 생길 수 있다.
사실 3번의 기준에서 EFK가 조금 더 끌리지만? 가장 중요한건 프로젝트의 규모라고 생각한다. 어려운 일은 어렵게 쉬운 일은 쉽게!
현재 프로젝트는 분산 환경도 아닐 뿐더러 엘라스틱이 필요한만큼 대규모 로그가 발생하는 환경도 아니다.
또한 Elasticsearch가 많은 메모리르 사용하게 되어 서버 부하가 발생할 수도 있다.
따라서 파일 기반의 로깅 + 관리자 페이지를 구현하기로 결정! (대신 이후 여유가 생기면 꼭 2탄으로 EFK 도입도 다루고 싶다!! )
안 할 수가 없잖아용~
Oauth 로그인 과정을 더 알아보고 싶다면? 👉 관련 글
@Controller
@RequestMapping("/api/v1/admin/pages")
public class AdminPageController {
@GetMapping("/log")
public String getAdminLogPage() {
return "admin-log";
}
@GetMapping("/login")
public String getAdminLoginPage() {
return "admin-login";
}
}
style 부분은 생략했다.
<!DOCTYPE html>
<html lang="ko">
<head>
<title>패밀링</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
</head>
<body>
<nav class="navbar navbar-expand-sm bg-light">
패밀링
</nav>
<div class="login-container">
<a href="/api/v1/admin/login/oauth/kakao" class="kakao-button">
<img src="/images/kakao-login-img.png" alt="카카오 로그인" />
</a>
</div>
</body>
</html>
카카오 리다이렉트 부분
oauth를 다루는 글은 아니기 때문에 생략했습니다 🙇♀️
카카오 코드로 사용자 인증 부분
1. 카카오 로그인 과정이 복잡해지면서 인증을 담당하는 AuthenticationService를 분리했다.
2. 관리자인 경우에만 리다이렉트하도록 인가 과정을 추가했습니당!
//컨트롤러 코드의 일부
@GetMapping("/api/v1/admin/login/oauth/kakao/code")
public void requestKakaoLoginScreen(@RequestParam(value = "code") String code, HttpSession session, HttpServletResponse response) throws IOException {
String accessToken = kakaoService.getKakaoAccessToken(code).accessToken();
User user = kakaoService.saveKakaoLoginUser(accessToken,session);
if (!authenticationService.isAdmin(user)) {
throw new CustomException(ExceptionCode.NO_ADMINISTRATOR_RIGHTS);
}
String token = authenticationService.makeToken(user);
response.addCookie(createCookie("Authorization", token));
response.sendRedirect("/api/v1/admin/pages/log");
}
💦 정적 이미지 접근안됌.
알고보니 정적 이미지 경로에 대해서도 시큐리티 설정에서 권한 허용을 설정해야했다.
이를 해결하고 나서 정상적으로 로딩할 수 있었다.

로그를 보여주는 방식은 크게 두 가지가 있다.
나는 SSE 방식을 선택했다! 왜냐하면 페이지를 만든 이유가 로그를 편리하게 보는 것이었기 때문에 실시간으로 로그가 업데이트 되는 기능이 필요했고, 폴링 방식보다 서버 부담이 적기 때문이다.
로그 페이지는 크게 3가지 기능으로 구성된다:
javascriptfunction connectSSE() {
const logArea = document.getElementById('logArea');
const statusElement = document.getElementById('connectionStatus');
eventSource = new EventSource('/api/v1/log/stream');
eventSource.onopen = function() {
statusElement.textContent = '연결됨';
logArea.innerHTML = ''; // 연결 시 로그 영역 초기화
logCount = 0;
};
eventSource.onmessage = function(event) {
try {
const logs = JSON.parse(event.data);
const formattedLogs = formatLog(logs);
if (formattedLogs) {
logArea.innerHTML += formattedLogs;
logArea.scrollTop = logArea.scrollHeight; // 자동 스크롤
}
} catch (error) {
console.error('Error processing log:', error);
}
};
eventSource.onerror = function(error) {
console.error('SSE Error:', error);
statusElement.textContent = '재연결 중...';
setTimeout(connectSSE, 3000); // 3초 후 재연결
};
}
javascriptfunction formatLog(logs) {
let formattedHtml = '';
logs.forEach(line => {
if (!line || line.trim() === '') return;
// ERROR, WARN 로그는 빨간색으로
if (line.includes('ERROR') || line.includes('WARN')) {
formattedHtml += `<div class="log-entry log-error">${line}</div>`;
} else {
// 타임스탬프 파싱해서 색상 분리
const timeMatch = line.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/);
if (timeMatch) {
const time = timeMatch[0];
const rest = line.substring(timeMatch[0].length);
formattedHtml += `<div class="log-entry">
<span class="log-time">${time}</span>
<span class="log-info">${rest}</span>
</div>`;
} else {
formattedHtml += `<div class="log-entry">${line}</div>`;
}
}
logCount++;
});
document.getElementById('logCount').textContent = `로그 수: ${logCount}`;
return formattedHtml;
}
백엔드에서는 로그 파일을 읽어서 클라이언트에게 전달하는 역할을 한다.
@Service
@Slf4j
public class LogService {
@Value("${log.file.path}")
private String logFilePath;
private long lastFilePointer = 0; // 마지막으로 읽은 위치
private final Object fileLock = new Object(); // 동기화용 락
public List<String> getNewLogs() {
synchronized (fileLock) { // 여러 사용자가 동시에 읽을 때 충돌 방지
try (RandomAccessFile file = new RandomAccessFile(logFilePath, "r")) {
long currentLength = file.length();
// 로그 파일이 로테이션된 경우 처리
if (lastFilePointer > currentLength) {
lastFilePointer = 0;
}
file.seek(lastFilePointer); // 마지막 위치부터 읽기 시작
List<String> logs = new ArrayList<>();
String line;
while ((line = file.readLine()) != null) {
if (line.trim().isEmpty()) continue;
// Hibernate SQL 로그는 여러 줄로 구성되므로 묶어서 처리
if (line.startsWith("Hibernate:")) {
StringBuilder sqlLog = new StringBuilder(line);
String nextLine;
while ((nextLine = file.readLine()) != null && nextLine.startsWith(" ")) {
sqlLog.append("\n").append(nextLine.trim());
}
logs.add(sqlLog.toString());
if (nextLine != null && !nextLine.trim().isEmpty()) {
logs.add(nextLine);
}
} else {
logs.add(line);
}
}
lastFilePointer = file.getFilePointer(); // 다음 읽기를 위해 위치 저장
return logs;
} catch (IOException e) {
log.error("로그 파일 읽기 실패: {}", e.getMessage(), e);
return new ArrayList<>();
}
}
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/log")
@Slf4j
public class LogController {
private final LogService logService;
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private static final Long TIMEOUT = Long.MAX_VALUE;
@GetMapping("/stream")
public SseEmitter streamLogs() {
SseEmitter emitter = new SseEmitter(TIMEOUT);
emitters.add(emitter);
// 연결 종료 시 리스트에서 제거
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
// 초기 로그 전송
try {
List<String> initialLogs = logService.getNewLogs();
emitter.send(SseEmitter.event().data(initialLogs));
} catch (IOException e) {
log.error("Failed to send initial logs", e);
emitter.complete();
}
return emitter;
}
@Scheduled(fixedDelay = 1000) // 1초마다 새로운 로그 체크
public void checkNewLogs() {
if (emitters.isEmpty()) return;
List<String> newLogs = logService.getNewLogs();
if (!newLogs.isEmpty()) {
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().data(newLogs));
} catch (IOException e) {
emitters.remove(emitter);
}
});
}
}
}
위 코드를 보다보면 알겠지만, 프론트 코드도 함께 짜야했다.
프론트 코드를 짜면서 개발자의 책임을 다시 생각하게 되었다.
의문 1. 이 상황에서 백엔드 개발자의 역할은 어디까지 일까?
다시 돌아보는 구체적인 문제 상황
- 프론트 측에서 백엔드가 개발한 API 연동 중에 문제가 생겼다.
- 프론트는 백엔드에게 로그를 요청했다.
- 백엔드는 즉시 대응할 수 없다. (보안키와 IP)
의문 2. 기능 개발에 책임을 다 했다고 할 수 있을까?
만약 개발한 API가 완벽하게 동작하더라고, API의 파라미터, 사용법, 발생할 수 있는 오류에 대해서 자세히 설명하지 않아서 프론트가 내 API를 올바르게 사용할 수 없다면 기능 구현에 책임을 다했다고 할 수 있을까?
혹은 프론트의 협업 요청에 적극적으로 대응하지 않았다면?? 🤔
- 개발자의 역할은 사용하는 기술 스택이나 언어로 제한되지 않는다.
- 기능 구현의 책임은 정확한 동작과 함께 문제의 정확한 해결과 원활한 사용 환경 제공까지 포함한다.
결국, 개발한 API를 팀원이 잘 사용할 수 있도록 하는 것까지가 나의 책임이었으며 해결하기 위해서 백엔드와 프론트엔드 모두 사용한 것이었다.
만약 벡엔드 개발자의 역할을 스프링 부트, 자바 코드로만 한정지었다면 EFK Stack 기술이나 SSE, 파일 포인터를 학습할 수 없었을 것이고, 팀 내의 문제를 해결할 수 없었을 것이다.
맡은 역할 바깥이라도 존재하는 문제를 포착하고 기술로서 해결하는 자세가 진짜 개발 역량을 키우는 방법이다!ㅎㅎ
개발자를 만나게 되면 백엔드하세요? 프론트하세요? 등의 질문을 자주 주고 받게된다. 물론 나 스스로 백엔드 개발자라고 대답하고, 백엔드의 매력을 사랑하지만 백엔드는 내가 사용하는 기술의 특징이고, 본질적인 역할은 기술로 문제를 해결하는 개발자라고 생각한다.. ㅎㅎ (물론 정말 내 생각일 뿐이고 정답이 아닐 수도 있다!!!)
문제를 해결하기 위해서 사용할 기술을 고민하고, 구현해보고 시행착오를 겪는 건 가끔 고통이기도 하지만 즐거움이 훨씬 큰 것 같다.
문제를 노트북 하나로 해결할 수 있다는 것은 개발자의 특권일 것이다.
ㅎㅎ 이런 즐거움을 계속해서 느낄 수 있도록 정진하자 !
그렇자면 정말 긴글 !! 읽어주셔서 감사합니다~~

로그분석에 활용할 수 있는 EFK 스택 간단하게 정리해보기-매직
좋은 글 감사합니다 ... 🙇♀️🙇♀️