수정 이력
- 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 내부를 순회하면서 출력했기 때문에 연쇄적으로 문제가 발생하면 의미없이 내용이 길어졌다.
그래서 다음 기준으로 슬랫 봇 메시지를 구성했다.
- 응답 코드가 어떻게 되는지
- 어느 api에서 문제가 발생했는지
- 프론트 기준 호출지점이 어디인지
- 예외 사유
- requestParam or requestBody
- 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/9481865/getting-the-ip-address-of-the-current-machine-using-java
'개발 > 자바' 카테고리의 다른 글
UPPER SNAKE CASE를 사용하면서 생긴 문제 (0) | 2024.07.23 |
---|---|
[Java] 무지성으로 final 쓰지 않기 (0) | 2024.01.05 |
자바가 엔진단에서 어떻게 동작할까 (0) | 2023.12.30 |
JPA 연관 관계에서 set과 list 차이 (2) | 2022.04.14 |
Serialization (+ JPA) (0) | 2022.04.09 |