Spring

[Spring] GlobalExceptionHandler와 AOP 로깅 - 응답과 실패 기록의 책임 나누기

디버거러너 2026. 8. 25. 01:55

 

팀 프로젝트에서 AOP와 로깅 작업을 맡았다. 처음에는 Controller의 반복 로그를 AOP로 묶고, 예외는
BusinessException으로 던져 GlobalExceptionHandler에서 공통 응답으로 바꾸면 충분하다고 생각했다.
하지만 결제·환불 흐름에 업무 로그를 추가하는 중, 두가지 의문이 생겼다.

  1. 이 실패를 클라이언트에 어떤 HTTP 응답으로 돌려줄 것인가?
  2. 이 실패를 개발자가 나중에 어떤 정보로 추적할 것인가?

둘은 관련 있지만 같은 책임은 아니다. 이 글에서는 커머스 결제 시스템에 적용한
GlobalExceptionHandler, Controller AOP, 결제·환불 업무 로그를 기준으로 응답과 기록의 책임을
나눈 과정을 정리한다.


GlobalExceptionHandler는 오류 응답을 통일한다

Spring MVC에서는 @ControllerAdvice 안의 @ExceptionHandler를 이용해 여러 Controller에서 발생한
예외를 공통으로 처리할 수 있다. Controller마다 같은 try-catch와 오류 응답 생성을 반복하지 않고,
HTTP 상태와 응답 형식을 한곳에서 관리할 수 있다.

 

도메인별 오류는 ErrorCode enum으로 관리하고, 이를 담는 BusinessException을 사용했다.
ErrorCode에는 HTTP 상태, 클라이언트용 코드, 기본 메시지가 들어 있다.

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
    ErrorCode code = e.getErrorCode();

    return ResponseEntity.status(code.getStatus())
            .body(ApiResponse.error(code, e.getMessage()));
}

예를 들어 포인트가 부족하면 409 Conflict와 ‘포인트가 부족합니다.’라는 메시지를 공통 응답 형식에 담아 반환한다.

재고 부족, 포인트 부족, 이미 처리된 환불처럼 서로 다른 도메인 오류도 같은 형식으로 응답할 수 있다.

중요한 점은 예외가 발생한 계층이 아니라, ErrorCode가 나타내는 실패의 성격에 따라 HTTP 상태를 결정하는 것이다.


전역 Handler에서 다룬 예외와 방어 목적

전역 Handler가 모든 Java 예외를 하나씩 나열할 필요는 없다. HTTP 요청 경계에서 응답의 의미가 달라지는 대표 예외만 구분했다. @Valid 검증 실패, 깨진 JSON, 경로·쿼리 파라미터 타입 오류는 모두 클라이언트 요청 문제지만 Spring에서 발생시키는 예외가 서로 다르다.

 

예외 발생 상황 응답 방어하는 문제

(도메인별 오류는 ErrorCode enum으로 관리하고, 이를 담기 위해 프로젝트에서 정의한 BusinessException을 사용했다.)
예외 발생 상황 HTTP Status 처리하지 않았을 때 문제
BusinessException 포인트·재고 부족, 중복 환불 등 ErrorCode 기준 4xx·5xx 도메인 오류가 모두 500으로 처리되는 문제
MethodArgumentNotValidException @Valid DTO 검증 실패 400 검증되지 않은 요청값이 업무 로직으로 전달되는 문제
HttpMessageNotReadableException 깨진 JSON, 요청 본문 타입 변환 실패 400 요청 형식 오류가 서버 장애로 분류되는 문제
MethodArgumentTypeMismatchException 경로·쿼리 파라미터 타입 오류 400 /orders/abc 같은 요청이 500으로 처리되는 문제
NoResourceFoundException (미구현) 존재하지 않는 API·정적 페이지 요청 404 예정 잘못된 URL이 500과 ERROR 로그로 기록되는 문제
그 밖의 Exception 예상하지 못한 코드·DB 오류 500 내부 예외 정보 노출과 형식 없는 오류 응답

 

여기서 ‘방어한다’는 말은 예외 발생 자체를 막는다는 뜻이 아니다.

 

클라이언트 입력 오류가 500 서버 장애로 잘못 분류되는 것, 내부 예외 정보가 응답에 노출되는 것, Controller마다 다른 오류 형식을 만드는 것을 막는다는 의미다.

 

존재하지 않는 데모 페이지를 요청하면 NoResourceFoundException이 포괄적인 Exception Handler에 잡혀 서버 내부 오류를 뜻하는 500으로 응답되는 한계도 확인했다. 사용자 주소 오타를 서버 장애로 분류한 것이므로 404 처리가 필요하지만, 아직 구현하지 않은 개선 대상으로 남겼다.

 

반대로 모든 IllegalArgumentException을 일괄적으로 400으로 바꾸지는 않았다. 외부 요청을 변환하다 발생한 입력 오류인지, 이미 검증된 값을 내부 코드가 잘못 전달한 계약 위반인지에 따라 400과 500의 의미가 달라질 수 있기 때문이다. Spring Security 필터의 인증·인가 오류, DNS 오류, 프론트엔드 내부 오류도 MVC 전역 Handler의 처리 범위 밖에 있다.


응답을 통일해도 실패의 맥락까지 알 수 있는 것은 아니다

공통 응답은 클라이언트가 실패 이유를 이해하는 데 필요하다. 그러나 운영 중 특정 결제나 환불을 추적하기에는 PG사 처리 중 오류라는 응답만으로 부족하다. 어떤 결제에서 PG 취소가 실패했는지, 포인트가 얼마나 사용·복구됐는지, 실패 뒤 상태를 변경했는지는 해당 업무를 수행하는 코드가 가장 잘 알고 있다.

 

그래서 로그의 책임을 다음처럼 나눴다.

Controller AOP
  └─ Controller 메서드 실행 시작·완료·실패, URI, 처리 시간

Facade / Service의 결과를 아는 지점
  └─ 결제·환불 결과, 포인트 정산액, 보상 처리 같은 업무 맥락

GlobalExceptionHandler
  └─ 공통 오류 응답 생성, 예상하지 못한 서버 오류의 stack trace

 

처음에는 업무 로그를 전부 Facade에 모으는 방식도 생각했다. 하지만 포인트 사용액, 적립액, 복구액과 처리 후 잔액은 실제 정산을 수행하는 PointService가 가장 정확히 알고 있었다. 로그를 특정 계층에 억지로 모으기보다, 해당 결과가 확정되는 지점에만 남기는 것이 더 명확했다.

 

도메인별 ExceptionHandler도 단순히 파일을 나누기 위한 목적으로 추가하지 않았다. 결제와 주문이 실제로 다른 오류 응답 형식이나 별도 변환 규칙을 가져야 할 때는 나눌 수 있지만, 여기서는 하나의 전역 Handler로 공통 응답을 만드는 편이 단순했다.


AOP는 Controller 메서드의 반복 로그를 맡긴다

Controller마다 요청 시작·완료 시각, URI, 처리 시간을 직접 기록하면 같은 코드가 반복된다. 이 정보는 개별 도메인의 규칙이 아니라 여러 Controller에 걸친 공통 관심사다.

 

Spring AOP의 @Around Advice는 대상 메서드 실행 전후를 감쌀 수 있다. 이때 원래 메서드를 실행하려면 ProceedingJoinPoint.proceed()를 호출해야 한다.

@Around("execution(public * io.github.spartateam6.commercepaymentsystem.domain..controller..*.*(..))")
public Object logController(ProceedingJoinPoint joinPoint) throws Throwable {
    long startNanos = System.nanoTime();

    log.info("[요청 시작] httpMethod={} uri={} controller={}.{}()",
            httpMethod, requestUri, className, methodName);

    try {
        Object result = joinPoint.proceed();

        log.info("[요청 완료] httpMethod={} uri={} controller={}.{}() durationMs={}",
                httpMethod, requestUri, className, methodName,
                elapsedMillis(startNanos));
        return result;
    } catch (Throwable throwable) {
        log.warn("[요청 실패] httpMethod={} uri={} controller={}.{}() durationMs={} exceptionType={}",
                httpMethod, requestUri, className, methodName,
                elapsedMillis(startNanos),
                throwable.getClass().getSimpleName());
        throw throwable;
    }
}

 

다만 요청 본문 파싱과 인자 변환은 Controller 메서드를 호출하기 전에 수행된다. 

따라서

HttpMessageNotReadableException,
MethodArgumentTypeMismatchException,
MethodArgumentNotValidException

 

처럼 이 단계에서 발생한 오류는 메서드 AOP가 기록하지 못한다.

이 예외들은 GlobalExceptionHandler가 400 응답으로 변환한다.

Controller 실행 여부와 관계없이 모든 HTTP 요청을 기록해야 한다면 Filter나 Interceptor처럼 더 바깥 경계를 검토해야 한다.

 

같은 로그인 URI에 요청을 보냈을 때 첫 요청은 BusinessException과 함께 실패 WARN으로

다음 요청은 완료 INFO로 구분됐고 각 처리 시간도 기록됐다.

동일한 로그인 요청에서 실패 WARN과 완료 INFO가 구분된 로그

 

이 Aspect는 예외 원인을 분석하거나 응답을 만들지 않는다. 예외를 다시 던져 GlobalExceptionHandler가 응답을 만들게 하고, AOP는 “어떤 요청이 Controller 메서드에 도달해 얼마나 걸린 뒤 성공 또는 실패했는가”만 기록한다.

 

이 로그만으로도 로그인 요청이 실패한 사실은 알 수 있지만 비밀번호 오류인지 회원이 없는지는 알 수 없다. 이것이 AOP 로그와 업무·예외 로그를 따로 둔 이유다.


결제·환불에는 결과를 설명하는 업무 로그를 남긴다

공통 AOP 로그만으로는 “환불 요청이 완료됐다”는 사실만 알 수 있다. PG 취소가 필요했는지, 사용 포인트가 얼마나 돌아왔는지까지 확인하려면 업무 결과를 별도로 기록해야 한다.

포인트 정산은 PointService가 실제 증감과 처리 후 잔액을 알고 있으므로 그 자리에서 남겼다.

log.info(
        "환불 포인트 정산 반영 paymentId={} memberId={} restored={} revoked={} balanceAfter={}",
        payment.getId(),
        memberId,
        restoreAmount,
        revokeAmount,
        member.getPointBalance()
);

PG 호출 여부와 최종 환불 결과는 RefundFacade가 알고 있으므로 Facade에서 남겼다.

log.info(
        "PG취소 후 환불 완료 refundId={} paymentId={} pgRefundAmount={} pointRefundAmount={} pgCancelRequired=true",
        result.response().refundId(),
        request.paymentId(),
        result.response().pgRefundAmount(),
        result.response().pointRefundAmount()
);

포인트 전액 결제와 환불을 실행한 로그에서는 공통 요청 로그 사이에 업무 로그가 시간순으로 이어진다.

포인트 전액 결제 후 전액 환불까지 이어진 요청·업무 로그

 

이 화면에서는 다음 흐름을 확인할 수 있다.

  1. 주문 생성 요청이 완료된다.
  2. 포인트 1,500원이 사용되고 PG 금액이 0원이므로 PortOne을 호출하지 않고 결제가 완료된다.
  3. 환불 시 사용 포인트 1,500원이 복구된다.
  4. RefundFacade가 PG 호출 없는 포인트 전액 환불 결과를 기록한다.
  5. 마지막으로 환불 Controller 요청 완료와 처리 시간이 남는다.

DB를 직접 조회하지 않아도 한 요청 안에서 포인트 정산과 환불 분기가 어떻게 이어졌는지 볼 수 있었다. 다만 모든 정상 서비스 메서드에 INFO를 추가한 것은 아니다. 결제·주문 취소·환불처럼 상태가 크게 바뀌거나 외부 연동 여부를 확인해야 하는 지점으로 범위를 제한했다.


실패 자체보다 실패 후 처리를 기록해야 했다

PG 취소가 실패하면 “외부 호출 실패”만 중요한 것이 아니다. 환불 상태를 실패로 변경했는지, 예외를
호출자에게 다시 전달했는지도 함께 확인해야 한다.

try {
    paymentGateway.cancelPayment(
            result.portonePaymentId(),
            result.cancelReason(),
            result.pgRefundAmount().longValue()
    );
} catch (RuntimeException exception) {
    refundService.markFailed(result.response().refundId());

    log.warn(
            "PG취소 실패 후 실패 처리 refundId={} paymentId={} pgRefundAmount={}",
            result.response().refundId(),
            request.paymentId(),
            result.pgRefundAmount(),
            exception
    );
    throw exception;
}

 

이 실패 분기는 RefundFacade 단위 테스트의 실행 결과에서 확인했다.

PG 취소 실패 분기를 재현한 RefundFacade 단위 테스트 로그

 

[Test worker]라는 스레드 이름에서 알 수 있듯 위 로그는 실제 PortOne 장애가 아니라 단위 테스트에서 재현된 실패다. 실행 결과를 통해 PG 취소 실패 시 markFailed()를 호출하고 WARN을 남긴 뒤 예외를 다시 던지는 흐름을 확인했다. 실제 외부 통신 장애까지 검증하려면 PortOne 테스트 환경이나 별도의 통합 테스트가 필요하다.

 

테스트 코드에 로그를 추가한 것은 아니다. 테스트 대상인 RefundFacade의 실제 메서드가 실행되면서 해당 메서드에 작성한 로그가 콘솔에 출력됐다. 단위 테스트가 로그 문구 자체를 검증하지는 않지만, 실패 분기가 실행됐다는 점은 실행 결과에서 함께 확인할 수 있었다.


남은 문제: 5xx의 진짜 원인을 보존하기

PortOneClient는 HTTP 오류나 연결 오류를 PAYMENT_GATEWAY_ERROR라는 BusinessException으로 변환한다.

하지만 원래 예외를 cause로 연결하지 않아 타임아웃, 4xx 응답, 5xx 응답 중 무엇이 원인이었는지 stack trace에서 구분하기 어렵다.

 

또한 GlobalExceptionHandler의 일반 Exception Handler는 ERROR와 stack trace를 남기지만, BusinessException은 별도 Handler가 먼저 처리한다. 따라서 502인 PAYMENT_GATEWAY_ERROR도 공통 응답만 반환한다. 다음 리팩터링에서는 아래처럼 역할을 정리할 수 있다.

 

아래 코드는 적용한 구현이 아니라 다음 작업에서 이렇게 구현하면 어떨까하는 개선안이다,,
// BusinessException
public BusinessException(ErrorCode errorCode, Throwable cause) {
    super(errorCode.getMessage(), cause);
    this.errorCode = errorCode;
}

// PortOneClient
catch (HttpClientErrorException |
       HttpServerErrorException |
       ResourceAccessException e) {
    throw new BusinessException(ErrorCode.PAYMENT_GATEWAY_ERROR, e);
}

// GlobalExceptionHandler
if (code.getStatus().is5xxServerError()) {
    log.error(
            "서버 처리 중 예외가 발생했습니다. code={} status={}",
            code.getCode(),
            code.getStatus().value(),
            e
    );
}

이 정책을 적용한다면 Facade는 paymentId, refundId, 금액처럼 업무 식별 정보만 WARN으로 남기고, stack trace는 Handler에서 한 번만 기록하는 편이 낫다.

포인트 부족·재고 부족·중복 환불 같은 4xx 업무 거절은 공통 응답과 요청 실패 로그만 남기고, 조사해야 할 5xx만 ERROR와 원인 예외를 남길 수 있다.


개발 환경과 배포 환경은 같은 로그량이 필요하지 않다

로컬에서는 요청 시작·완료와 처리 시간을 모두 보는 것이 디버깅에 유리하다. 그러나 배포 환경에서 모든 정상 요청을 계속 INFO로 남기면 필요한 실패 로그를 찾기 어려워질 수 있다.

Spring Boot는 logging.level.<logger-name>으로 logger별 로그 레벨을 설정할 수 있다.

application-prod.yaml에서는 Controller AOP logger를 WARN으로 설정했다.

logging:
  level:
    io.github.spartateam6.commercepaymentsystem.global.logging.ControllerLoggingAspect: WARN

 

따라서 로컬에서는 [요청 시작], [요청 완료], [요청 실패]를 모두 확인하고, 배포 프로필에서는 Controller AOP의 정상 요청 INFO를 숨기고 실패 WARN은 유지한다.

이 설정은 해당 AOP logger에만 적용되므로 결제·환불·포인트 업무 로그의 운영 레벨은 서비스 성격과 로그 보관 정책에 따라 별도로 결정해야 한다.

 

이 방식에서는 포인트 부족이나 중복 요청처럼 정상적인 4xx 업무 거절도 WARN으로 남는다. 데모에서는 실패 흐름을 확인하는 데 유용하지만, 실제 운영에서는 발생량과 조사 필요성에 따라 INFO로 낮추거나 별도로 분류할 필요가 있다..


정리

이번 작업에서 GlobalExceptionHandler, AOP, 업무 로그의 책임을 다음과 같이 나눴다.

  • GlobalExceptionHandler: 예외를 공통 HTTP 오류 응답으로 변환한다.
  • Controller AOP: 실제로 호출된 Controller 메서드의 시작·완료·실패와 처리 시간을 기록한다.
  • Facade와 Service: 결제·환불·포인트 결과를 가장 정확히 아는 지점에서 필요한 업무 맥락만 기록한다.
  • 환경 설정: 로컬과 배포 프로필의 로그량을 분리한다.

처음에는 AOP 하나로 원하는 서비스 메서드를 묶으면 로깅이 끝날 것이라 생각했다. 그러나 AOP는 반복되는 Controller 실행 흐름을 기록하는 데 적합했고, 결제·환불의 세부 원인은 해당 흐름 안에서 직접 기록해야 했다.

예외를 던지는 것에서 끝나는 것이 아니라, 실패 뒤에 상태를 어떻게 바꿨고 어떤 정보가 남아야 다시 추적할 수 있는지까지가 로깅 설계의 범위였다.