개발 slecs

관리자 로깅 유틸 추가로 운영 디버깅 가시성 개선

목차

관리자 쪽 컨트롤러에서 예외가 터졌을 때 로그가 너무 단조로웠다. e.getMessage() 한 줄 찍고 끝나는 패턴. 운영에서 실제로 뭔가 이상해졌을 때 로그를 뒤지다 보면 "어느 요청에서, 어떤 파라미터로, 어떤 경로로 들어왔는지"가 없어서 재현이 어렵다는 걸 여러 번 경험했다. 증상은 보이는데 원인 파악이 안 되니 디버깅 시간이 불필요하게 길어진다.

이번에 PrettyLogUtil을 추가하고 AdminControllerAdvice.java를 손본 건 그 연장선이다. 작업 규모는 작지만 운영에서 체감하는 차이는 생각보다 크다.

AdminControllerAdvice와 로깅의 균형

Spring MVC에서 @ControllerAdvice는 컨트롤러 레이어 전반의 예외를 가로채는 역할을 한다. 어드민 쪽은 특히 요청 파라미터 바인딩 실패(BindException, MethodArgumentNotValidException)나 서비스 로직에서 던지는 RuntimeException이 자주 올라온다.

문제는 이걸 그냥 잡아서 에러 응답만 내면 로그에 아무것도 안 남는다는 것이고, 반대로 스택트레이스를 풀로 찍으면 운영 로그가 너무 시끄러워진다는 것이다. 사이에 균형점을 잡아야 한다.

기존 처리 패턴이 이런 식이었다면:

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
    log.error("Error: {}", e.getMessage());
    return ResponseEntity.status(500).body("오류가 발생했습니다.");
}

이번에 바꾼 방향은 중요 입출력값은 INFO로, 예외 요약은 WARN/ERROR로 분리하고, PrettyLogUtil로 파라미터를 포맷해서 찍는 것이다. 운영에서 로그를 grep할 때 "이 요청이 어떤 값을 들고 왔었는지" 한눈에 보이도록.

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntimeException(HttpServletRequest request, RuntimeException e) {
    PrettyLogUtil.logRequest(log, request);   // 요청 URI, 파라미터 정리해서 INFO
    log.warn("[AdminAdvice] RuntimeException - {}", e.getMessage());
    return ResponseEntity.status(500).body(ErrorResponse.of("처리 중 오류가 발생했습니다."));
}

스택트레이스를 기본으로 안 찍는 게 맞냐고 할 수 있는데, 운영에서 로그 볼륨이 쌓이면 진짜 필요한 정보가 묻힌다. 재현에 필요한 건 어떤 요청이었는지고, 코드 레벨 추적은 개발 환경에서 한다. 스택트레이스는 최초 발생 시 ERROR로 한 번만 남기고, 이후 같은 예외 유형은 WARN으로만 찍는 방식이 운영 로그 관리에 훨씬 낫다.

PrettyLogUtil 설계 포인트

별도 유틸 클래스로 뺀 이유가 있다. 같은 로깅 패턴을 여러 컨트롤러 내부 클래스에 복사-붙여넣기 하다 보면 나중에 포맷 바꿀 때 전부 뒤져야 하는 상황이 온다. 내부 클래스에서도 같은 유틸을 공통으로 쓰게 하려면 정적 메서드로 모아두는 게 맞다.

PrettyLogUtil이 담당하는 역할:

메서드 역할
logRequest(log, request) URI, HTTP 메서드, 파라미터 맵 정리해서 INFO 출력
logParams(log, label, obj) 임의 객체를 JSON-like 포맷으로 요약 출력
logResult(log, label, result) 처리 결과값 요약 (null 방어 포함)

null이나 빈 값이 들어왔을 때 NPE 안 나도록 방어 처리를 유틸 내부에서 잡고, 호출부는 신경 안 써도 된다. 이게 핵심이었다. 각 컨트롤러에서 null 체크 달고 로그 찍는 패턴을 반복하면 어느 시점에서 반드시 구멍이 생긴다. 실수는 반복되는 코드에서 난다.

포맷도 의식적으로 단순하게 유지했다. key=value 형태로 한 줄에 몰아찍거나, 파라미터가 많으면 개행해서 읽기 좋게 정렬하는 정도. JSON 직렬화 라이브러리 끌어오는 건 굳이 안 했다. 어드민 로그 포맷은 사람이 직접 grep해서 읽는 게 주 용도라 파싱보다 가독성이 우선이다.

예외 전파 전략과 트랜잭션 범위

이번 작업에서 고민했던 건 예외를 @ControllerAdvice까지 올릴 것이냐, 서비스 레이어에서 잡아서 결과값으로 전환할 것이냐였다. 두 전략을 섞어 쓰면 나중에 어떤 예외가 어디서 잡히는지 파악이 안 되니 기준을 명확히 해야 한다.

기준을 이렇게 정리했다:

  • 입력 검증 실패, 권한 이상 같은 "클라이언트 오류"는 서비스에서 커스텀 예외로 던지고 Advice에서 4xx 응답으로 변환
  • DB 트랜잭션 실패나 외부 연동 오류 같은 "시스템 오류"는 RuntimeException 그대로 올려서 Advice에서 5xx 처리 + 로그
  • @Transactional 범위는 서비스 메서드 단위로만. 컨트롤러에는 걸지 않음

MyBatis 환경에서 트랜잭션을 컨트롤러까지 끌고 오면 커넥션 점유 시간이 길어진다. 어드민 화면은 여러 쿼리를 묶어서 조회하는 패턴이 많은데, JSP 뷰 렌더링 전까지 커넥션이 안 닫히면 커넥션 풀에 부담이 쌓인다. 서비스 레이어에서 트랜잭션 닫고 컨트롤러는 뷰 제어만 담당하는 구조가 운영 안정성에 낫다.

내부 클래스에서 비즈니스 로직 처리할 때도 마찬가지다. 요청 바인딩 객체와 결과 매핑 객체를 내부 클래스로 정의하는 건 응집도 면에서 나쁘지 않은데, 트랜잭션 경계가 내부 클래스 메서드 안으로 들어가면 호출 구조가 복잡해진다. 트랜잭션은 서비스 레이어 퍼블릭 메서드에서만 제어하고, 내부 클래스는 데이터 구조 역할만 하도록 분리했다.


작업 후 로컬에서 어드민 화면 기동해서 정상 요청, 바인딩 실패, 서비스 예외 케이스 각각 확인하고 개발 DB에 반영한 다음 배포했다. 로그 포맷이 바뀐 거라 배포 전에 팀에 미리 공유했다. 로그 수집이나 모니터링 파이프라인이 있으면 포맷 변경으로 파싱이 깨지는 경우가 있어서다.

이런 유틸성 작업은 기능 티켓에 묻히기 쉬운데, 쌓아두면 나중에 디버깅 시간을 확실히 줄여준다. "이게 뭔 요청이었지" 하고 로그 뒤지는 시간이 없어지는 것만으로도 체감이 다르다.

댓글 0

첫 댓글 달아줘.