본문 바로가기

개발/자바

오류 요약해서 남기기

수정 이력

- 24.10.01 server name 및 ip 정보 추가

문제 제기

우리 팀에선 개발하면서 발생하는 오류들을 슬랙 봇으로 요약해서 보여주고 있었다.

하지만, 오류를 그대로 출력했기 때문에 상황에 따라선 불필요한 내용이 많아져 제대로 읽기 힘든 상황들이 있었다.

이런 불편함을 해소하기 위해 오류 메시지를 어떻게 요약해서 보여줄 수 있는지 고민한 결과 다음 형태가 나올 수 있었다.

초기 오류 메시지 형식

구분: java.lang.NullPointerException
    클래스: Test1.java
    메소드: testMethod1
    사유: at com.mapp.server.util.ExceptionInstrumentationUtil.handleFatalThrowable(ExceptionInstrumentationUtil.java:212)
        at com.mapp.server.util.ExceptionInstrumentationUtil.access$000(ExceptionInstrumentationUtil.java:32)
        at com.mapp.server.util.ExceptionInstrumentationUtil$2.sample(ExceptionInstrumentationUtil.java:164)
        at com.mapp.server.util.ExceptionInstrumentationUtil$2.sample(ExceptionInstrumentationUtil.java:160)
        at com.google.monitoring.runtime.instrumentation.ConstructorInstrumenter.invokeSamplers(ConstructorInstrumenter.java:207)
        at java.lang.RuntimeException.<init>(RuntimeException.java:52)
        at java.lang.NullPointerException.<init>(NullPointerException.java:60)
    라인: 391

    ...
    클래스: Test1
    메소드: testMethod2
    사유: 아무튼 오류
    라인: 999

 

특정 오류의 경우 위에 라이브러리 내부를 출력해 원인 파악에 어려움이 있었다.

StackTrace 내부를 순회하면서 출력했기 때문에 연쇄적으로 문제가 발생하면 의미없이 내용이 길어졌다.

그래서 다음 기준으로 슬랫 봇 메시지를 구성했다.

 

  1. 응답 코드가 어떻게 되는지
  2. 어느 api에서 문제가 발생했는지
  3. 프론트 기준 호출지점이 어디인지
  4. 예외 사유
  5. requestParam or requestBody
  6. host name, ip

처음엔 host name과 ip는 달지 않았지만, 몇몇 WAS만 오류가 난 상황이 있었다.

어느 WAS에서 오류 난 건지 찾는 과정이 번거로워 해당 내용도 추가했다.

개선해본 메시지 형식

GET

host name: task-server-2
host ip: 1.2.3.4
응답 코드: 500 INTERNAL_SERVER_ERROR
예외 api: /api/post/search
호출 위치: http://localhost:3000/postId=1
예외 구분: java.lang.RuntimeException
예외 사유: printStackTrace or 커스텀으로 입력한 예외
RequestParam: search=brorica
author=brorica1
tag=공지사항
page=1
limit=10

POST

host name: task-server-2
host ip: 1.2.3.4
응답 코드: 500 INTERNAL_SERVER_ERROR
예외 api: /api/post
호출 위치: http://localhost:3000/postId=1
예외 구분: java.lang.RuntimeException
예외 사유: printStackTrace or 커스텀으로 입력한 예외
RequestBody:
{
    "title": "첫 번째 게시글",
    "content": "여기에 게시글의 내용을 작성합니다.",
    "author": "사용자1",
    "tags": ["공지사항", "안내"],
    "created_at": "2024-08-09T10:00:00Z"
}

 

처리 흐름

슬랙 봇 서비스 코드 설명은 생략한다.

찾아보면 예제도 많고, 쓰고나니 글의 주제와 동떨어지는 느낌이었다.

ControllerAdvice

@RequiredArgsConstructor
@RestControllerAdvice
public class AdviceController {

    private final SlackBotService slackBotService;

    @ExceptionHandler(value = {NullPointerException.class})
    protected ResponseEntity<?> nullPointerException(NullPointerException e, HttpServletRequest request) {
        // ExceptionDto은 슬랫 봇에 메시지를 담을 정보를 담는 dto
        ExceptionDto exceptionDto = new ExceptionDto(HttpStatus.INTERNAL_SERVER_ERROR, e, request);
        slackBotService.send(exceptionDto);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

    // ....
}

ExceptionDto

@Getter
public class ExceptionDto {

    // 응답 코드
    private HttpStatus httpStatus;

    // 예외 구분
    private String exceptionName;

    // 슬랙 봇이 출력할 메시지
    private String message;

    // 오류가 발생한 요청 정보
    private HttpServletRequest request;

    public ExceptionDto(final HttpStatus httpStatus, final Exception e, final HttpServletRequest request) {
        this.httpStatus = httpStatus;
        this.exceptionName = e.getClass().getName();
        this.message = e.getMessage();
        this.request = request;
    }

    /**
     * 슬랙 봇에 사용될 메시지로 변환
     */
    @Override
    public String toString() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("server name: ").append(SERVER_NAME).append(lineSeparator());
        stringBuilder.append("server ip: ").append(SERVER_IPV4_ADDRESS).append(lineSeparator());
        stringBuilder.append("응답 코드: ").append(httpStatus).append(lineSeparator());
        stringBuilder.append("요청 api: ").append(decodeUriUtf8()).append(lineSeparator());
        stringBuilder.append("호출 위치: ").append(parseReferer()).append(lineSeparator());
        stringBuilder.append("예외 구분: ").append(exceptionName).append(lineSeparator());
        stringBuilder.append("예외 사유: ").append(message).append(lineSeparator());
        stringBuilder.append(parseRequestDto());
        return stringBuilder.toString();
    }

    /**
     * 한글 URI의 경우 디코딩한다.
     * 예외 발생시 원문으로 반환함
     */
    public String decodeUriUtf8() {
        String requestURI = request.getRequestURI();
        try {
            return URLDecoder.decode(requestURI, StandardCharsets.UTF_8);
        }
        catch (Exception e) {
            return requestURI;
        }
    }

    /**
     * referer 헤더에서 프론트 시작점을 확인
     */
    public String parseReferer() {
        return request.getHeader(HttpHeaders.REFERER);
    }

    /**
     * 요청 데이터를 추출해서 보여줌
     */
    public String parseRequestDto() {
        StringBuilder stringBuilder = new StringBuilder();
        // GET 처리
        if (request.getMethod().equals("GET")) {
            // reference: https://stackoverflow.com/questions/6847192/httpservletrequest-get-query-string-parameters-no-form-data
            stringBuilder.append("RequestParam: ");
            String queryString = URLDecoder.decode(request.getQueryString(), StandardCharsets.UTF_8);
            // 쿼리 파라미터 구분
            String[] parameters  = queryString.split("&");
            for (String parameter : parameters) {
                // 키, 값 구분
                String[] keyValuePair = parameter.split("=");
                // 슬랙 봇에 전달할 메시지 추가, 여백을 잘 조절하면 눈에 잘 띈다
                stringBuilder.append(keyValuePair[0]).append(": ").append(keyValuePair[1]).append(lineSeparator());
            }
        }
        // POST 처리
        else if (request.getMethod().equals("POST")) {
            try {
                /**
                 * 원래 RequestBody는 한번 읽으면 사라진다.
                 * 따라서 Fitler를 하나 추가해, RequestBody를 복사해놓는다.
                 */
                String json = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
                stringBuilder.append(lineSeparator()).append("RequestBody: ").append(lineSeparator()).append(json);
            } catch (Exception e) {
                // 예외시, 별다른 처리 없이 파싱 실패했단 메시지만 보낸다.
                stringBuilder.append("RequestBody 파싱 오류");
                log.error(e.getMessage(), e);
            }
        }

        return stringBuilder.toString();
    }
}

Filter 처리

처음엔 스택 오버플로우에서 처리하려 했지만, 계속 빈 값만 읽었고 좀 더 검색해보니 RequestBody는 한번 읽으면 사라진단 것을 알게 됐다.

따라서, Controller에서 읽기 전에 RequestBody를 복사해 여러번 읽을 수 있도록 했다.

이분 블로그 글이 설명이 잘 돼있고, 복붙만 해도 돌아갈 정도로 깔끔했다.

https://velog.io/@saint6839/Controller에서-HttpRequest-Body-값은-왜-비워져-있을까

호스트 이름, ip 수집

처음엔 InetAddr으로 server name 수집은 성공했지만,

InetAddress.getLocalHost()를 사용하니 엉뚱한 IP를 주는 문제가 있었다.

이 문제를 아래 링크의 Mr.Wang from Next 님의 답변을 사용해 해결했다.

https://stackoverflow.com/questions/9481865/getting-the-ip-address-of-the-current-machine-using-java

 

왜 엉뚱한 IP를 주는지 찾아본 결과, NIC가 여러 개인 경우 발생할 수 있단 것이었다.

 

그래서 네트워크 인터페이스를 순회해 IP를 찾는 코드도 있었지만, 잘못된 IP를 가져올 수 있다 생각했다.

스프링 부트를 처음 시작할 때, 구글 DNS 서버와 UDP 통신을 해, 시작 주소를 가져왔다.

import org.springframework.stereotype.Component;

import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.UnknownHostException;

@Component
public class UdpClient {

    public static String SERVER_NAME;

    public static String SERVER_IPV4_ADDRESS;

    public UdpClient() {
        // 서버 이름 수집
        try {
            SERVER_NAME = InetAddress.getLocalHost().getHostName();
        } catch (UnknownHostException e) {
            // ...
        }
        // 서버 주소 수집
        try(final DatagramSocket socket = new DatagramSocket()){
            socket.connect(InetAddress.getByName("8.8.8.8"), 10002);
            SERVER_IPV4_ADDRESS = socket.getLocalAddress().getHostAddress();
        } catch (SocketException | UnknownHostException e) {
            // ..
        }
    }
}

마치면서

슬랙 봇에 RequestBody를 보여주는 과정에서 JSON을 보기좋게 가공하는게 많이 번거로웠다.

여러번 시도하다 결국 원문 그대로 보내는게 낫다고 결정했다.

(튜닝의 끝은 순정)

또한, 필터에서 RequestBody를 복사하는 행위가 사용자가 많은 환경에서 적합할까란 생각이 들었다.

사용자가 많아진다면 이런 부분에서 문제가 생기지 않을까란 우려가 들지만, 제한된 사용자만 쓰는 환경이라 내가 우려한 에러가 나올지는 지켜봐야한다.

Reference

https://stackoverflow.com/questions/6847192/httpservletrequest-get-query-string-parameters-no-form-data

https://stackoverflow.com/questions/9481865/getting-the-ip-address-of-the-current-machine-using-java