본문 바로가기

Backend

파이프라인 개선 안 - 로그 뷰어

현재 저는 다음 프로젝트인 특화 프로젝트에 기획 단계에 있습니다. 팀원들과 기획을 준비하며, 팀 외적으로 팀원들이 개발에 집중할 수 있도록 Backend단의 DEMO 파일 구성과 만들려고 했던 로그 뷰어에 제작에 대한 업무를 진행하였으며, 이 과정에서 로그 뷰어에 대한 이야기를 해보려고 합니다.

 

목차

     


    개요

     

    협업 인원 
    프론트엔드 디자인 및 페이지 작성 : https://github.com/d2doo

    활용 스택

     

    크게 보면 활용한 스택은 다섯가지에 해당합니다. 기본적인 SpringBoot와 Spring Data MongoDB를 활용하여 로그에 대한 통신과 저장을 담당하였고, 통신에 대한 세부 사항은 웹 소켓을 활용하여 통신하는 방식으로 진행해 해당 소켓에 접속해있는 sendTextMessage를 하지 않는 클라이언트의 전부에게 똑같은 로그 데이터를 보내 이를 화면단에 뿌려주는 작업을 하였습니다. 아래는 이러한 과정에 대한 가벼운 아키텍쳐 그림 입니다.

    가벼운 플로우 아키텍처

    저희 파이프라인은 기본적으로 FE 팀원들이 개발을 할 때 편하게 하도록 Merge시 마다 갱신되는 미러 BE 서버를 갖고 있습니다. 이 서버에 LogBack의 AppenderBase를 상속받은 클래스를 작성하여 모든 Log 이벤트를 통제서버의 WebSocket으로 송신하는 과정을 추가하였습니다. 아래는 관련 코드입니다.

     

     


    코드 설명


     

    개발 서버측 코드

    본문내용넣기

     

     

     

     

     

    먼저 개발서버에 추가된 CustomWebSocketAppender와 관련한 코드입니다.

    먼저  CustomWebSocketAppender는 Component로 등록할까도 고민해보았지만, 로그는 Spring Container에 들어갈 시 Container에서 의존성 주입이 끝나기 전의 로그는 캐치하지 못하기에 logback.xml에 추가하여 관리하게 되었습니다.

    @Slf4j
    public class CustomWebSocketAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
    
        private static final ObjectMapper mapper = new ObjectMapper();
        private static final WebSocketClient client = new StandardWebSocketClient();
    
        private static final WebSocketHandler handler = new TextWebSocketHandler();
    
        private static String ip;
    
        static{
            try{
                ip = InetAddress.getLocalHost().getHostAddress();
            } catch (UnknownHostException e) {
                log.info( "ip 조회간 에러 발생");
                log.debug( e.toString() );
            }
        }
    
        private static WebSocketSession session;
    
        static{
            try {
                session = client.execute( handler, "ws://통제서버 웹 소켓 url" ).get();
            } catch (InterruptedException e) {
                log.info("ws 생성간 인터럽트 발생");
                log.debug( e.toString() );
            } catch (ExecutionException e) {
                log.info("ws 생성간 에러 발생");
                log.debug( e.toString() );
            }
        }
        private static final String FORMAT_MESSAGE = "[%s] %s %s.%s:%d - %s";
    
        // @formatter:off
        protected void append(ILoggingEvent e) {
            if( !session.isOpen() ){
                refreshSession();
            }
            Date now = Date.from( Instant.now() );
            String content = formatLog( e );
            LogDto sendData = new LogDto( content, now, ip );
    //        WebSocketMessage sendData = new TextMessage( msg );
            //로그는 매 순간 실시간이기에 반복적인 ObjectMapper 생성은 비효율적임. static으로 관리
    //        ObjectMapper mapper = new ObjectMapper();
    
    
            try {
                String sendDataJson = mapper.writeValueAsString(sendData);
                log.info( "sendMsg: " + sendDataJson );
                TextMessage sendDataTextMessage = new TextMessage( sendDataJson );
                session.sendMessage( sendDataTextMessage );
            } catch (IOException ex) {
                log.info("WebSocket 데이터 송신 간 장애 발생");
                log.debug( e.toString() );
            }
        }
    
    
        private void refreshSession() {
            try {
                session = client.execute( handler, "ws://통제서버 웹 소켓 url" ).get();
            }catch (ExecutionException e) {
                log.info("ws 세션 생성간 장애 발생");
                log.debug( e.toString() );
            } catch (InterruptedException e) {
                log.info("ws 세션 생성간 인터럽트 발생");
                log.debug( e.toString() );
            }
        }
    
        private void refreshIp(){
            try{
                ip = InetAddress.getLocalHost().getHostAddress();
            } catch (UnknownHostException e) {
                log.info( "ip 조회간 에러 발생");
                log.debug( e.toString() );
            }
        }
    
        private String formatLog(ILoggingEvent event){
            StackTraceElement[] callerData = event.getCallerData();
            StackTraceElement stackTraceElement = callerData[0];
            String threadName = event.getThreadName();
            String level = event.getLevel().toString();
            String logger = event.getLoggerName();
            String msg = event.getFormattedMessage();
            // String className = stackTraceElement.getClassName();
            String method = stackTraceElement.getMethodName();
            int lineNumber = stackTraceElement.getLineNumber();
    
            return String.format(FORMAT_MESSAGE, threadName, level, logger, method, lineNumber, msg);
        }
    
    }

     

    1. 기본적으로 소켓 세션을 유지하되 연결 시도시 연결이 끉겨 있을 시 이를 다시 요청하여 연결 합니다.
    2. 모든 Logging Event에 대하여 ILogginEvent 객체에서 필요한 데이터를 String으로 format하여 전송할 TextMessage를 만듭니다. 
    3. 이를 WebSocket을 통해 송신하고 서버측은 이를 수신하여 필요한 처리를 하게 됩니다. 이는 개발 간에는 xml 설정을 지워두고 .gitignore에 등록 후 브랜치에서 관리할 계획입니다. ( 이 기능은 개발 서버에서 필요한 기능이기 때문입니다.)

    아래는 이러한 Appender를 등록하는 xml입니다.

    <?xml version="1.0" encoding="UTF-8"?>
    <configuration>
    
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <!-- encoders are assigned the type
                 ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n</pattern>
            </encoder>
        </appender>
    
    
    <!--    <appender name="SOCKET" class="ch.qos.logback.classic.net.SocketAppender">-->
    <!--        <remoteHost>localhost</remoteHost>-->
    <!--        <port>8082</port>-->
    <!--        <reconnectionDelay>1000</reconnectionDelay>-->
    <!--        <includeCallerData>false</includeCallerData>-->
    <!--    </appender>-->
    
        <appender name="WEBSOCKET" class="com.ssafy.i10a709be.Domain.Log.CustomLogAppender.CustomWebSocketAppender">
    
        </appender>
    
        <root level="debug">
            <appender-ref ref="STDOUT" />
            <appender-ref ref="WEBSOCKET" />
        </root>
    </configuration>

     

     

     


     

    통제 서버 측 코드

     

     

    먼저 패키지 구조를 설명드리겠습니다.

     

    \---src
        +---main
        |   +---java
        |   |   \---com
        |   |       \---ssafyhelper
        |   |           \---controlserver
        |   |               |   ControlServerApplication.java
        |   |               |
        |   |               +---Common
        |   |               |   +---Configuration
        |   |               |   |       WebConfig.java
        |   |               |   |       WebSocketConfig.java
        |   |               |   |
        |   |               |   +---Handler
        |   |               |   |       GlobalExceptionHandler.java
        |   |               |   |       WebSocketHandler.java
        |   |               |   |
        |   |               |   \---WebSocketManager
        |   |               |           WebSocketManager.java
        |   |               |
        |   |               \---Domain
        |   |                   +---Log
        |   |                   |   +---Controller
        |   |                   |   |       LogRestController.java
        |   |                   |   |       LogViewController.java
        |   |                   |   |
        |   |                   |   +---Domain
        |   |                   |   |       Log.java
        |   |                   |   |       LogType.java
        |   |                   |   |
        |   |                   |   +---Dto
        |   |                   |   |       LogDto.java
        |   |                   |   |
        |   |                   |   +---Infra
        |   |                   |   |   |   LogRepository.java
        |   |                   |   |   |   LogRepositoryImpl.java
        |   |                   |   |   |
        |   |                   |   |   +---Entity
        |   |                   |   |   |       LogDocument.java
        |   |                   |   |   |       LogSequenceDocument.java
        |   |                   |   |   |
        |   |                   |   |   \---Repository
        |   |                   |   |           LogMongoRepository.java
        |   |                   |   |           LogSequenceMongoRepository.java
        |   |                   |   |
        |   |                   |   \---Service
        |   |                   |           LogSequenceService.java
        |   |                   |           LogService.java
        |   |                   |
        |   |                   \---PipeLine
        |   |                       +---Controller
        |   |                       |       ContinousDeliveryRestController.java
        |   |                       |       MonitorRestController.java
        |   |                       |
        |   |                       +---Dto
        |   |                       |       ArtifactRequestDto.java
        |   |                       |       JobDto.java
        |   |                       |       ResponseDto.java
        |   |                       |
        |   |                       +---Exception
        |   |                       |   +---Monitor
        |   |                       |   |       AutomatingSequenceTransferException.java
        |   |                       |   |       JobNotFoundException.java
        |   |                       |   |       MergingSequenceNotFoundException.java
        |   |                       |   |
        |   |                       |   \---Ssh
        |   |                       |           ExecConnectionException.java
        |   |                       |           NullSessionException.java
        |   |                       |           SftpConnectionException.java
        |   |                       |           SftpRuntimeException.java
        |   |                       |           SshConnectionException.java
        |   |                       |           SshRuntimeException.java
        |   |                       |           UnknowExecException.java
        |   |                       |           UnknownSftpException.java
        |   |                       |
        |   |                       \---Service
        |   |                               MonitorService.java
        |   |                               SshConnectionService.java
        |   |
        |   \---resources
        |       |   application.yml
        |       |
        |       \---static
        |           +---css
        |           |       log.css
        |           |
        |           +---js
        |           |       log.js
        |           |
        |           \---templates
        |                   log.html
        |

    SSR 기반으로 화면 여태까지의 로그와 앞으로의 로그를 출력할 html, css, js 화면단의 GetMapping과 로그 데이터를 제공해줄 Controller 패키지내의 클래스들 그리고 이러한 서비스를 제공하기 위한 WebSocketConfig와 Handler 및 여러 세션을 동시에 관리할 웹 소켓 Manager클래스를 작성하였습니다.

    마지막으로 이러한 과정에서 여태 쌓인 로그들을 MongoDB에 넣고 이에 순차를 부여할 Sequence Service등을 작성하였습니다. 아래는 이러한 제공하는 서비스의 화면입니다.

    이어서 코드로 설명하겠습니다.

    let tags;
    window.onload = () => {
        tags = document.querySelectorAll(".btn_logs");
        // const baseUrl = window.location.href;
        // const pathSegments = baseUrl.split('/');
        // const lastPath = pathSegments.pop();
    
        ListSetting( 'dev' );
        buttonSetting();
    
    }
    
    
    
    let socket;
    
    const ListSetting = async ( type ) => {
        document.querySelector(".log-box").innerHTML="";
        const url = "/logs/" + type;
        const res = await fetch( url );
    
        const json = await res.json();
    
        json.forEach( el => {
           let html = `<div>${el.content}
                        </div>`
           document.querySelector(".log-box").innerHTML += html;
        });
        let objDiv = document.getElementById("box");
        objDiv.scrollTop = objDiv.scrollHeight;
    
        socket = new WebSocket("ws://서버 소켓 주소");
        socket.onopen = function(event) {
            console.log( "소켓 열림" );
        };
        socket.onmessage = async ( event ) => {
            // console.log( event );
            const json = await JSON.parse( event.data );
            let html = `<div>${json.content}
                        </div>`
            document.querySelector(".log-box").innerHTML += html;
            let objDiv = document.getElementById("box");
            objDiv.scrollTop = objDiv.scrollHeight;
        }
    
    
    
        socket.onclose = function(event) {
            console.log('웹 소켓 닫힘');
        };
    
    }
    
    const buttonSetting = () => {
        tags.forEach( (el, index ) => {
            el.addEventListener("click", ( e ) => {
                const val = e.target.getAttribute("id");
                e.target.style.backgroundColor="black";
                e.target.style.color="white";
                tags[ ( index + 1 ) % 2 ].style.backgroundColor="white";
                tags[ ( index + 1 ) % 2 ].style.color="black";
                ListSetting( val );
            })
        })
    }

     

    1차적으로 페이지 로딩시 MongoDB에서 여태 쌓인 로그들을 가져옵니다. 이에 대한 로딩이 끝날 시, 소켓 연결을 통해 주기적으로 TextMessage를 받고 이를 화면단에 추가하여 줍니다. 이외에는 버튼 이벤트에 대한 javascript를 작성하였습니다.

     

     

    이어서 백엔드 주요 코드를 정리해보겠습니다.

    Handler는 각 WebSocket 통신에 대한 처리를 담당합니다. HashMap에 각 I.P를 담아 Dev, Deploy를 구분할 생각이지만, 아직 배포서버를 할당받지 못하여 Map에 추가하진 못하였으며, deploy와 dev에 대해 각각 세션을 관리해주어야 하지만, 할당 후 추가해도 늦지 않다고 판단하여, GitHub GitLab 미러링 업무의 우선순위에 밀렸습니다.

    아래 로직의 흐름은 다음과 같습니다.

    1. 소켓 통신 생성시 WebSocketManager 클래스에 세션 추가

    2. 소켓 통신시 payload를 받아 I.P에 맞추어 logSequence를 통한 log의 Sequence 생성과 LogService를 통한 저장이 일어납니다.

    3. 저장이 끝날시 WebSocketManager에 의해 등록된 session 들에 해당 로그를 보내게 됩니다.

     

    package com.ssafyhelper.controlserver.Common.Handler;
    
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.ssafyhelper.controlserver.Common.WebSocketManager.WebSocketManager;
    import com.ssafyhelper.controlserver.Domain.Log.Domain.Log;
    import com.ssafyhelper.controlserver.Domain.Log.Domain.LogType;
    import com.ssafyhelper.controlserver.Domain.Log.Dto.LogDto;
    import com.ssafyhelper.controlserver.Domain.Log.Infra.Entity.LogSequenceDocument;
    import com.ssafyhelper.controlserver.Domain.Log.Service.LogSequenceService;
    import com.ssafyhelper.controlserver.Domain.Log.Service.LogService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    import org.springframework.web.socket.CloseStatus;
    import org.springframework.web.socket.TextMessage;
    import org.springframework.web.socket.WebSocketSession;
    import org.springframework.web.socket.handler.TextWebSocketHandler;
    
    import java.util.HashMap;
    import java.util.Map;
    
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class WebSocketHandler extends TextWebSocketHandler {
    
        private final LogService logService;
        private final LogSequenceService logSequenceService;
        private final WebSocketManager manager;
        private final ObjectMapper mapper = new ObjectMapper();
    
    
        private final Map< String, String > serverMap = new HashMap<>();
        {
            serverMap.put("70.12.246.221", "dev");
        }
    
        @Override
        protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    
    //        System.out.println("get : " + message.getPayload());
            LogDto targetLog = mapper.readValue(message.getPayload(), LogDto.class );
            String value = (serverMap.getOrDefault( targetLog.getIp(), "dev" ));
            targetLog.setType(LogType.valueOf( value ));
    
    //        Long seq = logSequenceService.genereateSequence( LogType.valueOf( value ) );
    //        System.out.println("seq!!!!!!!!!!!" + seq );
            LogSequenceDocument document = logSequenceService.findeSequence( LogType.dev );
    
            Log log = Log.from( targetLog, document.getValue() );
            document.setValue(document.getValue() + 1 );
            logSequenceService.save(document);
    //        System.out.println( log );
            logService.save( log );
    
            manager.handleTextMessage( session, message );
    
        }
    
        @Override
        public void afterConnectionEstablished(WebSocketSession session) throws Exception {
            manager.addSession(session);
        }
    
        @Override
        public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
            manager.removeSession(session);
        }
    
    }

     

     

    package com.ssafyhelper.controlserver.Common.WebSocketManager;
    
    
    import org.springframework.stereotype.Component;
    import org.springframework.web.socket.TextMessage;
    import org.springframework.web.socket.WebSocketSession;
    
    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.List;
    
    @Component
    public class WebSocketManager {
    
        private final List<WebSocketSession> sessionList = new ArrayList<>();
    
        public void addSession( WebSocketSession session ){
            sessionList.add( session );
        }
    
        public void removeSession( WebSocketSession session ){
            sessionList.remove( session );
        }
    
        public List<WebSocketSession> findAll(){
            return sessionList;
        }
    
        public void handleTextMessage( WebSocketSession origin, TextMessage message){
            for( WebSocketSession session : sessionList){
                if( session.equals( origin ) ) continue;
                try {
                    session.sendMessage(message);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }

     

    마무리

    추가적으로 MongoDB를 활용한 이유는 싸피에서 제공해주는 여분의 몽고 DB가 있었고 NoSQL이 읽기,쓰기면에서 우수한데 로그의 특성상 실시간으로 계속해서 소켓에서 데이터를 받아오고 이를 삽입하기에 유리하다고 생각하였습니다. 또한 웹 소켓을 활용한 이유는 단순 http 통신으로는 화면단에 실시간으로 모든 로그를 보여줄 수 없다고 판단하였고 아래와 같이 로직을 작성하게 되었습니다. 해당 경험을 통해, NoSQL DB에 대한 경험과 웹 소켓에 대한 경험을 할 수 있었습니다. 이어서 추가적으로 하는 작업들에 대해서도 정리하여 올려보도록 하겠습니다.