[Error] - 예외를 다뤄보자

yeom yaloo·2023년 7월 6일
0

백엔드 관련 지식

목록 보기
1/7
post-thumbnail

[예외 핸들링(+추가)]

1. 나는 왜 예외 핸들링을 했을까?

회원로그인, 회원가입 등등 여러 기능에서 사용자에게 예외를 던져주어야 하는 경우가 생긴다.(예를 들면 회원 로그인 아이디로 이미 가입되어 있는 회원인데 중복해서 가입하려는 등의 문제)

이때 무지성으로 예외를 던지게 되면 사용자가 해당 서비스를 이용할 때 많은 어려움을 겪을 것이다. (별다른 처리 없이 예외만 던지면 서비스가 종료되거나 사용자가 서비스 이용에 많은 불편함을 겪게 되기 때문이다.)

그렇기 때문에 사용자의 접근이 비정상적이라고 생각이 들면 개발자인 내가 해당 접근에 대한 예외를 적절하게 던져주고 던진 예외를 ExceptionHandler와 (Rest)ControllerAdvice를 사용해서 사용자에게 보다 더 나은 서비스를 제공하고자 했다.

또한 MSA구조로 이루어진 나의 프로젝트의 경우엔 RestTemplate을 사용한 통신이 빈번했고, RestTemplate을 사용해서 서버끼리 통신을 했을 때 넘어오는 오류가 400번대 에러임에도 불구하고 500번대 서버 에러로 넘어오는 등의 문제가 있어서 이를 핸들링하고 싶어서 예외를 핸들링하기로 했다.

2. 예외를 다루는 목적

사용자는 개발자가 만든 서비스를 이용할 때 발생할 수 있는 에러를 미리 사전에 예측해서 사용자가 서비스를 이용하는 동안에 불편함이 없도록 유도하기 위해서 에러를 핸들링하는 과정이 필요하다.

3. 에러와 예외의 차이점?

비슷하지만 발생 주체가 다르다. 에러는 컴퓨터가 발생시키는 것을 의미하고 예외는 개발자가 의도적으로 발생시키는 것을 의미한다.

또한 예외와 에러 모두 발생하면 프로그램이 종료된다는 공통점이 있지만 예외는 예외처리를 통해 프로그램의 종료 없이 프로그램을 계속해서 사용자가 사용할 수 있게 할수 있다는 차이점이 있다.

  • Error? 에러는 언어의 문법과 관련된 에러, 통신 장애로 인한 에러등 컴퓨터가 코드를 실행하는 과정 자체에서 발생하는 것들을 에러라고 일컫는다.

  • 개발자가 의도적으로 발생시키는 예외? 예외는 코드가 실행되는 일에는 큰 문제가 없지만 개발자가 판단하기에 정상적 상황이 아닌 과정이 일어날 때 예외를 발생시키는 것을 개발자가 의도적으로 발생시키는 예외라고 일컫는다.
    [예시 상황]

  • 이미 가입된 회원 아이디를 사용해서 가입하려는 회원이 있는 경우 (AlreadyExistExceptin과 같은 400번대 예외를 던져서 해당 경우를 핸들링)

  • 이미 탈퇴환 회원을 찾으려고 할 때 예외를 던져서 해당 경우를 핸들링

4. 컴퓨터는 예외를 어떻게 받아들일까?

  • 컴퓨터 입장에서는 개발자가 의도적으로 던진 예외의 경우는 에러라고 받아들이지 않는다. 코드를 실행하기에 정상적인 호출이 있을 수 있기 때문이다.
  • 예외 발생 전 사전 처리, 발생 예외를 잡아서 처리하는 모든 과정을 예외 핸들링이라고 한다.

[ExceptionHandler(+추가)]

1. 컨트롤러에서 발생한 예외를 처리해주는 ExceptionHandler

  • @ExceptionHandler같은 경우는 @Controller, @RestController가 적용된 Bean내에서 발생하는 예외를 잡아서 하나의 메서드에서 처리해주는 기능을 한다.
  • 위의 말은 즉슨 @Service, @Repository, @Component, @Bean과 같은 다른 빈주입 애노테이션이 붙은 경우라면 동작하지 않는것을 의미한다.
  • 또한 해당 ExceptionHandler가 적용된 Controller, RestController의 예외만 처리할 수 있다.
    • 모든 Controller, RestController에 대해서 이 작업을 진행하고 싶다면 아래의 (Rest)ControllerAdvice를 클래스로 만들어두고 ExceptionHandler를 작업해두자

2. (Rest)ControllerAdvice, ExceptionHandler

  • (Rest)ControllerAdvice가 ExceptionHandler보다 더 큰 범주를 확인한다.
  • ExceptionHandler가 하나의 예외만을 상대하는 개념이라면 이들을 모아두고 @RestControllerAdvice, @ControllerAdvice는 @Controller, @RestController가 붙은 모든 예외가 발생하는 곳에서 예외를 잡아 처리하는 개념이다.
    • 이때 ExceptionHandler가 처리하고 있는 예외의 종류와 같은 예외 발생시에만 예외를 처리한다.

[코드를 통한 범위 적용의 차이점을 보자!]

1. 한정적으로 예외를 다룰 경우

@(Rest)ControllerAdvice를 사용하지 않고 특정 컨트롤러에@ExceptionHandler만을 적용한 경우

[에러 응답에 사용될 클래스]


@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse{

	private HttpStatus errorCode;
	private String errorMessage;

	public static ErrorResponse of(HttpStatus errorCode,String errorMessage) {
		return ErrorResponse.builder()
			.errorCode(errorCode)
			.errorMessage(errorMessage)
			.build();
	}
}

[RestController와 @ExceptionHandler]

@RestController
public class ExceptionHandlerController {

	@GetMppint("/member/{loginId}")
    public ResponseEntity<Member> getMemberByLoginId(){
    	//1. 로그인 아이디를 사용해서 해당 회원을 찾아오는 로직이 있다고 예를 들어 봅시다. 
       // 2. 그때 여기서 회원이 없어서 NotFoundMemberException이 발생했다고 생각해봅시다

      	throw new NotFoundMemberException();
    }

    @ExceptionHandler(NotFoundMemberException.class)
    public ResponseEntity exceptionHanlerMethod(Exception e) {
        System.err.println(e.getClass());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
        	.body(ErrorResponse.of(
            		HttpStatus.NOT_FOUND, "not found member@"));

    }
    
}
  • 해당 컨트롤러 클래스 내에서 ExceptionHandler에 정의한 종류의 예외가 발생했다면 ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.of(HttpStatus.NOT_FOUND, "not found member"));를 실행해서 아래와 같은 json 형태의 응답을 받을 것이다.

[해당 오류가 발생했을 때 ExceptionHandler가 적용된 응답처리 결과]

body로 해당 json message가 출력할 수 있게 설정해두었음..

{
	errorCode: 
  	errorMessage: "not found member@"
}

2. 한정적으로 예외를 다룰 경우

@(Rest)ControllerAdvice를 사용하지 않고 특정 컨트롤러에@ExceptionHandler 역시 적용하지 않은 경우

[에러 응답에 사용될 클래스]

@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse{

	private HttpStatus errorCode;
	private String errorMessage;

	public static ErrorResponse of(HttpStatus errorCode,String errorMessage) {
		return ErrorResponse.builder()
			.errorCode(errorCode)
			.errorMessage(errorMessage)
			.build();
	}
}

[RestController만 있는 경우 - ExceptionHandler가 해당 컨트롤러 내에 없을 경우]

@RestController
@RequiredArgsConstructor
class TestRestControllerTwo{

    private final QueryMemberService queryMemberService;

    @GetMapping("/test/member/{loginId}")
    public ResponseEntity<MemberLoginResponse> getMemberByLoginId(@PathVariable(name = "loginId") String loginId){
        //1. 로그인 아이디를 사용해서 해당 회원을 찾아오는 로직이 있다고 예를 들어 봅시다.
        // 2. 그때 여기서 회원이 없어서 NotFoundMemberException이 발생했다고 생각해봅시다

        MemberLoginResponse member = queryMemberService.findMemberByLoginId(loginId);
        if(Objects.isNull(member)){
            throw new NotFoundMemberException();
        }

        return ResponseEntity.status(HttpStatus.OK).body(member);
    }

[ExceptionHandler 적용 없이 예외 발생할 경우]

  • 따로 예외 처리를 해주지 않아서 에러 메시지나 바디가 비어있는 것을 확인할 수 있다.

3. @ExceptionHandler 전체 테스트 코드

package com.yaloostore.shop;

import com.yaloostore.shop.member.controller.MemberLoginHistoryRestController;
import com.yaloostore.shop.member.dto.response.MemberIdResponse;
import com.yaloostore.shop.member.dto.response.MemberLoginResponse;
import com.yaloostore.shop.member.entity.Member;
import com.yaloostore.shop.member.exception.NotFoundMemberException;
import com.yaloostore.shop.member.service.inter.QueryMemberService;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.Objects;

import static org.hamcrest.Matchers.equalTo;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;


@WebMvcTest(TestRestController.class)
@AutoConfigureRestDocs
public class test {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private QueryMemberService service;

    @Test
    @WithMockUser
    void test() throws Exception {


        String loginId = "test";

        when(service.findMemberByLoginId(loginId)).thenThrow(NotFoundMemberException.class);

        ResultActions perform = mockMvc.perform(get("/member/{loginId}", loginId)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON));

        perform.andDo(print())
                .andExpect(MockMvcResultMatchers.status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.errorCode", equalTo(HttpStatus.NOT_FOUND.getReasonPhrase())))
                .andExpect(jsonPath("$.errorMessage", equalTo("not found member@")));



    }
    @Test
    @WithMockUser
    void test_two() throws Exception {


        String loginId = "test";

        when(service.findMemberByLoginId(loginId)).thenThrow(NotFoundMemberException.class);

        ResultActions perform = mockMvc.perform(get("/test/member/{loginId}", loginId)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON));

        perform.andDo(print())
                .andExpect(MockMvcResultMatchers.status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.errorCode", equalTo(HttpStatus.NOT_FOUND.getReasonPhrase())))
                .andExpect(jsonPath("$.errorMessage", equalTo("not found member@")));



    }
}

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
class ErrorResponse{
    private String errorCode;
    private String errorMessage;

    public static ErrorResponse of(HttpStatus errorCode, String errorMessage){
        return ErrorResponse.builder()
                .errorCode(errorCode.getReasonPhrase())
                .errorMessage(errorMessage)
                .build();
    }
}

@RestController
@RequiredArgsConstructor
class TestRestController{

    private final QueryMemberService queryMemberService;

    @GetMapping("/member/{loginId}")
    public ResponseEntity<MemberLoginResponse> getMemberByLoginId(@PathVariable(name = "loginId") String loginId){
        //1. 로그인 아이디를 사용해서 해당 회원을 찾아오는 로직이 있다고 예를 들어 봅시다.
        // 2. 그때 여기서 회원이 없어서 NotFoundMemberException이 발생했다고 생각해봅시다

        MemberLoginResponse member = queryMemberService.findMemberByLoginId(loginId);
        if(Objects.isNull(member)){
            throw new NotFoundMemberException();
        }

        return ResponseEntity.status(HttpStatus.OK).body(member);
    }

    @ExceptionHandler(NotFoundMemberException.class)
    public ResponseEntity exceptionHandlerMethod(Exception e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ErrorResponse.of(
                        HttpStatus.NOT_FOUND, "not found member@"));

    }
}
@RestController
@RequiredArgsConstructor
class TestRestControllerTwo{

    private final QueryMemberService queryMemberService;

    @GetMapping("/test/member/{loginId}")
    public ResponseEntity<MemberLoginResponse> getMemberByLoginId(@PathVariable(name = "loginId") String loginId){
        //1. 로그인 아이디를 사용해서 해당 회원을 찾아오는 로직이 있다고 예를 들어 봅시다.
        // 2. 그때 여기서 회원이 없어서 NotFoundMemberException이 발생했다고 생각해봅시다

        MemberLoginResponse member = queryMemberService.findMemberByLoginId(loginId);
        if(Objects.isNull(member)){
            throw new NotFoundMemberException();
        }

        return ResponseEntity.status(HttpStatus.OK).body(member);
    }

}
  • 전체적으로 (Rest)Controller에서 발생하는 예외를 다루고 싶다면 @(Rest)ControllerAdvice와 @ExceptionHandler를 함께 사용하도록 하자!

[핸들링 전엔 어떤 모습일까?]

org.springframework.web.client.HttpServerErrorException$
InternalServerError: 500 : 
"{"timestamp":"2023-07-06T01:44:16.253+00:00",
"status":500,
"error":"Internal Server Error",
"trace":"com.yalooStore.common_utils.exception.ClientException: member not found
  • 기본적으로 RestTemplate을 사용해서 API에 접근할 때 에러가 발생하면 500 Error를 던져준다.
  • 본인은 이 에러를 조금 다르게 사용하고자한다면 아래와 같이 설정을 진행해준다.

[API 측]

1. Advice 작성

@ExceptionHandler(ClientException.class)
public ResponseEntity<ResponseDto<Object>> clientExceptionHandler(ClientException e){

        ResponseDto<Object> response = ResponseDto.builder()
                .success(false)
                .status(e.getResponseStatus())
                .errorMessages(List.of(e.getDisplayErrorMessage())).build();

        return ResponseEntity.status(e.getResponseStatus()).body(response);

    }
  • ClientException은 custom한 Exception입니다.
  • ResponseDto 역시 custom한 dto객체 입니다.
  • 해당 에러가 발생하면 400대 예외를 내려주기 위해서 exceptionHandler를 작성해줍니다.

2. Advice 적용 후

기존 ExceptionHandler 적용 전

  • 500대 HttpServerErrorException을 던져줍니다.

API server 측에서 ExceptionHandler 작업을 진행 후

  • 400번대 HttpClientErrorException을 던져줍니다.

[API를 사용하는 측]

try {
      ResponseEntity<ResponseDto<MemberLoginResponse>> memberLoginResponse = restTemplate
          .exchange(uri.toUri(),
          HttpMethod.GET,
          httpEntity,
          new ParameterizedTypeReference<>() {
          });
      MemberLoginResponse data = memberLoginResponse.getBody().getData();
      User user = new User(data.getLoginId(), data.getPassword(), getAuthorities(data));
      return user;
    } catch (HttpClientErrorException e){
      throw new UsernameNotFoundException("not found username");
      }
        
  • 해당 HttpClientErrorException이 발생하면 UsernameNotFoundException을 을 던져줘 AuthenticationFailureHandler가 작동할 수 있게 한다.

[정리(+추가)]

1. 지금까지 우리는 왜 예외를 개발자의 입맛에 맞게 custom하고 이 예외를 다시 다룰까?(Error Handling)

-> 에러가 아닌 경우에 한해서 개발자가 사용자의 접근이 옳지 않다고 판단하는 경우를 직접 판단해 해당 경우에 한해 적절한 예외를 던지도록 한다.
-> 던진 예외를 ExceptionHandler를 통해서 특정 컨트롤러에서만 관리할 수도 있고 @(Rest)ControllerAdvice를 사용해서 모든 컨트롤러에 대해서 ExceptionHandler를 적용하여 예외를 관리 할수 있게 된다.
-> 결과적으로 이런 예외 핸들링은 사용자에게 더 높은 서비스를 제공할 수 있으기 때문에 진행한다고 할수 있다.

2. 이때 java/spring을 사용해서는 어떻게 이 상황을 처리할 수 있을까?

-> @ExceptionHandler를 특정 메서드에 붙여서 처리할수 있다.
-> 더 넓은 범위에서 예외를 다루고 싶다면 @(Rest)Controller를 사용해서 해당 예외를 다룰 수 있게 한다.

3. (Rest)Controller와 ExceptionHandler는 어떻게 다르고 같을까?

-> 둘은 예외를 다룬다는 점에서 공통점이 있다.
-> 그러나 둘은 범위의 차이가 있다 ExceptionHandler는 특정 메서드에 붙이면 해당 컨트롤러의 메서드에서 발생한 예외에만 동작하지만 @(Rest)ControllerAdvice에 ExceptionHandler를 작성해두면 모든 컨트롤러에서 발생하는 예외를 비교해서 예외를 다룰 수 있게 된다.

profile
즐겁고 괴로운 개발😎

0개의 댓글