Global ExceptionHandler (글로벌 익셉션 핸들러)
애플리케이션 내에서 발생할 수 있는 예외를 전역적으로 처리하는 방법 - Spring 프레임워크에서 제공
주로 @RestControllerAdvice 어노테이션과 함께 사용됨
RESTful API에서 발생하는 예외를 처리하는데 유용
✅ Controller에서 발생하는 예외들을 한 클래스에서 처리할 수 있다.
@RestControllerAdvice
@ControllerAdvice + @ResponseBody(JSON 객체 리턴)
@ControllerAdvice
모든 @Controller에서 발생하는 예외들에 대해 전역적으로 처리 가능한 @ExceptionHandler
📌 예외 발생시 일관된 응답이 가능하다.
📌 반복되는 try-catch문 줄이기 가능
✅ @ControllerAdvice이더라도 리턴값이 ResponseEntity일 경우 데이터 객체 리턴
@ExceptionHandler
@Controller
public class UserController{
@ExceptionHandler({RuntimeException.class, FileSystemException.class}) //예외 처리 클래스
public ResponseEntity<?> handle(Exception e){
...
}
}
@Controller 클래스의 예외 처리
동작 과정
톰캣(Tomcat)은 스레드 기반으로 운영되어 요청마다 request 객체가 생성된다.
➡️ 전달받은 request 객체를 SecurityFilter에서 Filter로 넘긴다.
➡️ DispatcherServlet은 전달받은 request를 이용해
- 해당 Controller를 찾는다.
- Controller 리턴값에 따라 viewResolver 또는 MessageConverter 호출한다.
- 리턴값이 @Controller이면(view 파일)- viewResolver 호출
- 리턴값이 @RestController이면 MessageConverter 호출 (값을 그대로 리턴)
- Controller 메서드 매개변수의 값을 동적으로 주입한다.
- Controller가 던진 Exception을 제어한다.
- GlobalExceptionHandler 존재시, Controller가 던진 Exception을 GlobalExceptionHandler를 호출하여 넘긴다.
✳️ DispatcherServlet은 Spring Web 라이브러리 사용시 리플렉션으로 자동으로 만들어진다.(구현됨)
만들어진 DS를 try-catch로 제어하고 싶을때 @ControllerAdvice를 사용한다.
✳️ SecurityFilter에서 발생한 예외는 SecurityFilter에서 직접 response를 처리해야한다.
SecurityConfig - securityFilterChain
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 1. CSRF 해제
http.csrf().disable(); // postman 접근해야 함!! - CSR 할때!!
// 2. iframe 거부
http.headers().frameOptions().sameOrigin();
// 3. cors 재설정
http.cors().configurationSource(configurationSource());
// 4. jSessionId 사용 거부 (5번을 설정하면 jsessionId가 거부되기 때문에 4번은 사실 필요 없다)
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 5. form 로긴 해제 (UsernamePasswordAuthenticationFilter 비활성화)
http.formLogin().disable();
// 6. 로그인 인증창이 뜨지 않게 비활성화
http.httpBasic().disable();
// 7. 커스텀 필터 적용 (시큐리티 필터 교환)
http.apply(new CustomSecurityFilterManager());
// 8. 인증 실패 처리
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
FilterResponseUtils.unAuthorized(response, new Exception401("인증되지 않았습니다"));
});
// 9. 권한 실패 처리
http.exceptionHandling().accessDeniedHandler((request, response, accessDeniedException) -> {
FilterResponseUtils.forbidden(response, new Exception403("권한이 없습니다"));
});
// 11. 인증, 권한 필터 설정
http.authorizeRequests(
authorize -> authorize.antMatchers("/carts/**", "/options/**", "/orders/**", "/users/**").authenticated()
.antMatchers("/admin/**")
.access("hasRole('ADMIN')")
.anyRequest().permitAll()
);
return http.build();
}
filterResponseUtils.java
public static void unAuthorized(HttpServletResponse resp, Exception401 e) throws IOException {
resp.setStatus(e.status().value());
resp.setContentType("application/json; charset=utf-8");
ObjectMapper om = new ObjectMapper();
String responseBody = om.writeValueAsString(e.body());
resp.getWriter().println(responseBody);
}
직접 response 생성하여 처리
GlobalExceptionHandler
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception400.class)
public ResponseEntity<?> badRequest(Exception400 e){
return new ResponseEntity<>(e.body(), e.status());
}
@ExceptionHandler(Exception401.class)
public ResponseEntity<?> unAuthorized(Exception401 e){
return new ResponseEntity<>(e.body(), e.status());
}
@ExceptionHandler(Exception403.class)
public ResponseEntity<?> forbidden(Exception403 e){
return new ResponseEntity<>(e.body(), e.status());
}
@ExceptionHandler(Exception404.class)
public ResponseEntity<?> notFound(Exception404 e){
return new ResponseEntity<>(e.body(), e.status());
}
@ExceptionHandler(Exception500.class)
public ResponseEntity<?> serverError(Exception500 e){
return new ResponseEntity<>(e.body(), e.status());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> unknownServerError(Exception e){ //제어하지 못한 오류
ApiUtils.ApiResult<?> apiResult = ApiUtils.error(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); //500번 서버 에러
return new ResponseEntity<>(apiResult, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
400번대, 500번 서버 에러를 잡는 것 뿐만 아니라 미처 잡지 못한 에러까지 모두 처리해주어야한다.
GlobalExceptionHandler 적용
적용 전 try-catch
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO requestDTO, Errors errors, HttpServletRequest request) {
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
try {
userService.join(requestDTO);
return ResponseEntity.ok().body(ApiUtils.success(null));
} catch (RuntimeException e) {
return globalExceptionHandler.handle(e, request);
}
}
적용 후
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO requestDTO, Errors errors, HttpServletRequest request) {
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
userService.join(requestDTO);
return ResponseEntity.ok().body(ApiUtils.success(null));
}
예외 처리 코드가 사라졌다.
✳️ 예외 발생시 DS(DispatcherServlet)가 예외를 잡아 @GlobalExceptionHandler를 호출한다.
AOP(Aspect-Oriented Programming : 관점 지향 프로그래밍)
관심사를 분리(핵심기능-부가기능 분리)하여 애플리케이션 전체에서 사용되는 부가기능을 모듈화(따로 메서드로 분리), 재사용
프로그래밍 패러다임의 하나
코드의 가독성과 유지 보수성을 높일 수 있음
OOP vs AOP
📌 OOP(객체 지향 프로그래밍) : 비즈니스 로직의 모듈화 - 상속, 위임 VS AOP : 인프라 혹은 부가기능의 모듈화
➡️ AOP : 흩어진 관심사를 모듈화하여 한 곳으로 모아 관리, 모듈은 여러 곳에 적용(재사용)
📌 Spring 프레임워크에서 사용자는 DispatcherServlet을 제어하지 못하므로 AOP를 통해 제어한다. (원래 reflection으로 가능)
AOP 용어 정리
Advice
부가기능을 수행할 메서드(공통 기능(공통 관심사)의 코드, 부가기능 로직 정의)
Join point에서 실행되는 코드
Join point (결합 지점)
클라이언트가 호출하는 모든 비즈니스 메서드
Advice 실행되는 시점, 적용가능한 위치 - 후보지점
부가 기능이 핵심 로직 실행 전에 실행될지 후에 실행될지 시점 결정
📌 스프링 AOP는 프록시 방식이므로 Join point는 항상 메서드 실행 시점
✅ Join point 표시할때 public method 호출이 아닌 Annotation(ex) @Log)을 이용하면 편리하다.
Pointcut
부가기능을 적용할 핵심 로직 결정 ex)Advice가 실행될 Target의 특정 메서드 지정
선택된(필터링된) 하나 또는 여러 JoinPoint - 실제 Weaving 일어나 Advice가 적용되는 Join Point
Join point보다 상세한 정보(구체적인 지점 정하기 가능)
✅ Proxy가 실제로 Target 요청을 Intercept할 위치
📌Proxy는 Target을 대리하여(Target인척 하여) Target 호출을 Intercept한다.
그리고 Target 사이의 공통관심사(Advice)인 before와 after를 Target 사이에 적용하여 응답한다.
(응답받은 객체는 Target이 응답한 것으로 인식)
✅ 많은 Join point 중 특정 메서드에서만 공통 기능을 수행시키기위해 사용
Weaving시 결합 규칙 정의
Weaving
Pointcut으로 지정된 타겟의 JointPoint에 Advice를 삽입하는 과정 - Advice를 핵심 로직에 적용
(JoinPoint들을 Advice로 감싸는 과정)
Aspect(Advice+Pointcut)을 대상 객체에 제공하여 새로운 Proxy 객체 생성
Target
부가기능 부여할 대상
Aspect(공통 관점) = Advisor
Pointcut + Advice
부가 기능과 해당 부가 기능을 어디에 적용할지 정의, AOP 중심 단위
코드로 살펴보기
@Aspect
@Component
public class MultipleJoinPointsAspect {
//2개의 pointcut(join point 지정)
@Pointcut("execution(* com.example.myapp.service.*Service.*(..))") //서비스의 모든 클래스 메서드
public void serviceMethods() { //point cut 별칭
}
@Pointcut("execution(* com.example.myapp.repository.*Repository.*(..))")
public void repositoryMethods() {
}
//3개의 advice
@Before("serviceMethods() || repositoryMethods()") //Advice 동작 시점
public void beforeExecution(JoinPoint jp) { //호출된 객체의 메서드(join point)
System.out.println("Before method execution: " + jp.getSignature().toShortString());
}
@AfterReturning("serviceMethods() || repositoryMethods()")
public void afterReturningExecution(JoinPoint jp) {
System.out.println("After method execution: " + jp.getSignature().toShortString());
}
@AfterThrowing(pointcut = "serviceMethods() || repositoryMethods()", throwing = "ex")
public void afterThrowingException(JoinPoint jp, Exception ex) {
System.out.println("Exception thrown from method: " + jp.getSignature().toShortString());
System.out.println("Exception message: " + ex.getMessage());
}
}
여러개의 join point들 중 Advice를 적용할 join point들을 선택하여 pointcut에 지정한다.
Advice 동작시점에는 @Before, @After, @AfterReturning(메서드 정상실행후), @AfterThrowing(예외 발생 후), @Around(메서드 호출 이전, 이후, 예외발생등 모든 시점에서 동작)이 있다. (아래에 정리)
Advice에서 실제 호출된 메서드(pointcut에 등록된 join point)를 JoinPoint 객체가 가지고있고, 이를 이용해 공통 기능을 수행한다.
Advice 동작 시점
@Before
메서드 실행 전에 Advice 실행
JoinPoint 객체를 통해 메서드의 인자, 클래스, 메서드 이름 등의 정보를 가져올 수 있다.
@Before("execution(* com.example.myapp.service.MyService.*(..))") //MyService 메서드 실행전
public void beforeAdvice(JoinPoint joinPoint) {
System.out.println("Before Advice: " + joinPoint.getSignature().getName());
}
@After
메서드 실행 후에 Advice 실행
JoinPoint 객체를 사용하여 메서드의 실행 결과나 예외 정보를 가져올 수 있다.
@After("execution(* com.example.myapp.service.MyService.*(..))") //MyService 메서드 실행 후
public void afterAdvice(JoinPoint joinPoint) {
System.out.println("After Advice: " + joinPoint.getSignature().getName());
}
@Around
메서드 실행 전 후 모두 Advice 실행
ProceedingJoinPoint 객체를 사용하여 메서드 실행을 수행하고 실행 결과 반환 가능
@Around("execution(* com.example.myapp.service.MyService.*(..))") //MyService 메서드 실행 전후
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("Before Advice: " + joinPoint.getSignature().getName()); //실행 전
Object result = joinPoint.proceed(); //메서드 실행 결과
System.out.println("After Advice: " + joinPoint.getSignature().getName()); //실행 후
return result;
}
예제 코드 1
Before //join point - Advice 실행 시점 결정
pointcut(login()) //pointcut - Advice를 적용할 메서드
log(){ //Advice (공통 로직)
System.out.println("로그 실행");
}
예제 코드 2 - 어노테이션으로 만들기
After
pointcut(@MyLog)
log(){
System.out.println("로그 실행됨");
}
@MyLog 어노테이션 생성
어노테이션 적용하기
@MyLog
login(){}
@MyLog
join(){}
login과 join에 적용
AOP 적용 방법
1. 깃발(어노테이션) 만들기
2. 그 깃발을 PointCut으로 등록하기
3. Advice 만들기
4. joinPoint 적용하기
1. 어노테이션 만들기
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Hello {
}
2. 어노테이션을 PointCut으로 등록후 Advice 만들고 JoinPoint 적용하기
@Aspect
@Component
public class HelloAdvice {
// 어노테이션을 Pointcut으로 적용
@Pointcut("@annotation(shop.mtcoding.aopstudy.config.annotation.Hello)")
public void hello(){} //별칭
// JoinPoint 적용
@Before("hello()") //hello 실행 전
public void helloAdvice(JoinPoint jp) throws Throwable { //Advice
Object[] args = jp.getArgs(); //인자 가져오기
if(args.length < 1){ //인자 없을경우
System.out.println("아무개님 안녕");
}
else{ //인자 존재할 경우
for (Object arg : args) {
if(arg instanceof String){
String username = (String) arg;
System.out.println(username+"님 안녕");
}
}
}
}
}
위 클래스를 Aspect(JoinPoint+Advice)라고 한다.
📌 @annotation(shop.mtcoding.aopstudy.config.annotation.Hello) = hello()
적용 코드
@RestController
public class HelloController {
// @Hello 적용 X
@GetMapping("/hello/v1")
public String v1(){
return "v1";
}
@Hello //@Hello 적용
@GetMapping("/hello/v2")
public String v2(String username){
return "v2";
}
@Hello //@Hello 적용
@GetMapping("/hello/v3")
public String v3(){
return "v3";
}
}
v1 실행 => 출력 결과X
v2 실행 => {username값}님 안녕
v3 실행 => 아무개님 안녕
이미 만들어져있는 어노테이션에 PointCut 등록하기
- PointCut 등록
- Advice 생성
- joinPoint 적용
코드
@Aspect
@Component
public class ValidAdvice {
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void postMapping() {
}
@Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
public void putMapping() {
}
@Before("postMapping() || putMapping()") //post와 put 매핑일때
public void validationAdvice(JoinPoint jp) throws Throwable { //joinPoint는 메서드 정보 가짐
Object[] args = jp.getArgs();
for (Object arg : args) {
if (arg instanceof Errors) { //인자가 Errors일때
Errors errors = (Errors) arg;
//유효성 검사 예외 처리
if (errors.hasErrors()) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError error : errors.getFieldErrors()) {
errorMap.put(error.getField(), error.getDefaultMessage());
}
throw new MyValidationException(errorMap);
}
}
}
}
}
Post와 Put 요청은 Body 데이터를 가지므로 유효성 검사가 필요하다.
AOP를 통해 Post와 Put 요청시 한번에 예외 처리를 할 수 있다.
📌 2개의 join point(@PostMapping, @PutMapping)에 대해 pointcut으로 지정하였다.
특정 패턴이 수행될때 정의된 Advice 실행시키기
Spring Framework에서는 XML을 이용해서 AOP 설정 가능
Spring Boot에서는 주로 어노테이션 기반의 AOP를 사용하며, execution expression을 이용해 pointcut을 등록할 수 있다.
Spring Boot에서 AspectJ를 사용해 간편하게 pointcut을 등록할 수 있다. (execution expression을 이용)
📌 AspectJ는 유명한 AOP 정규식 라이브러리이다.
스프링부트 AOP 정규표현식
스프링 프레임워크에서 AOP를 구현할 때에는 포인트컷 표현식(Pointcut Expression)을 사용하여 어떤 메서드 또는 클래스에 AOP를 적용할지를 결정합니다. 이러한 포인트컷 표현식은 AspectJ의 문법을 따르며, 스프링 AOP에서 사용되는 정규표현식과는 다소 유사한 형태를 가지고 있습니다.
스프링 AOP에서 주로 사용되는 포인트컷 표현식은 다음과 같습니다:
Execution Pointcut (메서드 실행 지점):
execution(modifiers-pattern? return-type-pattern declaring-type-pattern? method-name-pattern(param-pattern) throws-pattern?)
예시: execution(* com.example.myapp.service.*.*(..)) - com.example.myapp.service 패키지 내의 모든 메서드 호출 지점을 선택
Bean Pointcut (빈 객체 지점):
bean(bean-id-pattern)
예시: bean(*Service) - 빈 ID가 "Service"로 끝나는 모든 빈 객체를 선택
Within Pointcut (특정 타입 내의 모든 메서드 지점):
within(type-pattern)
예시: within(com.example.myapp.service.*) - com.example.myapp.service 패키지 내의 모든 메서드를 선택
Annotation Pointcut (특정 어노테이션이 부여된 메서드 지점):
@annotation(annotation-type)
예시: @annotation(org.springframework.transaction.annotation.Transactional) - @Transactional 어노테이션이 부여된 메서드를 선택
Args Pointcut (특정 파라미터 타입을 갖는 메서드 지점):
args(type-pattern)
예시: args(java.lang.String) - 하나의 String 파라미터를 받는 메서드를 선택
정규표현식을 사용해서 어노테이션 찾고 값 주입하기
@Aspect
@Component
public class LogAdvice {
//UserController의 파라미터 0개 이상인 모든 메서드 실행시점
@Around("execution(* shop.mtcoding.aopstudy.controller.UserController.*(..))")
public Object logAdvice(ProceedingJoinPoint jp) throws Throwable {
System.out.println("실행전"); //Before()
Object result = jp.proceed(); //UserController 메서드 실행 결과(리턴값)
System.out.println(result+"리턴됨"); //After()
return result;
}
}
@Around를 이용하면 실행 전, 후 모두에 대해 Advice를 실행시킬 수 있다.
적용 코드(UserController.java)
@RestController
public class UserController {
// LogAdvice Test
@GetMapping("/user/v1")
public String v1(){
return "v1";
}
// LogAdvice Test
@GetMapping("/user/v2")
public String v2(){
return "v2";
}
// ValidAdvice Test
@PostMapping("/valid")
public String join(@RequestBody @Valid JoinDTO joinDTO, Errors errors){
return "ok";
}
}
UserController의 모든 메서드에 대해 Before()-"실행전" 출력, After()-"result 리턴됨" 출력된다.
AOP 적용
Controller - 중복되는 부가기능 코드 존재
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserService userService;
// (기능3) 이메일 중복체크
@PostMapping("/check")
public ResponseEntity<?> check(@RequestBody @Valid UserRequest.EmailCheckDTO emailCheckDTO, Errors errors) {
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
userService.sameCheckEmail(emailCheckDTO.getEmail());
return ResponseEntity.ok(ApiUtils.success(null));
}
// (기능4) 회원가입
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO requestDTO, Errors errors) {
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
userService.join(requestDTO);
return ResponseEntity.ok().body(ApiUtils.success(null));
}
}
보면 if문의 errors.hasErrors()가 반복되어 존재함을 알 수 있다.
반복되는 부분은 유효성 검사 후 에러 처리하는 코드이다.
AOP 코드
@Aspect //Aspect(Advice + Pointcut)
@Component //IoC 컨테이너에 띄움
public class GlobalValidationHandler {
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void postMapping() {
}
@Before("postMapping()")
public void validationAdvice(JoinPoint jp) {
Object[] args = jp.getArgs();
for (Object arg : args) {
if (arg instanceof Errors) { //에러 존재시
Errors errors = (Errors) arg;
if (errors.hasErrors()) { //맨 첫번째 에러 정보로 400번 에러 던짐
throw new Exception400(
errors.getFieldErrors().get(0).getDefaultMessage()+":"+errors.getFieldErrors().get(0).getField()
);
}
}
}
}
}
1. 어노테이션에 Pointcut 등록하기
2. Advice 만들기
3. Join Point 적용하기
Controller에 AOP 적용
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserService userService;
// (기능3) 이메일 중복체크
@PostMapping("/check")
public ResponseEntity<?> check(@RequestBody @Valid UserRequest.EmailCheckDTO emailCheckDTO, Errors errors) {
userService.sameCheckEmail(emailCheckDTO.getEmail());
return ResponseEntity.ok(ApiUtils.success(null));
}
// (기능4) 회원가입
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO requestDTO, Errors errors) {
userService.join(requestDTO);
return ResponseEntity.ok().body(ApiUtils.success(null));
}
// (기능5) 로그인
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody @Valid UserRequest.LoginDTO requestDTO, Errors errors) {
String jwt = userService.login(requestDTO);
return ResponseEntity.ok().header(JWTProvider.HEADER, jwt).body(ApiUtils.success(null));
}
}
반복되는 유효성 검사 에러 처리 코드 - if hasErrors()부분이 사라져 깔끔하다.
PostMapping시 @Valid로 인자 유효성 검사 후 Errors가 존재한다면 GlobalValidationHandler를 통해 유효성 검사 에러가 처리된다.
📌 Errors 인자는 계속 존재해야 GlobalValidationHandler에서 처리할 수 있다.
결과
유효성 검사 예외처리가 잘 된다.
'Spring > 카테캠 - TIL' 카테고리의 다른 글
TIL [0727] : 기능 구현, AOP 개념 정리 (0) | 2023.07.27 |
---|---|
TIL [0726] : 5주차 강의 - 상품 목록, 상세보기, 장바구니 담기, 조회, 업데이트 (0) | 2023.07.26 |
카카오테크캠퍼스 : 3주차 코드리뷰 (0) | 2023.07.21 |
카테캠 4주차 과제 중 문제 - 해결 (유효성 검사, Mockito 인자+anyInt, UserDetails 직접 주입) (0) | 2023.07.21 |
카카오테크캠퍼스 : 4주차 과제 (0) | 2023.07.19 |