오래된 B2B 엔터프라이즈 시스템에서는 비즈니스 요건이 추가될 때마다 개발자마다 고유의 커스텀 예외(Custom Exception)를 양산하는 경향이 있습니다.
그 결과 예외 처리 체계가 파편화되고, 불명확한 에러 로그와 정제되지 않은 예외 전파로 인해 실제 고객 장애 대응(CS) 리소스가 과도하게 낭비되는 문제가 발생했습니다.
기존 시스템의 커스텀 예외를 전수 리스트업하고 구조적 한계를 분석하는 사전 조사를 진행했습니다.
Exception, IOException 등 Checked Exception을 무분별하게 상속받아 시그니처(throws)에 전파를 강제하는 코드와, 상단 프레젠테이션 레이어에서 예외를 먹어버리는(Swallow) 코드가 뒤섞여 있었습니다.GlobalExceptionGlobalException → RuntimeException(Unchecked)을 상속하는 느슨한 공통 조상 구조는 갖추어져 있었습니다.RuntimeException 기반이므로 불필요한 컴파일 타임 에러나 명시적 예외 선언에서는 자유로웠고, 호출자에게 에러 정보를 전달하는 후속 처리 메서드(에러 코드·메시지 getter) 자체는 잘 정의되어 있었습니다.GlobalException 단일 접점을 활용한 AOP 일원화가 최소 비용 경로였습니다.전수 조사 결과를 3가지로 태깅했습니다.
⚠️ 미사용 예외의 일괄 삭제는 별도 릴리즈로 분리했습니다. 리플렉션·설정 파일(문자열 FQCN)·직렬화 경로에서 참조될 수 있어, 컴파일러만으로는 "진짜 미사용"을 100% 보장할 수 없기 때문입니다.
고객사는 비즈니스 상황마다 고유한 커스텀 예외 신설을 빈번하게 요청했습니다. 그러나 실제로 그 예외들이 클라이언트에게 내려주는 신호는 대부분 다음 몇 가지로 수렴했습니다.
즉 "커스텀 예외 개수"는 계속 늘어나도 "응답 코드의 종류"는 고정입니다. 이 괴리를 흡수하려면, 예외 클래스는 자유롭게 늘리되 HTTP Status로의 매핑을 한 곳에서 규정·통일하는 AOP 관리 지점이 필요했습니다. (예외 → ErrorCode → HTTP Status, N:1 수렴)
예외 관리 전략을 직관적이고 표준화된 AOP 예외 제어 모델로 일원화하기 위해 4단계를 적용했습니다.
곳곳에 하드코딩된 System.out.println() / e.printStackTrace()를 걷어내고 프레임워크 표준 로깅과 연동했습니다.
printStackTrace()는 (1) 동기 콘솔 I/O로 스레드를 블로킹하고, (2) 로그 레벨·수집기(ELK 등)와 무관하게 표준 에러로 직접 출력되며, (3) 수십 줄의 프레임 전체를 쏟아내 정작 원인 프레임이 묻힙니다.LoggingFacade를 두어 핵심 원인(Cause)과 최상단 프레임만 정제한 단일 라인 포맷으로 기록합니다.// 개선 전: 무분별한 System.out 및 stack trace 직접 출력
try {
businessService.execute(data);
} catch (GlobalException e) {
System.out.println("에러 발생: " + e.getMessage());
e.printStackTrace();
}
// 개선 후: Facade 기반 log4j 구조화 로깅
public class LoggingFacade {
private static final Logger logger = Logger.getLogger(LoggingFacade.class);
public static void logError(String contextMessage, Throwable throwable) {
// 전체 Stack Trace 대신 핵심 원인 메시지 + 최상단 프레임만 정제
String simplifiedStack = getSimplifiedStackTrace(throwable);
logger.error(String.format("[%s] Error Message: %s | Stack Message: %s",
contextMessage, throwable.getMessage(), simplifiedStack));
}
private static String getSimplifiedStackTrace(Throwable t) {
if (t == null) return "No Stack Trace";
StackTraceElement[] elements = t.getStackTrace();
if (elements.length > 0) {
StackTraceElement topFrame = elements[0];
return String.format("%s.%s(Line:%d)",
topFrame.getClassName(), topFrame.getMethodName(), topFrame.getLineNumber());
}
return t.getMessage();
}
}운영 팁: 간소화 로그는 평시 모니터링용입니다. 심층 디버깅을 위해 원본 스택은
logger.debug("stack", throwable)처럼 별도 레벨로 남기고, 로그 설정에서 필요 시에만 활성화합니다.
모든 API 응답을 통일된 JSON 포맷으로 반환하며, 비즈니스 예외 → HTTP Status의 N:1 수렴 매핑을 자동 수행하는 전역 처리기를 구축했습니다. (2.4의 괴리를 흡수하는 지점 — 예외 클래스는 늘어나도 HTTP Status는 고정)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(GlobalException.class)
public ResponseEntity<ErrorResponse> handleGlobalException(GlobalException ex) {
ErrorCode errorCode = ex.getErrorCode(); // 예외에 매핑된 고유 코드 + HTTP Status
LoggingFacade.logError("API Exception Handler Blocked", ex); // 3.1 간소화 로깅 재사용
ErrorResponse response = new ErrorResponse(
errorCode.getReturnCode(),
ex.getMessage() != null ? ex.getMessage() : errorCode.getDefaultMessage()
);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getHttpStatus()));
}
@ExceptionHandler(Exception.class) // 매핑되지 않은 미분류 예외의 안전망
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
LoggingFacade.logError("Unmapped Exception", ex);
ErrorResponse response = new ErrorResponse(ErrorCode.INTERNAL_ERROR.getReturnCode(),
ErrorCode.INTERNAL_ERROR.getDefaultMessage());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}// ErrorCode Enum: 세분화된 returnCode(고객사 요청 흡수)는 자유롭게 늘리되,
// httpStatus는 4xx/5xx의 정해진 집합으로만 수렴시킨다.
public enum ErrorCode {
// returnCode(비즈니스 식별자, N) httpStatus(전송 신호, 1) logLevel
INVALID_PARAMETER("E4001", 400, Level.WARN),
DUPLICATE_REQUEST ("E4002", 400, Level.WARN),
POLICY_VIOLATION ("E4090", 409, Level.WARN),
AUTH_EXPIRED ("E4011", 401, Level.INFO),
NO_PERMISSION ("E4030", 403, Level.WARN),
EXTERNAL_TIMEOUT ("E5020", 502, Level.ERROR),
INTERNAL_ERROR ("E5000", 500, Level.ERROR);
// ... 고객사별 커스텀 예외는 여기에 returnCode만 추가, httpStatus는 위 집합 재사용
}ErrorCode는 Enum으로 관리하여 "returnCode ↔ HTTP Status ↔ 기본 메시지 ↔ 로그 레벨"을 한 곳에서 규정합니다.ErrorCode 한 줄만 추가하고, HTTP Status는 기존 4xx/5xx 집합을 재사용합니다. 클라이언트 계약(상태 코드 기반 분기)은 흔들리지 않고, 세부 식별은 returnCode와 메시지로 전달됩니다.try-catch로 응답을 조립하던 중복 코드가 사라집니다. (횡단 관심사의 AOP 분리)B2B 서비스는 화면(Full-page) 요청과 비동기 Ajax 요청이 공존하므로, 요청 유형에 따른 지능형 예외 분기가 필요합니다.
① Tomcat web.xml + ExceptionPageResolver 연동 — 상태 코드/예외 타입별로 컨테이너 수준에서 안내 페이지로 포워딩합니다.
<error-page>
<error-code>404</error-code>
<location>/WEB-INF/views/error/404.jsp</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/WEB-INF/views/error/500.jsp</location>
</error-page>
<error-page>
<exception-type>com.enterprise.exception.GlobalException</exception-type>
<location>/WEB-INF/views/error/globalError.jsp</location>
</error-page>② Ajax 비동기용 에러 필터 — Ajax 요청에 렌더링된 에러 HTML이 반환되어 프론트엔드가 파손되는 것을 막습니다. X-Requested-With: XMLHttpRequest 헤더를 분석하여 페이지 리졸빙에서 제외하고 순수 JSON 에러만 전달합니다.
public class AjaxErrorFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
chain.doFilter(request, response);
} catch (Exception ex) {
if ("XMLHttpRequest".equals(httpRequest.getHeader("X-Requested-With"))) {
httpResponse.setContentType("application/json;charset=UTF-8");
httpResponse.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
httpResponse.getWriter().write(
String.format("{\"success\":false,\"message\":\"%s\"}", ex.getMessage()));
} else {
throw ex; // 일반 브라우저 요청은 컨테이너 예외 처리기로 재전파 → 에러 페이지
}
}
}
}GlobalException만 던지고 끝낸다. 로깅·응답 포맷·페이지 분기는 전부 경계(Advice/Filter/Resolver)에서 처리한다.@Aspect로 실행 시간·주요 파라미터·예외 발생 지점을 표준 포맷으로 남겨, GlobalExceptionHandler의 간소화 로그와 동일한 상관관계 키(traceId)로 묶습니다.printStackTrace 노이즈 제거로 로그 수집량·검색 비용이 줄었습니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.