반응형

로그인 기능을 이어서 구현하기 전에, @RestControllerAdvice를 이용하여 발생하는 예외들을 손쉽게 다뤄보도록 하겠습니다.

 

* 일단 그 전에 controller.response 패키지를, dto.response 패키지로 옮겨주었습니다. 어드바이스를 다룰 advice 패키지를 별도로 만들 예정이기 때문에, 여기에선 dto를 접근하는 편이 더 맞다고 생각되어 옮기게 되었습니다. 본인 생각에 따라 원하는 위치에 두셔도 됩니다.

 

 

이제 advice 패키지를 만들고, ExceptionAdvice를 작성해보겠습니다.

package kukekyakya.kukemarket.advice;

import kukekyakya.kukemarket.dto.response.Response;
import kukekyakya.kukemarket.exception.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
@Slf4j
public class ExceptionAdvice {

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Response exception(Exception e) { // 1
        log.info("e = {}", e.getMessage());
        return Response.failure(-1000, "오류가 발생하였습니다.");
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Response methodArgumentNotValidException(MethodArgumentNotValidException e) { // 2
        return Response.failure(-1003, e.getBindingResult().getFieldError().getDefaultMessage());
    }

    @ExceptionHandler(LoginFailureException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public Response loginFailureException() { // 3
        return Response.failure(-1004, "로그인에 실패하였습니다.");
    }

    @ExceptionHandler(MemberEmailAlreadyExistsException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public Response memberEmailAlreadyExistsException(MemberEmailAlreadyExistsException e) { // 4
        return Response.failure(-1005, e.getMessage() + "은 중복된 이메일 입니다.");
    }

    @ExceptionHandler(MemberNicknameAlreadyExistsException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public Response memberNicknameAlreadyExistsException(MemberNicknameAlreadyExistsException e) { // 5
        return Response.failure(-1006, e.getMessage() + "은 중복된 닉네임 입니다.");
    }

    @ExceptionHandler(MemberNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Response memberNotFoundException() { // 6
        return Response.failure(-1007, "요청한 회원을 찾을 수 없습니다.");
    }

    @ExceptionHandler(RoleNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Response roleNotFoundException() { // 7
        return Response.failure(-1008, "요청한 권한 등급을 찾을 수 없습니다.");
    }
}

@ExceptionHandler에 예외 클래스를 지정해주면, 실행 중에 지정한 예외가 발생하면 해당 메소드를 실행해줍니다.

@ResponseStatus로 각 예외마다 상태 코드를 지정해줄 수 있습니다.

@RestControllerAdvice로 지정했기 때문에, @ResponseBody가 포함되어있습니다.

이를 이용하여 Response.failure에 오류 코드와 오류 메시지를 같이 응답해주도록 하겠습니다.

1. 의도하지 않은 예외가 발생하면, 로그를 남겨주고 응답합니다. @ExceptionHandler는 발생하는 예외의 더 구체적인 것을 선택하기 때문에, 다른 @ExceptionHandler에서 잡아내지 못한 예외는 여기로 오게 될 것 입니다.

2. 요청 객체의 validation을 수행할 때, MethodArgumentNotValidException이 발생하게 됩니다. 각 검증 어노테이션 별로 지정해놨던 메시지를 응답해줍니다. 400 응답을 내려줍니다.

3. 아이디 또는 비밀번호 오류로 로그인에 실패했다면, 401 응답을 내려줍니다.

4~5. 닉네임 또는 이메일 중복이 발생했다면, 409 응답을 내려줍니다.

6~7. 요청한 자원을 찾을 수 없다면, 404 응답을 내려줍니다.

 

 

이제 이렇게 작성한 어드바이스를 테스트를 진행해보겠습니다.

현재 웹 계층에서의 테스트는 두 가지 부분으로 나뉘어서 진행되고 있습니다.

첫번째는, 요청 API의 유효성 및 응답 확인 테스트.

두번째는, 요청 객체의 제약 조건 검증 테스트.

어드바이스 테스트도 별도의 부분으로 분리해서 테스트를 작성해보겠습니다.

test 디렉토리에서, SignControllerTest와 동일한 패키지 경로에 SignControllerAdviceTest를 작성해줍니다.

전체 소스코드는 다음과 같습니다.

package kukekyakya.kukemarket.controller.sign;

import com.fasterxml.jackson.databind.ObjectMapper;
import kukekyakya.kukemarket.advice.ExceptionAdvice;
import kukekyakya.kukemarket.dto.sign.SignInRequest;
import kukekyakya.kukemarket.dto.sign.SignUpRequest;
import kukekyakya.kukemarket.exception.LoginFailureException;
import kukekyakya.kukemarket.exception.MemberEmailAlreadyExistsException;
import kukekyakya.kukemarket.exception.MemberNicknameAlreadyExistsException;
import kukekyakya.kukemarket.exception.RoleNotFoundException;
import kukekyakya.kukemarket.service.sign.SignService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(MockitoExtension.class)
class SignControllerAdviceTest {
    @InjectMocks SignController signController;
    @Mock SignService signService;
    MockMvc mockMvc;
    ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(signController).setControllerAdvice(new ExceptionAdvice()).build();
    }

    @Test
    void signInLoginFailureExceptionTest() throws Exception {
        // given
        SignInRequest req = new SignInRequest("email@email.com", "123456a!");
        given(signService.signIn(any())).willThrow(LoginFailureException.class);

        // when, then
        mockMvc.perform(
                post("/api/sign-in")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isUnauthorized());
    }

    @Test
    void signInMethodArgumentNotValidExceptionTest() throws Exception {
        // given
        SignInRequest req = new SignInRequest("email", "1234567");

        // when, then
        mockMvc.perform(
                post("/api/sign-in")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isBadRequest());
    }

    @Test
    void signUpMemberEmailAlreadyExistsExceptionTest() throws Exception {
        // given
        SignUpRequest req = new SignUpRequest("email@email.com", "123456a!", "username", "nickname");
        doThrow(MemberEmailAlreadyExistsException.class).when(signService).signUp(any());

        // when, then
        mockMvc.perform(
                post("/api/sign-up")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isConflict());
    }

    @Test
    void signUpMemberNicknameAlreadyExistsExceptionTest() throws Exception {
        // given
        SignUpRequest req = new SignUpRequest("email@email.com", "123456a!", "username", "nickname");
        doThrow(MemberNicknameAlreadyExistsException.class).when(signService).signUp(any());

        // when, then
        mockMvc.perform(
                post("/api/sign-up")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isConflict());
    }

    @Test
    void signUpRoleNotFoundExceptionTest() throws Exception {
        // given
        SignUpRequest req = new SignUpRequest("email@email.com", "123456a!", "username", "nickname");
        doThrow(RoleNotFoundException.class).when(signService).signUp(any());

        // when, then
        mockMvc.perform(
                post("/api/sign-up")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isNotFound());
    }

    @Test
    void signUpMethodArgumentNotValidExceptionTest() throws Exception {
        // given
        SignUpRequest req = new SignUpRequest("", "", "", "");

        // when, then
        mockMvc.perform(
                post("/api/sign-up")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isBadRequest());
    }
}

위 코드로, 테스트를 설정하는 부분과 예외를 발생시키는 법에 대해서 살펴보겠습니다. 

 

 

@ExtendWith(MockitoExtension.class)
class SignControllerAdviceTest {
    @InjectMocks SignController signController;
    @Mock SignService signService;
    MockMvc mockMvc;
    ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(signController).setControllerAdvice(new ExceptionAdvice()).build();
    }

이번에도 Mockito 프레임워크를 이용하였습니다.

API 호출만 테스트하던 SignControllerTest와는 다르게, ControllerAdvice를 테스트하기 위해서는,

MockMvc를 빌드하는 과정에 setControllerAdvice로 어드바이스를 등록해줘야합니다.

 

 

    @Test
    void signInLoginFailureExceptionTest() throws Exception {
        // given
        SignInRequest req = new SignInRequest("email@email.com", "123456a!");
        given(signService.signIn(any())).willThrow(LoginFailureException.class);

        // when, then
        mockMvc.perform(
                post("/api/sign-in")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isUnauthorized());
    }

LoginFailureException이 발생했을 때의 검증 테스트입니다.

가짜 객체로 만들어둔 SignService.signIn을 호출했을 때, LoginFailureException.class를 throw하도록 설정해주면 됩니다.

그러면 어드바이스를 통해, 401 상태 코드가 응답됩니다.

 

 

그렇다면, 메소드가 void 반환형을 가질 때는 어떻게 해야할까요?

    @Test
    void signUpMemberEmailAlreadyExistsExceptionTest() throws Exception {
        // given
        SignUpRequest req = new SignUpRequest("email@email.com", "123456a!", "username", "nickname");
        doThrow(MemberEmailAlreadyExistsException.class).when(signService).signUp(any());

        // when, then
        mockMvc.perform(
                post("/api/sign-up")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isConflict());
    }

이번에는 doThrow를 이용하여 테스트할 수 있습니다.

doThrow에 발생할 예외 클래스를 명시해주고, when을 이용하여 예외가 발생할 객체의 메소드를 지정해주면 됩니다.

어드바이스를 통해, 409 상태 코드가 응답되는 것을 확인할 수 있습니다.

 

 

다음과 같이 요청 객체의 제약 조건을 검증할 수도 있습니다.

    @Test
    void signInMethodArgumentNotValidExceptionTest() throws Exception {
        // given
        SignInRequest req = new SignInRequest("email", "1234567");

        // when, then
        mockMvc.perform(
                post("/api/sign-in")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isBadRequest());
    }

SignInRequest에 지정해둔 이메일과 비밀번호의 제약 조건이 지켜지지 않았기 때문에, MethodArgumentNotValidException이 발생하게 됩니다.

어드바이스를 통해 400 상태 코드가 응답되는 것을 확인할 수 있습니다.

 

* 여기에서 각 검증 단계를 구체적으로 테스트하지 않은 이유는, 지금의 테스트는 어드바이스가 정상적으로 예외를 잡아내고 처리하는지에 대한 테스트이기 때문입니다. 각 요청 객체에 대한 검증 테스트는, 지난 시간에 SignInRequestValidationTest에서 검증기를 통하여 별도로 테스트하였습니다.

 

 

 

로그인과 회원가입에 대한 기본적인 로직 구현과 예외 처리는 얼추 마무리 됐으니, Postman을 이용해서 간단하게 API를 테스트해보도록 하겠습니다.

자동화된 테스트도 좋지만, 한 번씩 눈으로 검증해줘야 더욱 재미가 붙곤 합니다.

이제 스프링부트 애플리케이션을 직접 구동해서 테스트를 진행해보겠습니다.

 

 

하지만, 우리의 애플리케이션을 이용하려면 먼저 권한 등급을 데이터베이스에 초기화해주어야합니다.

회원가입을 진행할 때, 이미 생성되어있는 ROLE_NORMAL 권한 등급을 조회하여 사용자에게 부여해주기 때문입니다.

이에 대한 내용은, 별도의 스크립트를 작성해두거나 데이터베이스를 생성하여 미리 데이터를 넣어두는 방법이 있겠습니다.

하지만 저는 아직 로컬에서 개발하면서 테스트 하고있는 단계이므로, 데이터베이스를 초기화해주는 간단한 클래스를 작성하여 빈으로 등록해주겠습니다.

다음과 같이 InitDB 클래스를 작성해주었습니다.

package kukekyakya.kukemarket;

import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import kukekyakya.kukemarket.repository.role.RoleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;
import java.util.List;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
@Slf4j
@Profile("local") // 1
public class InitDB {
    private final RoleRepository roleRepository;

    @PostConstruct // 2
    public void initDB() {
        log.info("initialize database");
        initRole(); // 3
    }

    private void initRole() {
        roleRepository.saveAll(
                List.of(RoleType.values()).stream().map(roleType -> new Role(roleType)).collect(Collectors.toList())
        );
    }
}

현재 application.yml에 활성 profile은 local로 설정되어있습니다.

1. 이 클래스는 활성 profile이 local일 때만 빈으로 등록됩니다.

2. @PostConstruct를 메소드에 지정하면, 빈의 생성과 의존성 주입이 끝난 뒤에 수행할 초기화 코드를 지정할 수 있습니다.

3. RoleType에 정의했던 권한들을 데이터베이스에 저장해줍니다.

 

* @PostConstruct에서는 @Transactional과 같은 AOP가 적용되지 않습니다. @Transactional과 같은 AOP는 빈 후처리기에 의해 처리되는데, @PostConstruct는 이러한 모든 후처리가 완료되었는지를 확인할 수 없습니다.

 

이제 postman을 이용하여 API 요청을 해보겠습니다.

 

임의로 잘못된 이메일 형식의 요청을 전송해보았습니다.

실패 요청

응답은 다음과 같습니다.

실패 응답

지정해뒀던 코드와 메시지, 400(Bad Request) 상태 코드가 응답되었습니다.

 

 

이제 정상적인 요청을 전송해보겠습니다.

회원가입 요청 성공

201(Created) 상태 코드 응답이 왔고, 응답 코드와 성공 여부도 확인해 볼 수 있습니다.

 

이제 회원가입한 사용자 정보로, 로그인 요청을 해보겠습니다.

로그인 요청 성공

두 종류의 토큰와 함께 상태 코드 200(OK)을 응답으로 받게 되었습니다.

 

이렇게 해서 로그인과 회원 가입에 관한 API를 직접 테스트해볼 수 있었습니다.

 

 

이번 시간에는, @RestControllerAdvice를 이용하여 예외를 손쉽게 다룰 수 있게 되었습니다.

이제 어떤 예외가 추가되더라도 어드바이스에 등록해준다면, 하나의 클래스에서 편리하게 처리할 수 있습니다.

다음 시간에는, 다시 로그인 기능을 계속 구현하면서, 사용자의 권한에 따른 인가 처리를 다뤄보도록 하겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts