Skip to content

Conversation

@hardwoong
Copy link
Member

관련 이슈

closed #8

작업한 내용

관련 이슈

작업한 내용

1. 전역 예외 처리 시스템 구현

  • GlobalExceptionHandler (@RestControllerAdvice): 모든 예외를 통일된 형식으로 처리

    • GeneralException: 커스텀 비즈니스 예외 처리
    • MethodArgumentNotValidException: @Valid 검증 실패 처리
    • ConstraintViolationException: 제약 조건 위반 처리
    • Exception: 일반 예외 처리 (500 에러)
  • GeneralException: BaseErrorCode를 사용하는 커스텀 예외 클래스

2. 도메인별 예외 처리 실습 구현

  • TestErrorCode: 테스트용 에러 코드 enum (TEST_EXCEPTION)
  • TestException: GeneralException을 상속한 도메인별 예외 클래스
  • TestController:
    • GET /temp/test: 정상 응답 테스트
    • GET /temp/exception?flag=1: 예외 발생 테스트
  • TestQueryService: flag == 1일 때 예외 발생 로직

3. API 응답 통일 처리

  • ApiResponse 개선:
    • onSuccess(BaseCode code, T result) 메서드 추가 (워크북 패턴)
    • BaseCode를 직접 전달하여 일관된 응답 보장
  • SuccessStatus 수정:
    • 코드: "COMMON2000""COMMON200"
    • 메시지: "성공입니다.""성공적으로 요청을 처리했습니다."
  • 모든 Controller에 워크북 패턴 적용:
    • ReviewController, TestController에서 SuccessStatus code = SuccessStatus._OK 사용

4. 보안 및 설정 파일 정리

  • SecurityConfig: /temp/** 경로 허용 추가
  • 프로파일 설정 정리:
    • application.properties: prod 설정 파일 제거 (dev 프로파일 기본 사용)
    • application-test.properties, application-prod.properties: spring.profiles.active 주석 처리

PR Point 및 참고사항, 스크린샷

핵심 구현 사항

  1. 예외 처리 흐름:

    Service에서 예외 발생 → TestException 생성 → GeneralException 상속 
    → GlobalExceptionHandler가 감지 → ApiResponse 형식으로 통일된 에러 응답
    
  2. 응답 통일 패턴:

    • 성공: ApiResponse.onSuccess(code, result)
    • 실패: GlobalExceptionHandler가 자동으로 ApiResponse.onFailure() 형식으로 변환

주요 파일

  • GlobalExceptionHandler.java: 전역 예외 처리 핵심 로직
  • GeneralException.java: 커스텀 예외 기본 클래스
  • TestException.java, TestErrorCode.java: 도메인별 예외 처리 예시
  • ApiResponse.java: 응답 통일 래퍼 클래스
  • SecurityConfig.java: /temp/** 경로 접근 허용

PR Point 및 참고사항, 스크린샷

스크린샷 2025-11-03 오전 2 45 00 스크린샷 2025-11-03 오전 2 45 09
@RestControllerAdvice의 장점과 필요성 # @RestControllerAdvice의 장점과 필요성

@RestControllerAdvice란?

@RestControllerAdvice는 Spring에서 전역적으로 예외를 처리하고 응답을 통일하기 위한 어노테이션

  • @ControllerAdvice + @ResponseBody의 결합
  • 모든 @RestController에서 발생하는 예외를 한 곳에서 처리

@RestControllerAdvice의 장점

1. 전역 예외 처리

모든 Controller에서 발생하는 예외를 한 곳에서 처리할 수 있음

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(GeneralException.class)
    public ResponseEntity<Object> handleGeneralException(GeneralException e) {
        // 모든 Controller의 GeneralException을 여기서 처리
        return ResponseEntity
            .status(e.getCodeBase().getReasonHttpStatus().getHttpStatus())
            .body(ApiResponse.onFailure(
                e.getCodeBase().getCode(),
                e.getCodeBase().getMessage(),
                null
            ));
    }
}

장점:

  • 각 Controller마다 try-catch 블록을 작성할 필요가 없음
  • 예외 처리 로직이 한 곳에 집중되어 유지보수 용이

2. 코드 중복 제거

RestControllerAdvice 없을 때

@RestController
public class UserController {
    
    @GetMapping("/users/{id}")
    public ResponseEntity<?> getUser(@PathVariable Long id) {
        try {
            User user = userService.findById(id);
            return ResponseEntity.ok(user);
        } catch (UserNotFoundException e) {
            return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("USER404", "사용자를 찾을 수 없습니다."));
        } catch (Exception e) {
            return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponse("COMMON500", "서버 에러"));
        }
    }
    
    @PostMapping("/users")
    public ResponseEntity<?> createUser(@RequestBody UserDto dto) {
        try {
            User user = userService.create(dto);
            return ResponseEntity.ok(user);
        } catch (DuplicateUserException e) {
            return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("USER400", "중복된 사용자"));
        } catch (Exception e) {
            return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponse("COMMON500", "서버 에러"));
        }
    }
    
    // 모든 메서드마다 동일한 try-catch 반복...
}

RestControllerAdvice 있을 때

@RestController
public class UserController {
    
    @GetMapping("/users/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        // 예외는 GlobalExceptionHandler가 처리
        User user = userService.findById(id);
        return ResponseEntity.ok(user);
    }
    
    @PostMapping("/users")
    public ResponseEntity<User> createUser(@RequestBody UserDto dto) {
        // 예외는 GlobalExceptionHandler가 처리
        User user = userService.create(dto);
        return ResponseEntity.ok(user);
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<?> handleUserNotFound(UserNotFoundException e) {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("USER404", e.getMessage()));
    }
    
    @ExceptionHandler(DuplicateUserException.class)
    public ResponseEntity<?> handleDuplicate(DuplicateUserException e) {
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse("USER400", e.getMessage()));
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleGeneral(Exception e) {
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("COMMON500", "서버 에러"));
    }
}

3. 일관된 응답 형식 보장

모든 에러 응답이 동일한 형식으로 통일

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
    @ExceptionHandler(GeneralException.class)
    public ResponseEntity<Object> general(GeneralException e, WebRequest request) {
        // 모든 에러를 ApiResponse 형식으로 통일
        return ResponseEntity
            .status(e.getCodeBase().getReasonHttpStatus().getHttpStatus())
            .body(ApiResponse.onFailure(
                e.getCodeBase().getCode(),
                e.getCodeBase().getMessage(),
                null
            ));
    }
}

응답 예시:

{
  "isSuccess": false,
  "code": "TEST400_1",
  "message": "이거는 테스트",
  "result": null
}

장점:

  • 프론트엔드에서 에러 처리 로직 단순화
  • API 문서화 용이
  • 클라이언트 개발자와의 협업 효율 증가

4. 관심사의 분리 (Separation of Concerns)

  • Controller: 비즈니스 로직 흐름에만 집중
  • Service: 실제 비즈니스 로직 처리
  • ExceptionHandler: 예외 처리 및 에러 응답 생성
// Controller - 비즈니스 흐름만 집중
@GetMapping("/exception")
public ApiResponse<TestResDTO.Exception> exception(@RequestParam Long flag) {
    testQueryService.checkFlag(flag);  // 예외 발생 가능
    return ApiResponse.onSuccess(code, TestConverter.toExceptionDTO("This is Test!"));
}

// Service - 비즈니스 로직만 집중
@Override
public void checkFlag(Long flag) {
    if (flag == 1) {
        throw new TestException(TestErrorCode.TEST_EXCEPTION);  // 예외 던지기만
    }
}

// ExceptionHandler - 예외 처리만 집중
@ExceptionHandler
public ResponseEntity<Object> general(GeneralException e, WebRequest request) {
    ErrorReasonDto reasonHttpStatus = e.getCodeBase().getReasonHttpStatus();
    return handleExceptionInternal(e, reasonHttpStatus, HttpHeaders.EMPTY, 
        reasonHttpStatus.getHttpStatus(), request);
}

5. 다양한 예외 타입 통합 처리

여러 종류의 예외를 체계적으로 처리할 수 있음

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
    // 1. 커스텀 예외 처리
    @ExceptionHandler(GeneralException.class)
    public ResponseEntity<Object> general(GeneralException e, WebRequest request) {
        // ...
    }
    
    // 2. Validation 예외 처리 (@Valid 실패)
    @Override
    public ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException e, HttpHeaders headers, 
            HttpStatusCode status, WebRequest request) {
        Map<String, String> errors = new LinkedHashMap<>();
        e.getBindingResult().getFieldErrors().forEach(fieldError -> {
            errors.merge(fieldError.getField(), 
                fieldError.getDefaultMessage(), 
                (existing, newMsg) -> existing + ", " + newMsg);
        });
        return handleExceptionInternalArgs(e, HttpHeaders.EMPTY, errors, 
            HttpStatus.BAD_REQUEST, request);
    }
    
    // 3. 제약 조건 위반 처리
    @ExceptionHandler
    public ResponseEntity<Object> handleConstraintViolation(
            ConstraintViolationException e, WebRequest request) {
        String errorMessage = e.getConstraintViolations().stream()
            .map(cv -> cv.getMessage())
            .findFirst()
            .orElseThrow();
        return handleExceptionInternalFalse(e, null, HttpHeaders.EMPTY, 
            HttpStatus.BAD_REQUEST, request, errorMessage);
    }
    
    // 4. 일반 예외 처리 (모든 예외 catch)
    @ExceptionHandler
    public ResponseEntity<Object> exception(Exception e, WebRequest request) {
        return handleExceptionInternalFalse(e,
            ApiResponse.onFailure("COMMON500", "서버 에러", null),
            HttpHeaders.EMPTY, HttpStatus.INTERNAL_SERVER_ERROR, 
            request, e.getMessage());
    }
}

6. 로깅 및 모니터링 통합

예외 처리 시 일관된 로깅을 적용할 수 있음

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    private ResponseEntity<Object> handleExceptionInternalFalse(
            Exception e, Object body, HttpHeaders headers, 
            HttpStatusCode statusCode, WebRequest request, String errorPoint) {
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        String url = servletWebRequest.getRequest().getRequestURI();
        
        // 통합 로깅
        log.error("Rest API Exception: {}, url: {}, message: {}", 
            statusCode, url, errorPoint, e);
        
        return super.handleExceptionInternal(e, body, headers, statusCode, request);
    }
}

@RestControllerAdvice 없을 경우의 불편함

1. 코드 중복

// Controller 1
@RestController
public class UserController {
    @GetMapping("/users")
    public ResponseEntity<?> getUsers() {
        try {
            // 로직
        } catch (Exception e) {
            // 동일한 예외 처리 코드
        }
    }
}

// Controller 2
@RestController
public class OrderController {
    @GetMapping("/orders")
    public ResponseEntity<?> getOrders() {
        try {
            // 로직
        } catch (Exception e) {
            // 또 동일한 예외 처리 코드
        }
    }
}

// Controller 3...
// 무한 반복...

문제점:

  • 100개의 API가 있다면 100번 동일한 코드 작성
  • 예외 처리 로직 수정 시 모든 Controller 수정 필요
  • 유지보수 비용 증가

2. 일관성 없는 에러 응답

각 개발자마다 다른 형식으로 에러를 반환할 수 있음

// 개발자 A
return ResponseEntity.status(400).body(Map.of("error", "잘못된 요청"));

// 개발자 B
return ResponseEntity.badRequest().body(new ErrorDto("400", "Bad Request"));

// 개발자 C
return new ResponseEntity<>(new Error("에러 발생"), HttpStatus.BAD_REQUEST);

결과:

// 응답 1
{ "error": "잘못된 요청" }

// 응답 2
{ "code": "400", "message": "Bad Request" }

// 응답 3
{ "errorMessage": "에러 발생" }

문제점:

  • 프론트엔드에서 각기 다른 에러 형식 처리 필요
  • API 문서화 어려움
  • 클라이언트 개발자 혼란

3. 비즈니스 로직과 예외 처리 혼재

Controller가 복잡해지고 가독성이 떨어짐

@RestController
public class OrderController {
    
    @PostMapping("/orders")
    public ResponseEntity<?> createOrder(@RequestBody OrderDto dto) {
        try {
            // 비즈니스 로직
            Order order = orderService.create(dto);
            
            try {
                paymentService.process(order);
            } catch (PaymentException e) {
                // 결제 예외 처리
                return ResponseEntity.status(402)
                    .body(Map.of("error", "결제 실패"));
            }
            
            try {
                notificationService.send(order);
            } catch (NotificationException e) {
                // 알림 예외는 무시
                log.warn("알림 전송 실패: {}", e.getMessage());
            }
            
            return ResponseEntity.ok(order);
            
        } catch (InsufficientStockException e) {
            return ResponseEntity.status(409)
                .body(Map.of("error", "재고 부족"));
        } catch (InvalidOrderException e) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", e.getMessage()));
        } catch (Exception e) {
            log.error("주문 생성 실패", e);
            return ResponseEntity.status(500)
                .body(Map.of("error", "서버 에러"));
        }
    }
}

문제점:

  • 실제 비즈니스 로직이 예외 처리 코드에 묻힘
  • 가독성 극도로 저하
  • 테스트 코드 작성 어려움

4. 예외 처리 누락 위험

@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    // 예외 처리를 깜빡함!
    User user = userService.findById(id);  // UserNotFoundException 발생 가능
    return ResponseEntity.ok(user);
}

결과:

// 500 Internal Server Error와 함께 스택 트레이스 노출
{
  "timestamp": "2025-11-02T12:34:56.789+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "trace": "com.example.exception.UserNotFoundException: User not found\n\tat com.example...",
  "message": "User not found",
  "path": "/users/999"
}

문제점:

  • 보안에 취약 (스택 트레이스 노출)
  • 클라이언트가 예상하지 못한 응답 형식
  • 프로덕션 환경에서 디버깅 정보 노출

5. 변경 비용 증가

에러 응답 형식을 변경하려면?

// 기존: { "error": "message" }
// 변경: { "isSuccess": false, "code": "...", "message": "...", "result": null }

// RestControllerAdvice 없으면 → 모든 Controller 수정 필요
// RestControllerAdvice 있으면 → GlobalExceptionHandler만 수정

실전 비교 예시

시나리오: 사용자 조회 API

Without @RestControllerAdvice

@RestController
public class UserController {
    
    @GetMapping("/users/{id}")
    public ResponseEntity<?> getUser(@PathVariable Long id) {
        try {
            // 1. 입력 검증
            if (id <= 0) {
                return ResponseEntity.badRequest()
                    .body(Map.of("error", "유효하지 않은 ID"));
            }
            
            // 2. 비즈니스 로직
            try {
                User user = userService.findById(id);
                return ResponseEntity.ok(user);
            } catch (UserNotFoundException e) {
                return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body(Map.of("code", "USER404", "message", "사용자를 찾을 수 없습니다."));
            } catch (DatabaseException e) {
                log.error("DB 에러: {}", e.getMessage());
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Map.of("error", "데이터베이스 에러"));
            }
            
        } catch (Exception e) {
            log.error("예상치 못한 에러", e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of("error", "서버 에러가 발생했습니다."));
        }
    }
}

With @RestControllerAdvice

@RestController
public class UserController {
    
    @GetMapping("/users/{id}")
    public ApiResponse<User> getUser(@PathVariable Long id) {
        // 비즈니스 로직만 집중
        User user = userService.findById(id);
        return ApiResponse.onSuccess(user);
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<Object> handleUserNotFound(UserNotFoundException e, WebRequest request) {
        return handleExceptionInternal(e, 
            ErrorStatus.USER_NOT_FOUND.getReasonHttpStatus(),
            HttpHeaders.EMPTY, 
            HttpStatus.NOT_FOUND, 
            request);
    }
    
    @ExceptionHandler(DatabaseException.class)
    public ResponseEntity<Object> handleDatabase(DatabaseException e, WebRequest request) {
        log.error("DB 에러: {}", e.getMessage());
        return handleExceptionInternal(e,
            ErrorStatus.DATABASE_ERROR.getReasonHttpStatus(),
            HttpHeaders.EMPTY,
            HttpStatus.INTERNAL_SERVER_ERROR,
            request);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> handleGeneral(Exception e, WebRequest request) {
        log.error("예상치 못한 에러", e);
        return handleExceptionInternal(e,
            ErrorStatus._INTERNAL_SERVER_ERROR.getReasonHttpStatus(),
            HttpHeaders.EMPTY,
            HttpStatus.INTERNAL_SERVER_ERROR,
            request);
    }
}

결론

@RestControllerAdvice는 단순한 편의 기능이 아니라, 프로덕션 수준의 API 개발에 필수적인 아키텍처 패턴

핵심 가치:

  1. 코드 중복 제거 → 개발 생산성 향상
  2. 일관된 응답 형식 → 클라이언트 개발 편의성 향상
  3. 관심사의 분리 → 코드 품질 및 유지보수성 향상
  4. 전역 예외 처리 → 안정성 및 보안성 향상

@hardwoong hardwoong self-assigned this Nov 2, 2025
@hardwoong hardwoong added the enhancement New feature or request label Nov 2, 2025
@hardwoong hardwoong linked an issue Nov 2, 2025 that may be closed by this pull request
@hardwoong hardwoong changed the title Feat/chapter7 [FEAT] 7주차 미션 Nov 3, 2025
@hardwoong hardwoong merged commit e05e674 into develop Nov 9, 2025
@hardwoong hardwoong deleted the Feat/Chapter7 branch November 9, 2025 16:42
Copy link

@ggamnunq ggamnunq left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Week7 Mission

3 participants