MyB

서버 내 에러 발생 시 에러 알람 구현 과정

2025.01.12

배경

진행중인 서비스가 곧 출시를 앞두고 있다. 그래서 요즘 개발을 하면서 실제 서비스 상황이라고 가정을 해보고 생각을 하고 있는데 이런 생각이 들었다.

“개발중일때는 api에서 에러가 나면 클라이언트 개발자가 에러가 발생했다고 알려줘서 고치는데 실제 배포 상황에선 에러가 언제 어디서 났는지 어떻게 확인하지?”

위와 같은 생각이 들었다. 로그를 계속 보고 있을 수도 없는 노릇이고 현재 로그에는 어떤 오류가 발생했다만 나와 있는데 어떤 API를 통해 들어와서 이런 오류가 발생했는지 알 수 있는 방법이 없다.

따라서 언제 어디에서 어떤 이유로 오류가 발생했는지 알 수 있는 방법이 없을까 하다가 오류가 발생했을때 알람이 오면 좋겠다는 생각이 들었고 어떤 알람으로 올 수 있을지 찾아보던 중 현재 우리팀에서 디스코드로 소통을 하고 있기 때문에 겸사겸사 디스코드 봇으로 알람을 추가해보면 어떨까 라는 생각이 들었다.

문제상황

우선 결과에는 다음이 포함되어 있었으면 했다.

  • 에러이유
  • 에러 발생 API
  • 전달받은 파라미터

일단 에러 코드와 에러 이유의 경우 에러 발생 지점이나 핸들러에서 알 수 있었고 실행된 API경로와 전달받은 파라미터의 경우 HttpServletRequest 를 통해 알 수 있었다.

이 객체를 알림을 전달하는 메서드까지 전달해야하는데 에러 발생 지점에까지 전달하기에는 모든 API포인트에 해당 객체를 전달해야 하기 때문에 너무 불필요한 작업이고 그렇다면 에러 핸들러에서 이 객체를 사용하게 해볼 수 있으면 좋겠다 싶었다. 그러기 위해서 에러 핸들러가 어떻게 동작하는지 요청이 들어오고부터 에러 핸들러에 에러가 처리되는 흐름을 알게된다면 객체를 전달할 수 있는 실마리를 얻을 수 있을 것 같았다.

해결과정

Controller Advice와 Exception Handler

찾아보니 기존에 @Exception Handler는 @Controller 클래스에만 적용이 됐었는데 @Controller Advice를 붙인 클래스에 @Exception Handler가 붙어 있다면 어떤 컨트롤러에도 적용이 될 수 있다는 것을 확인. 즉, Advice, AOP관점에서 “예외”라는 관점을 분리하여 하나의 클래스에 담을 수 있도록 하는 어노테이션이었던 것

그러면 결국 예외가 발생하여 해당 메서드가 실행되게 해주는 역할은 @Exception Handler이고 예외가 발생하면 어떤 과정으로 해당 메서드가 실행되는지를 파악해야함

dispatcher servlet과 예외 처리 흐름

  1. 클라이언트로부터 http요청이 들어옴.
  2. 디스패처 서블릿이 RequestMappingHandlerAdapter를 통해 핸들러(컨트롤러 메서드)를 확정함. 컨트롤러에 있는 Request Mapping을 하여 메서드를 정함.
  3. 확정된 핸들러를 ServletInvocableHandlerMethod 로 감싸 호출하고 HandlerMethodArgumentResolverComposite 가 파라미터를 주입해줌.
  4. 로직 중 에러 발생
  5. 디스패처 서블릿 단에 있는 try-catch문으로 인해 processHandlerException() 메서드가 실행되고 HandlerExceptionResolver리스트들 중 ExceptionHandlerExceptionResolver@ExceptionHandler들을 탐색
  6. 발견된 @ExceptionHandler메서드는 마찬가지로 ServletInvocableHandlerMethod로 감싸져 호출되고 역시 파라미터 주입을 HandlerMethodArgumentResolverComposite가 처리하여 메서드를 실행함.

ServletInvocableHandlerMethod와 HandlerMethodArgumentResolverComposite

ServletInvocableHandlerMethod는 서블릿 환경에 특화된 실행기. 반환 값을 HTTP 응답으로 처리까지 해줌. 그래서 실제로 응답을 만들어야 하는 컨트롤러 메서드 부분과 예외처리 메서드가 ServletInvocableHandlerMethod에 의해 실행이 됨. 이때 파라미터 역시 컨트롤러 메서드와 예외처리 메서드가 HandlerMethodArgumentResolverComposite에 의해 동일하게 주입됨.

HandlerMethodArgumentResolverComposite는 메서드에 쓰일 각 파라미터에 대해서 어떤 리졸버가 이 파라미터를 처리할 지를 순서대로 탐색하고, 선정된 리졸버에게 해석을 위임함.

처음에는 왜 리스트쓰는거지? 그냥 map쓰면 안되는건가 싶었는데 다음과 같은 이유로 리스트를 사용한다 고 함.

  1. 우선 순위 필요: 여러 리졸버가 같은 파라미터를 처리할 수 있어, 첫 매칭 순서가 중요
  2. 조건 복합: 리졸버를 선택할때 단순히 그냥 이름으로 하는게 아니라 조건을 통해 리졸버를 선택함.
  3. 성능은 캐시로 해결: 성능때문에 map이 낫지 않나 싶었는데 캐시로 해결했다고 하니..

이런 이유로 리스틑 활용하고 각 리졸버를 선택하게 되는 코드는 아래와 같다.

Object[] getMethodArgumentValues(...) {
  for (파라미터 p in 메서드 파라미터들) {
    // 1) 캐시 조회
    resolver = argumentResolverCache.get(p);
    if (resolver == null) {
      // 2) 순서대로 supportsParameter 검사
      for (r in resolvers) {
        if (r.supportsParameter(p)) {
          resolver = r;
          argumentResolverCache.put(p, r); // 3) 결과 캐시
          break;
        }
      }
    }
    if (resolver == null) throw ... // 처리 가능한 리졸버 없음 → 예외

    // 4) 실제 값 생성
    args[i] = resolver.resolveArgument(p, mavContainer, webRequest, binderFactory);
  }
  return args;
}

번외

ExceptionHandlerExceptionResolver가 어떤 @ExceptionHandler메서드를 쓸지 선택하는 과정

예외 발생시 탐색 순서는 아래와 같다.

  1. 해당 컨트롤러 내부에 있는 @ExceptionHandler 먼저
    1. ExceptionHandlerExceptionResolver가 실행될때 예외가 발생한 컨트롤러도 파라미터 값으로 받음
    2. 따라서 컨트롤러 클래스 내부에서 가장 구체적인 예외 타입을 처리하는 메서드를 우선으로 찾음
  2. 전역에 설정된 @ControllerAdvice내부에 있는 @ExceptionHandler
    1. 스프링 부팅 시 @ControllerAdvice빈이 등록되고 그 다음에 ExceptionHandlerExceptionResolver이 등록이 되면서 @ControllerAdvice안에 있는 @ExceptionHandler를 미리 스캔하고 예외와 메서드를 매핑하여 미리 캐싱해둠. 예외 발생하면 이때 캐싱해둔 메서드를 바로 실행함.

구현

image

결과

알람 구현후 다른 백엔드 팀원들이 직접 putty를 키고 서버를 접속하여 로그를 볼 필요가 없으니 귀찮은 과정이 단축되었고 클라이언트 팀원들 역시 백엔드 개발자와 소통할 필요 없이 알람을 보면 서버에서 어떤 오류가 발생했는지 알 수 있어 의사 소통 비용도 줄일 수 있었다.

클라이언트 팀원들이 문제를 발견하여 원인을 분석하고 백엔드 팀원들에게 어떻게 전달할지 고민하고 전달할 때 까지의 과정이 짧으면 10분 길면 1시간까지도 간다고 했을때 평균 35분정도의 시간을 잡으면 에러 발생을 모든 개발인원이 한번에 공유하고 소통비용, 확인 비용을 절약할 수 있었고 이를 10분정도의 시간이라 가정했을때 소통 비용의 약 3분의 1이라는 시간을 절약할 수 있었다.

이렇게 디스코드에 알람이 가게 구현했다. 에러 핸들러에 파라미터만 추가해주면 되는 간단한 작업이였지만.. 그 과정을 알아보면서 예외처리 흐름과 그 세부 클래스들에 대해서 알 수 있었고 스프링 빈 등록에 대한 부분도 다시 짚어볼 수 있었다.

최근 글