15장 요청 본문 로깅에서는 클라이언트에서 서버에 어떤 Body 데이터를 가지고 서버에 들어오는지를 로그를 찍어서 추적하고 싶었다.
지금까지 개발할 때 @ControllerAdvice 나 @RestControllerAdivce 로 프론트 백사이 응답구조를 맞춰주는것을 정석으로 알고 맞춰했음.
혼자서 기능개발 한다고 포맷을 신경쓰지않고 그냥 하다보니 왜 그렇게 해왔는지 참의미가 이해되어서 파고들다보니 만들어진 글
목차
1. 요청 응답시 서버 개발자의 배려가 부족하다면...
2. @Controller Advice 와 @ExceptionHandler
3. 백엔드 응답 전략 (코드 구현)
4. 프론트엔드 응답별 성공/실패 분기 처리전략
1. 요청 응답시 서버 개발자의 배려가 부족하다면...
어차피 HTTP 표준이라는게 있으니까 응답에 상태코드 + 헤더 + 바디들어갈거고 이정도면 알아서 판단하겠지하고
백엔드 개발시 프론트엔드 개발을 배려하지 않고 내맘대로 응답을 던진다고 생각해보자
바디도 예전엔 XML로 프론트와 백엔드 통신 포맷을 맞췄다가 무겁고 파싱이 느려서 가벼운 JSON(JavasScript Object Notation)으로 통일시키기로 했다 (애초에 JavaScript 객체니까)
Spring 에서 Jackson 으로 Java 객체 JSON 으로 자동 직렬화 해줄거니까 뭐 이정도면 충분히 배려할만큼 했다고 생각하고 DTO 던지면 알아서 받아먹겠지하고 끝낸다고 생각해보자
@GetMapping("/user")
public UserDto getUser() {
return new UserDto("Alice", 25); // 자동으로 200 OK + JSON
}
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 123
{
"name": "Alice",
"age": 25
}
이렇게 하면 프론트에서는 상태코드는 200 OK, Content-Type 은 application/json 으로 들어갈 것이고 바디 포맷은 DTO 에 정의된 대로 { "name": "Alice", "age": 25 } 이렇게 반환될 것이다. 뭐가 문제일까
@GetMapping("/user")
public UserDto getUser() {
throw new IllegalArgumentException("잘못된 요청");
}
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"timestamp": "2025-04-12T13:04:56.789+00:00",
"status": 404,
"error": "Not Found",
"message": "유저가 없습니다",
"path": "/user"
}
예외가 발생하는 경우엔 응답받는 스타일이 다르다.
즉 프론트에선 response 객체를 받아서 res.data 로 UI 데이터 뿌려주고 있었는데 예외가 발생하는 순간 undefined 에러가 나면서 터진다. try catch 로 감싸거나 if 문 분기하면서 성공과 예외로직을 나눠야한다.
성공이 되어도 문제인게 데이터가 예제처럼 object 일지 string 일지 boolean 일지 array 일지 모르기 때문에 일일이 다 분기처리해야하고 응답에 대응할 수 있는 공통 컴포넌트를 만들기도 곤란하다.
만약 타입스크립트를 쓴다면, 백엔드와 약속된 구조를 타입 (ex <UserDto> )으로 정의해놓고 그걸 기준으로 정적 타입검사와 자동완성을 제공해주지만 막 던져서 구조가 예상이 안되면 any 쓰면서 그 의미도 다 죽는다.
여튼 성공이든 실패든 약속없이막 던지면 프론트엔드는 코드복잡도와 유지보수 지옥에 빠지면서 개판이 된다.
// 성공시
{
"code": "SUCCESS",
"message": "요청 처리 완료",
"data": {
"name": "Alice",
"age": 25
}
}
// 에러시
{
"code": "USER_NOT_FOUND",
"message": "해당 유저는 존재하지 않습니다",
"data": null
}
성공이든 실패든 이렇게 깔끔하게 데이터가 온다면 어떨까?
프론트엔드에서는 일관되게 처리하기가 쉽다. 공통 컴포넌트나 유틸함수를 만들기도 용이하고
Swagger 등 문서화와 pagination이나 metadata 추가도 유연하다 (?)
HTTP가 통신 포맷을 정해놨지만 Body는 공통된 양식을 정의하지 않았다. body 는 비즈니스 레벨에서 만든 약속이어서 개발자들이 약속해야함.
2. @Controller Advice 와 @ExceptionHandler
@ControllerAdvice 는 AOP 느낌이 나는 '공통 관심사 처리용 Bean' 임. AOP처럼 예외처리를 '횡단 관심사' 로 분리하고 처리한다는 말. AOP가 메서드 실행전후 공통로직을 삽입한다면 얘는 예외가 발생했을때 공통핸들러에서 에러들을 끌어안아주는 역할을 한다고 보면됨.
// ❌ 이런 방식은 권장 안 함
public ApiResponse<?> getUser() {
try {
User user = userService.getUser();
return ApiResponse.success(user);
} catch (Exception e) {
return ApiResponse.error("ERR", e.getMessage());
}
}
만약 이렇게 컨트롤러 마다 혹은 서비스계층마다 에러대응을 한다면 중복 로직이 폭발하고 테스트 디버깅 어렵고 읽기 싫은 코드가 된다. 이럴때 컨트롤러에서 에러 안잡고 ControllerAdvice 로 넘겨서 예외처리 깔끔하게 싹 공통적으로 해버리면 정갈하다.
// Controller
@GetMapping("/user/{id}")
public ApiResponse<?> getUser(@PathVariable Long id) {
User user = userService.getUserById(id); // 예외 발생해도 try-catch 없음
return ApiResponse.success(user);
}
// Service
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException());
}
그러니까, 굳이 컨트롤러 안에서 try catch 하면서 에러 일일이 대응하지 않고, 컨트롤러는 본연의 목적인 클라이언트 요청처리에만 집중할 수 있다는 말. 비지니스 로직 담당하는 서비스에서 throw 로 예외를 던지면 컨트롤러에서 굳이 try-catch 하지 않고 ControllerAdvice 로 토스하고 거기서 공통 처리하면 코드가 깔끔해진다.
라이프사이클 상으로 보면 Filter, Interceptor, AOP 보다 뒤쪽에서 작동한다. 다시말해 Controller 예외가 DispatcherServlet에 의해 감지된 이후에 작동한다. 그 말은 @ControllerAdvice 를 붙여놓은 클래스 안에서는 디스패쳐 서블릿 이후의 예외는 모두 감지할 수 있다는 말.
좀 더 풀어서 설명하면, 모든 에러는 최종 종착지인 DispatcherServlet으로 전파되고 DispatcherServlet 이 ExceptionResolver 를 작동시켜 @ControllerAdvice 내부의 @ExceptionHandler 찾아가는 식의 운영을 함.
컨트롤러 레벨에서 예외가 발생하면 (서비스 이하에서 throw 로 컨트롤러에 던진 것도 포함) DispatcherServlet 이 처리 책임을 갖게 되고 SpringMVC 에서 등록된 @ControllerAdivce Bean 들을 검색하여 거기에 등록된 @ExceptionHandler 중 예외 타입과 일치하는 return 을 호출. @ExceptionHandler 에서 해당 예외 타입이 발생했을때 실행할 메서드들을 만들어 놓으면 상황에 맞게 대응할 수 있음.
3. 백엔드 응답 전략 (코드 구현)
공통적으로 성공여부(success) 상태코드(status) 메세지(message) 응답시간(timestamp) 요청경로(path) 를 응답한다. 성공시엔 data 를, 실패시엔 error 를 추가로 리턴한다
{
"success": true,
"status": 201,
"message": "회원가입이 완료되었습니다.",
"data": {
"userId": 12345,
"nickname": "헬창왕"
},
"timestamp": "2025-04-13T22:00:00Z",
"path": "/api/users/signup"
}
성공시 포맷은 위와 같다. 올바른 응답인지 아닌지를 기준으로 프론트엔드가 분기처리하기 위해 success 필드를 넣어준다. 또한 status 만 보고 성공을 예상할 수도 있지만 message 를 통해 좀 더 정확히 어떤 작업이 성공했는지 백엔드 개발자가 성공시 정해놓은 메세지를 확인하고 제대로 된 데이터임을 확신할 수 있다!
timestamp 나 path 도 물론 header 에도 뜬다. 그러나 이것은 통신 네트워크 상 응답된 시간과 경로이지 실제 에러가 발생한 path 와는 차이가 있을 수 있다. 예를들면 직접 비지니스 로직을 수행하거나 쿼리를 실행한 시간과 최종적으로 서버에서 응답하는 시간과는 미세한 차이가 있을 수 있고, 또 리버스 프록시나 API 게이트웨이 등 중간 계층에서 라우팅되어서 실제 서버에 들어간다면 클라이언트의 요청주소와 실제 서버경로가 다를 수 있음.
그런 이유가 아니더라도 body에 URL 과 timestamp 를 찍어놓으면 굳이 헤더 들어가서 요청 시간이나 path 를 찾아보지 않아도 응답만 봐도 시간 경로가 보이니 좀 더편하겠다. ELK 등에서 body 로그만 모아서 보기도 용이하고
"여기서 이 시간에 이런 응답이 있었군" vs "이런 응답이 있었네. 이때 시간은 뭐였지? 경로는 정확히 어디지? 헤더에 들어가서 찾아봐야겠다"
정도의 느낌이랄까
{
"success": false,
"status": 500,
"message": "요청이 실패했습니다",
"error": {
"code": "PHONE_ALREADY_REGISTERED",
"message": "이미 등록된 휴대폰 번호입니다.",
"details": null
},
"timestamp": "2025-04-13T22:00:00Z",
"path": "/api/users/signup"
}
실패시 응답 포맷은 위와 같다.
서버에서 실패시 throw new RuntimeException 을 던지면 헤더에 상태코드 500이 적용된다. 프론트엔드 입장에서는 서버 오류인건 알겠는데 그래서 뭐가 오류인지 알 길이 없다. 이러면 프론트엔드 개발자가 디버깅위해 서버켜서 로그 찾아봐야되나? 짜증이 밀려옴.
따라서 status 로 대략 어떤 상황인지 범주를 나눠놓고 디테일한 에러는 error 필드에 error.code 와 error.message 로 어떤 약속된 에러가 발생했는지를 알려주면 프론트엔드는 에러코드에 맞게 분기처리해서 대응할 수 있다
(만약 예상치 못했던 에러라도 따로 error.code = "INTERNAL_SERVER_ERROR" 찍어주고 메세지에서는 JVM 이 던지는 메세지를 보여주는 식으로 처리하면 된다)
따라서 status 외에 error code 를 정의하여 "이런 에러는 이렇게 클라이언트에게 표시해주자" 라고 미리 약속하는것
status | error.code | |
의미 | 에러의 종류(큰 틀) | 에러의 구체적인 원인(세부 키) |
예시 | 409 (중복) | PHONE_ALREADY_REGISTERED |
역할 | HTTP 표준 (브라우저/네트워크용) | 앱 내부에서 디버그/UX/로직 분기 |
결국 서로 편하자고 맞춰준 구조.
응답의 통일성을 위해 서버에서 맞춰놓은 클래스는 아래와 같다.
클래스명 | 역할 | 비고 |
ApiException | 비즈니스 로직에서 발생시키는 커스텀 예외 | 비지니스 실패에만 사용 |
ApiResult<T> | 프론트에 반환할 표준 API 응답 객체 | 모든 응답에서 사용 |
ErrorDetail | 실패시 error 필드에 들어갈 내용 | 실패에만 사용 |
ErrorEnum | 에러 코드, 메시지, HTTP 상태 정의 Enum | - |
GlobalExceptionHandler | 전역 예외 처리기 | - |
성공시 응답 반환 프로세스는 간단하다.
public class UserController {
...
public ApiResult<UserDto> signup(@Valid @RequestBody UserDto userDto) {
// DTO -> Entity -> 저장 -> DTO 변환
User createdUser = userService.signup(userDto.toEntity());
UserDto responseDto = UserDto.fromEntity(createdUser);
return ApiResult.success("회원가입이 완료되었습니다", responseDto);
}
}
컨트롤러에서 성공시엔 ApiResult 를 반환한다.
public static <T> ApiResult<T> success(String message, T data) {
return ApiResult.<T>builder()
.success(true)
.status(HttpStatus.OK.value())
.message(message)
.data(data)
.timestamp(getCurrentTimestamp())
.path(getCurrentPath())
.build();
}
ApiResult 는 클라이언트가 받아볼 최종 Dto 객체
실패시 프로세스는 3가지로 나눴다.
컨트롤러 계층에서 발생한 에러 (사용자의 입력오류), 서비스 계층에서 발생한 에러(비지니스 에러), 레포지토리 계층에서 발생한에러 (데이터베이스 에러)
public enum ErrorEnum {
// 공통
INVALID_INPUT("INVALID_INPUT", "입력값이 유효하지 않습니다.", HttpStatus.BAD_REQUEST), // 컨트롤러에서 발생가능
DATABASE_ERROR("DATABASE_ERROR", "데이터베이스 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), // 레포지토리에서 발생가능
INTERNAL_SERVER_ERROR("INTERNAL_SERVER_ERROR", "서버 내부 오류입니다.", HttpStatus.INTERNAL_SERVER_ERROR), // 그외 예상치못한 모든 에러
// 회원 관련
USER_NOT_FOUND("USER_NOT_FOUND", "사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), // 서비스에서 발생가능
PHONE_ALREADY_REGISTERED("PHONE_ALREADY_REGISTERED", "이미 등록된 휴대폰 번호입니다.", HttpStatus.CONFLICT), // 서비스에서 발생가능
...
ErrorCode(String errorCode, String message, HttpStatus status) {
this.errorCode = errorCode;
this.message = message;
this.status = status;
}
}
미리 enum 타입으로 각 예외별 에러 코드, 에러 메세지, 상태코드를 정의해서 예외를 집중화해서 관리한다
@RestControllerAdvice
public class GlobalExceptionHandler {
// 컨트롤러 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class) // 대부분 얘 하나로 커버됨
public ApiResult<Void> handleValidationException(MethodArgumentNotValidException e) {
Map<String, String> validationErrors = new HashMap<>();
e.getBindingResult().getFieldErrors().forEach(error ->
validationErrors.put(error.getField(), error.getDefaultMessage())
);
return ApiResult.error(ErrorEnum.INVALID_INPUT, validationErrors.toString());
}
// 서비스 예외 처리
@ExceptionHandler(ApiException.class)
public ApiResult<Void> handleApiException(ApiException e) {
return ApiResult.error(e.getErrorEnum(), e.getBackendComment());
}
// 데이터베이스 예외 처리
@ExceptionHandler(DataAccessException.class)
public ApiResult<Void> handleDataAccessException(DataAccessException e) {
return ApiResult.error(ErrorEnum.DATABASE_ERROR, "데이터베이스 오류가 발생했습니다: " + e.getMessage());
}
// 모든 예외 처리
@ExceptionHandler(Exception.class)
public ApiResult<Void> handleException(Exception e) {
return ApiResult.error(ErrorEnum.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습습니다: " + e.getMessage());
}
}
모든 예외는 @RestControllerAdvice 로 정의된 클래스에서 받고 ApiResult 를 통해 정형화된 응답을 한다
(서비스 계층에서는 에러코드 + 백엔드 개발자 코멘트를, 그 외 에러들은 시스템에서 리턴하는 메세지를 프론트엔드에 전달한다)
*Unchecked Exception (Runtime Execption?) 이 전부
public User signup(User user) {
// 전화번호 중복 체크
if (userRepository.existsByPhoneNumber(user.getPhoneNumber())) {
throw new ApiException(ErrorEnum.PHONE_ALREADY_REGISTERED, "개발자 설명");
}
return userRepository.save(user);
}
서비스 계층에서 오류가 발생하면 ApiException 객체를 만들어 @RestControllerAdvice 에 던진다
@Getter
public class ApiException extends RuntimeException {
private final ErrorCode errorCode;
private final String backendComment;
// 개발자 주석 다는것
public ApiException(ErrorCode errorCode, String backendComment) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.backendComment = backendComment;
}
}
ApiException 객체는 서비스 계층에서 발생하는 커스텀 예외객체
use
// ExceptionHandler에서 사용
public static <T> ApiResult<T> error(ErrorCode errorCode, String details) {
return ApiResult.<T>builder()
.success(false)
.status(errorCode.getStatus().value())
.error(
ErrorDetail.builder()
.code(errorCode.getCode())
.message(errorCode.getMessage())
.details(details)
.build()
)
.timestamp(getCurrentTimestamp())
.path(getCurrentPath())
.build();
}
예외시 클라이언트는 위와같은 응답을 받게 된다.
public class ErrorDetail {
private final String errorCode;
private final String message;
private final String details;
}
에러시에 전달받는 error 는 위와같은 구조로 서비스 계층에서 발생하는 에러에선 details 가 백엔드에서 추가한 주석, 그 외 에러에선 시스템에서 던지는 내용이 포함됨.
(error 객체의 message 는 enum 에서 어떤 오류인지 정의한 것이고 details 는 백엔드 개발자가 exception 던질때 추가적으로 주석달거나 시스템상에서 던지는 실제메세지를 적어놓음)
*AOP에서 예외를 감싸서 해당하는 공통 포맷으로 응답을 넘겨줘도 되지만 HTTP 상태코드나 REST Response 최적화 어려움. 그렇다고 컨트롤러 실행전후 동작하는 인터셉터에서 응답을 통일시킨다고하면 성공 응답은 감싸도 postHandle()은 예외 발생 시엔 대응할 수 없음. 예외가 난 뒤에는 DispatcherServlet이 응답을 처리하므로, Interceptor는 더이상 응답을 가공할 수 없음
'[개발일지] > CS 스터디 플랫폼' 카테고리의 다른 글
16장. 이미지 어디서 어떻게 관리할까 S3? 파이어베이스? (2) | 2025.04.11 |
---|---|
15장. 요청 본문 로깅 어디서 하지? 또 어떤 로그를 찍어놓을까 Filter Interceptor AOP (1) | 2025.03.29 |
14장. DB에 물리적 FK 걸지마라. 조회가 빈번하면 인덱싱걸어라. (0) | 2025.03.27 |
13장. MySQL에서 되던게 PostgreSQL 에선 왜 안돼? (JPA의 @GeneratedValue 전략 차이) (1) | 2025.03.25 |
12장. IntelliJ가 해주는 역할이 뭐였을까. Cursor AI로 넘어오면서 생긴 일들 (=Cursor 쓰면 새로운 디펜던시 반영 어떻게 (0) | 2025.03.20 |