전부터 계속 백엔드 서버만 개발을 해오다 보니, 프론트엔드와 협업 또는 독자적인 프론트 공부를 한 적이 없었습니다. 하지만 이번 인턴 생활을 하던 도중 과제에 프론트엔드도 구현하라는 요구사항이 있었습니다.
프론트 쪽은 일단 AI 에이전트 도움을 받아가면서 구현 및 공부를 했는데, 프론트와 백엔드를 연동하면서 서버에서 발생한 문제를 Http Status와 Custom Error Message를 사용자 화면에 보여주게 하고 싶었는데, 백엔드 서버만 만들 때 처럼 Service 계층에서 특정 로직이 발생했을 때
throw new IllgerArgumentException("중복된 이메일입니다");
위 코드처럼 오류를 처리해왔었습니다. 하지만 프론트와 연동을 시작하니 문제가 생겼습니다. 저는 "중복된 이메일입니다"라는 메시지를 프론트 alert에 보여주고 싶었지만 실제 응답은 이랬습니다.
{
"timestamp": "...",
"status": 500,
"error": "Internal Server Error",
"path": "/api/users"
}
그래서 이번 글은 왜 우리가 서버에서 메시지를 담아서 오류가 나도 프론트에서는 그 메시지가 보이지 않았을까? 이걸 해결하려면 어떻게 해야할까?에 대하여 글을 작성하겠습니다.
위에 말로만 설명해서 감이 잘 안 오셨을 수도 있을텐데 일단 이해를 돕기위한 간단한 코드 예제를 보면서 진행해보겠습니다.
// Controller
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class Controller {
private final UserService service;
@PostMapping
public ResponseEntity<?> register(@RequestBody RequestDto request) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.register(request));
}
}
// Service
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public ResponseDto register(RequestDto request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("중복된 이메일 입니다.");
}
User user = User.create(request);
userRepository.save(user);
return ResponseDto.of(user.getEmail(), user.getName());
}
}
일단 코드를 보시면 바로 유저의 가입 API라는 것이 보이실텐데 이 코드를 실행해서 Postman으로 통해 최초 가입하고 이후 똑같은 이메일로 가입을 다시 해보겠습니다.


혹시 차이가 보이실까요? 저희는 프론트엔드와 작업을 하기 위해서는 JSON을 통해 프론트에서 알기 쉽게 데이터를 가공하여 보여줘야 합니다. 물론 오류도 마찬가지입니다.
하지만 위에를 보면 저희 서버 콘솔에만 Service에서 작성한 오류 메세지가 보이고 Postman으로 API 요청을 통해 받은 응답에는 JSON이긴 하지만 저희가 작성한 오류 메시지가 보이지 않고, 그냥 Spring에서 기본적으로 발생시키는 오류를 JSON 형태로 올려보내서 500 Internal Server Error가 나타나는 것입니다.
현재 코드로 중복 이메일 가입 시도 흐름을 보겠습니다.
(1) Tomcat이 HTTP 요청 수신 → DispatcherServlet의 doDispatch()로 진입 합니다.
(2) 핸들러 탐색
HandlerMapping이 /api/users POST와 매칭되는 컨트롤러 메서드(Controller#register)를 찾습니다.
(3) 컨트롤러 실행
HandlerAdapter가 컨트롤러 메서드 호출.
@RequestBody로 JSON → RequestDto 바인딩 완료
(4) 서비스 호출
service.register(request) 진입 → existsByEmail(email) 호출 → true 반환(이미 등록됨)
(5) 예외 발생
@PostMapping
public ResponseEntity<?> register(@RequestBody RequestDto request) {
ResponseDto response = service.register(request); → 예외 발생!
return ResponseEntity.status(HttpStatus.CREATED).body(response); → 코드 실행 X
}
throw new IllegalArgumentException("중복된 이메일 입니다.");
여기서 컨트롤러 메서드의 return ResponseEntity.status(CREATED)... 라인은 실행되지 못하고 중단됩니다.(스택이 예외로 튀어나감)
(1) 예외 전파 → DispatcherServlet 예외 처리 단계
doDispatch()가 예외를 잡아 processHandlerException()으로 넘깁니다. HandlerExceptionResolver 리스트를 순회합니다.
- ExceptionHandlerExceptionResolver → @ExceptionHandler 있는지 확인
- ResponseStatusExceptionResolver → @ResponseStatus 붙어있는지 확인
- DefaultHandlerExceptionResolver → MVC 표준 예외 처리
하지만 IllegalArgumentException은 어느 곳에서도 처리되지 않습니다 → 결국 Null 반환
(1)컨테이너 에러 디스패치
모든 Resolver가 패스하면 예외는 ServletContainer로 전파됩니다.→ 이를 서블릿 컨테이너가 ERROR 디스패치를 수행, /error로 forward 합니다.
(2) Spring Boot의 BasicErrorController 동작
컨테이너가 실어 준 javax.servlet.error.* 속성을 ErrorAttributes로 읽어 기본 에러 맵(JSON/HTML) 생성합니다.
Content Negotiation 결과 JSON 선호면 기본 JSON 에러 바디(timestamp/status/error/message/path 등)로 응답합니다.
{
"timestamp": "2025-09-13T00:30:12.345+09:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/users"
}
위 코드가 500 기본 JSON 에러 포맷입니다.
Spring에서 @RestControllerAdvice와 @ExceptionHandler를 통해 발생한 예외를 한 곳에서 모아 처리할 수 있습니다.
이를 기반으로 CustomException과 ErrorCode를 정의하고, ErrorResponse로 변환해 내려주는 구조를 만듭니다.
@AllArgsConstructor
@Getter
public enum ErrorCode {
NOT_FOUND_EMAIL(HttpStatus.NOT_FOUND, "U001","없는 이메일 입니다.");
private final HttpStatus status;
private final String code;
private final String message;
}
@Getter
public class CustomException extends RuntimeException{
private final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.getMessage()); // RuntimeException.message에 저장
this.errorCode = errorCode;
}
}
@Getter
@AllArgsConstructor
public class ErrorResponse {
private final int status;
private final String code;
private final String message;
public static ErrorResponse of(ErrorCode errorCode) {
return new ErrorResponse(
errorCode.getStatus().value(),
errorCode.getCode(),
errorCode.getMessage()
);
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e, HttpServletRequest request) {
ErrorCode errorCode = e.getErrorCode();
ErrorResponse errorResponse = ErrorResponse.of(errorCode);
return ResponseEntity.status(errorCode.getStatus()).body(errorResponse);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleEtc(Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(500, "S001", "서버 오류가 발생했습니다."));
}
}
processHandlerException()에서 예외 resolver 체인 순회를 한다는 것을 기억할 것입니다.HandlerExceptionResolver 리스트를 순서대로 호출합니다.ExceptionHandlerExcetpionResolver를 먼저 호출하는데 @ExceptionHandler 메서드를 갖는 컨트롤러/@ControllerAdvice(= 우리가 만든 @RestControllerAdvice)를 스캔해, 현재 예외와 매칭되는 메서드를 찾습니다. 일단 저희의 오류는 바로 매칭이 됩니다.ErrorResponse를 만들고 ResponseEntity로 감싸서 반환) → 리턴 값 처리(ErrorResponse를 HttpMessageConverter에게 전달되어 JSON 직렬화) 하여 프론트에게 JSON 형태로 에러 포맷을 보여줍니다.UserService.register()
...
if (userRepository.existsByEmail(request.getEmail())) {
throw new CustomException(ErrorCode.EMAIL_ALREADY_IN_USE); → 변경
}
...

이로써 프론트는 언제나 response.data.message로 사용자 메시지를 안전하게 꺼낼 수 있습니다.