전역 예외 처리까지 만들었으면, 이제 실제 운영에서 바로 나오는 질문을 정리할 차례다.
- “message 문자열만으로 충분한가?”
- “프론트에서는 에러를 어떻게 구분하지?”
- “운영 중 장애 나면 로그를 어떻게 추적하지?”
- “어디서 로그를 남겨야 하지?”
이번 글에서는 에러를 ‘운영 가능한 형태’로 만드는 패턴을 정리한다.
0. 이 글에서 다루는 것
이번 단계의 목표는 다음 3가지다.
- 에러 코드를 enum으로 관리하는 이유
- 요청 단위 추적 ID를 응답/로그에 남기는 패턴
- 로그를 어디서, 어떤 레벨로 남기는지 기준
모니터링 툴, 분산 트레이싱 같은 건 여기 범위 아님.
1. 왜 message 문자열만으로는 부족한가
현재 구조는 보통 이런 응답이다.
{
"status": 404,
"message": "해당 근무 기록이 없습니다."
}
이 구조의 문제는 명확하다.
- 메시지는 사람용이다
- 프론트/클라이언트에서 분기 처리하기 애매하다
- 메시지가 바뀌면 클라이언트 로직도 흔들릴 수 있다
그래서 보통 사람은 message를 보고,시스템은 errorCode를 본다
2. 에러 코드 enum 도입
2-1. 에러 코드 enum 정의
public enum ErrorCode {
INVALID_REQUEST(400, "잘못된 요청입니다."),
NOT_FOUND(404, "리소스를 찾을 수 없습니다."),
INTERNAL_ERROR(500, "서버 내부 오류입니다.");
private final int status;
private final String message;
ErrorCode(int status, String message) {
this.status = status;
this.message = message;
}
public int status() {
return status;
}
public String message() {
return message;
}
}
여기서 포인트:
- HTTP 상태 코드 + 기본 메시지를 한 세트로 관리
- 문자열을 여기저기 흩뿌리지 않음
- 에러 종류가 늘어나도 한 곳에서 관리됨
2-2. 커스텀 예외에 에러 코드 연결
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.message());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
그리고 사용은 이렇게 한다.
throw new BusinessException(ErrorCode.NOT_FOUND);
3. 에러 응답에 errorCode 추가
3-1. 에러 응답 DTO 확장
public record ErrorResponse(
int status,
String code,
String message,
String traceId
) {}
- status: HTTP 상태
- code: 시스템용 에러 코드
- message: 사용자/개발자용 메시지
- traceId: 요청 추적용 ID
3-2. 전역 예외 처리기 수정
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(
BusinessException ex,
HttpServletRequest request
) {
ErrorCode errorCode = ex.getErrorCode();
ErrorResponse body = new ErrorResponse(
errorCode.status(),
errorCode.name(),
errorCode.message(),
request.getAttribute("traceId").toString()
);
return ResponseEntity.status(errorCode.status()).body(body);
}
}
4. 추적 ID(traceId)는 왜 필요하냐
운영 중 장애가 나면 보통 이런 일이 생긴다.
- 프론트: “이 요청에서 500 났어요”
- 백엔드: “로그가 너무 많아서 못 찾겠는데요?”
그래서 요청 하나당 ID를 하나 부여한다. → “해당 에러 응답의 traceId로 로그를 찾기”
4-1. 간단한 traceId 생성 필터
@Component
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = UUID.randomUUID().toString();
request.setAttribute("traceId", traceId);
filterChain.doFilter(request, response);
}
}
- 요청 시작 시 traceId 생성
- request에 저장
- 예외 응답/로그에서 공통 사용
보통 MDC를 쓰지만, 지금은 이 정도면 충분
→ MDC는 로그에 요청 단위 컨텍스트 정보를 자동으로 포함시키기 위한 기능으로, traceId 등을 ThreadLocal에 저장해 로그를 요청 단위로 추적할 수 있게 한다
5. 로그는 어디서 남겨야 하나
컨트롤러는 최소, 서비스는 의미 있는 지점만, 예외 처리기는 에러 로그의 중심
5-1. Controller
- 보통 로그 거의 안 남김
- 남기더라도 요청 시작/종료 정도
log.info("POST /api/shifts 요청");
5-2. Service
- 비즈니스 분기 지점
- 정책적으로 중요한 판단
log.warn("이미 존재하는 근무 기록. userId={}", userId);
5-3. GlobalExceptionHandler
- 에러 로그의 최종 집결지
log.error("예외 발생. traceId={}, code={}",
traceId, errorCode.name(), ex);
여기서:
- stack trace는 서버 로그에
- 클라이언트 응답에는 최소 정보만
6. 로그 레벨 기준
| 레벨 | 언제 |
| INFO | 정상 흐름, 요청 시작/종료 |
| WARN | 정책 위반, 예상 가능한 문제 |
| ERROR | 예외 발생, 장애 원인 |
DEBUG/TRACE는 개발 중에만.
7. 최종 에러 응답 예시
{
"status": 404,
"code": "NOT_FOUND",
"message": "리소스를 찾을 수 없습니다.",
"traceId": "3f2a8c1e-9e6b-4c44-8c5e-1a0c2f9d8a77"
}
- 프론트: code로 분기
- 운영: traceId로 로그 추적
- 사용자: message 확인
8. 지금 단계에서 가져가야 할 감각
- 에러는 문자열이 아니라 코드로 관리
- 예외는 던지고, 응답은 전역에서 통제
- 로그는 많이보다, 의미 있게
'백엔드' 카테고리의 다른 글
| [스프링 로드맵] F-1. 테스트란 (0) | 2026.01.20 |
|---|---|
| [스프링 로드맵] E-1. 전역 예외 처리 기본 - 응답을 하나로 묶는 이유 (1) | 2026.01.20 |
| [스프링 로드맵] D-2. Validation 응답 설계 - BindingResult vs 예외 처리 (0) | 2026.01.20 |
| [스프링 로드맵] D-1. Validation 기본 - DTO 검증이 필요한 이유 (0) | 2026.01.20 |
| [스프링 로드맵] C-3. Repository 실전 - JpaRepository가 해주는 것들 (1) | 2026.01.19 |