본문 바로가기
프로젝트/토이 프로젝트

Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 3

by 진꿈청 2024. 2. 19.

이번 글에서는 통합 테스트와 예외 처리에 관한 내용이다.

 

하지만, 시작하기전에 앞 글에서 수정한 내용들이 있다.

 

 

1. open-in-view 처리

 

일명 OSIV를 False 하였다.

 

OSIV 처리

 

OSIV에 관한 설명은 아래를 참조 바란다.

 

https://hdbstn3055.tistory.com/14

 

[SpringBoot] OSIV와 성능 최적화

OSIV는 Open Session In View의 약자이다. OSIV는 Spring에 spring.jpa.open-in-view: true 기본값으로 설정되어 있다. OSIV 전략은 최초 데이터베이스 커넥션 시작부터 API 응답(View에 전송 및 DTO 반환 등)이 끝날 때

hdbstn3055.tistory.com

 

 

2. User에 Gender Enum 추가

 

public class User extends BaseEntity{

    /* 기존 코드 */

    @Enumerated(value = EnumType.STRING)
    private Gender gender;

    /* 기존 코드 */


    //==DTO 활용 유저 생성 메서드==//
    public static User createUserByDto(UserDto.SignUp signUpDto) {
        User user = new User();
        user.setEmail(signUpDto.getEmail());
        user.setName(signUpDto.getName());
        user.setNickName(signUpDto.getNickname());
        user.setRole(Authority.ROLE_USER);
        user.setAddress(signUpDto.getAddress());
        user.setPassword(signUpDto.getPassword());
        user.setGender(signUpDto.getGender());
        return user;
    }
}

 

 

Gender Class

public enum Gender{
    MALE("남자"),
    FEMALE("여자");

    private final String value;

    Gender(String value) {
        this.value = value;
    }


    @JsonCreator
    public static Gender from(String sub){
        for(Gender gender : Gender.values()){
            if(gender.getValue().equals(sub)){
                return gender;
            }
        }
        log.debug("EnumCollection.Gender.from() exception occur sub: {}", sub);
        throw new BusinessLogicException(ErrorCode.INVALID_GENDER);
    }

    @JsonValue
    public String getValue(){
        return value;
    }

}

 

넣게 된 이유는 @JsonCreator를 사용해보기 위함이다.

 

Controller에서 DTO를 활용하여 데이터를 받아올 때(Json body),

Json To Object 과정에서 deserialize는 Jackson 라이브러리에서 실행된다.

이 경우 Enum value의 Name과 동일한 경우, 기본 deserialize에 의해 문제가 없지만

변수가 틀리게되면 바로 에러가 발생한다.

 

 JSON parse error: Cannot deserialize value of type `enum package` 

 

따라서, 지정한 Setter를 사용하게 지원해주는 @JsonCreator를 활용하면 에러를 해결할 수 있다.

 

동작 과정: API 요청 -> Gender 값으로 받은 String -> @JsonCreator를 지정한 메서드의 파라미터 ->

                메서드의 기능으로 Request Dto에 저장

 

 

본 작업(테스트 코드 작성)

 

프로젝트를 진행하며 테스트 코드의 필요성을 느끼게 되었고 관련하여 공부하여 적용해 보았다.

 

그렇게 알게 된 것이 Spring REST Docs이다. 우선, 기존 Assertj 라이브러리로 테스트 코드는 몇번 작성해봤는데

이렇게 직접 RESTful API를 호출하여 테스트를 진행해보는 것은 처음이었다.

 

Spring REST Docs란?

 

테스트 코드 기반으로 HTML, MarkDown, Assicodoctor 등 다양한 형식의 문서를 생성하여 RESTful API 문서화

 

사용한 Spring REST Docs의 특징은 다음과 같다.

  • 작성한 테스트 코드들의 최신화가 보장된다.(테스트가 통과해야만 작성되기 때문.)
  • 문서 형태는 md로 사용할 수 있지만 주로 Asciidoctor 형식을 사용.

 

build.gradle 기본 셋팅

 

SpringBoot 버전과 asciidoctor 버전 차이 때문에 애를 많이 먹었다..

SpringBoot 3.x.x 버전으로 들어오면서 호완이 잘 안되는 것들이 많아진 것 같다.

(MVN Repository를 잘 참조하도록 하자.) -> MVN Repository

(다른 분들은 나 같은 실수를 범하질 않길 바란다.) -> Asciidoctor 링크

 

plugins {
    ...
    // asciidoc 파일을 변환해서, build 폴더에 복사해주는 플러그인
    id 'org.asciidoctor.jvm.convert' version "3.3.2"
    ...
}

...

ext{
    snippetsDir = file('build/generated-snippets') // 빌드될 adoc 경로폴더
}

...

configurations {
    asciidoctorExt // configuration 등록
    compileOnly {
        extendsFrom annotationProcessor
    }
}

...

asciidoctor {
    dependsOn test    // gradle build 시 test -> asciidoctor 순으로 수행
    inputs.dir snippetsDir
    attributes 'snippets' : snippetsDir // 빌드된 snippets 경로(실행된 서버에서)
    doFirst {
        delete 'src/main/resources/static/docs' // 기존 문서파일 삭제
    }
}

...

bootJar{ //springboot를 이용한 jar 파일 생성 시 필요한 설정 task이다.
    dependsOn asciidoctor //asciidoctor 를 의존하도록 하여, bootJar 생성 전에 asciidoctor task를 수행하도록 함
    // (jar 파일 생성 시, 문서 생성을 보장 함.)
    copy {
        from "build/docs/asciidoc" // 빌드폴더에 있는 adoc파일들을 from->into
        into "src/main/resources/static/docs"
    }
}

...

dependencies {
	...
    // Spring Rest Docs을 이용하기 위한 라이브러리
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    ...
}

...

test {
    useJUnitPlatform()
    outputs.dir snippetsDir
}

 

실제 테스트 코드 작성

 

BaseIntegrationTest(통합 테스트에 공통적으로 사용할 수 있는 공동 클래스)

 

@Disabled // 해당 Annotation이 지정된 테스트 클래스 또는 테스트 메서드 실행 X
@Transactional // 데이터베이스 롤백
@SpringBootTest // Test를 위한 Application Context를 로딩하며 여러가지 속성 제공
@AutoConfigureMockMvc // @WebMvcTest가 아닌 @SpringBootTest 어노테이션을 사용하며 MockMvc를 이용한 테스트를 위한 어노테이션
@AutoConfigureRestDocs // Spring Rest Docs를 사용하기 위해 MockMvc 빈을 커스터마이즈.
//@ActiveProfiles("test")
//@WebMvcTest <- Web 계층만을 테스트할 때 사용하는 어노테이션, Web 계층 테스트에 필요한 Bean들만 등록(Security 함께 진행)
@ExtendWith(RestDocumentationExtension.class)
public class BaseIntegrationTest {

    @Autowired
    protected MockMvc mockMvc;
}

 

  • @SpringBootTest: Test를 위한 Application Context를 로딩하며 여러 속성 제공
  • @Disabled: 해당 Annotation이 지정된 테스트 클래스 또는 테스트 메서드 실행 X
  • @Transactional: 클래스 내부의 각각 테스트 메서드가 실행될 때마다, 데이터베이스 롤백(반복 가능하게 도와줌)
  • @AutoCongifureMockMvc: @WebMvcTest가 아닌 @SpringBootTest Annotation을 사용하면서 MockMvc를 이요한 테스트를 해야할 때 필요.
  • @AutoConfigureRestDocs: Spring Rest Docs를 사용하기 위해 MockMvc 빈을 커스터마이즈
  •  @WebMvcTest: Web 계층(Controller)만을 테스트 할 대 사용하는 Annotation이다.  Web 계층 테스트에 필요한 Bean들만 등록. Spring Security를 사용 중이라면 Spring Security도 함께 등록.
  • @ActiveProfiles: 테스트 수행시 사용할 프로파일 지정
  • @ExtendWith(RestDocumentationExtension.class): Spring REST Docs를 활성화하는 Junit5 Annotation

 

 

MockMvc는 무엇일까?

 

본격적으로 작성 Test Class 설명을 하기에 앞서, MockMvc에 관해 알아야 한다.

MockMvc는 스프링 Mvc의 통합테스트를 위한 라이브러리이다.

 

MockMvc.perform()

 

위 메서드는 MockMvcRequestBuilders를 매개 변수로 받아서 ResultActions를 return 한다.

MockMvcRequestBuilders를 반환하는 정적 메서드는 post(), get(), patch(), delete() 등이 있다.

해당 메서드들은 HttpRequest를 만들어내기 위한 Builder로 header, body등을 지정하는 메서드들이 존재한다.

즉, 간편하게 테스트를 위한 웹 요청 생성이 가능하다.

 

 

ResultActions.andDo()

 

MockMvc 요청을 한 뒤, 행동을 지정하는 메서드이다. 결과를 출력하거나 로그를 출력하는 등의 해동 지정이 가능하다.

 

 

ResultActions.andExpect()

 

요청의 결과로 예상되는 응답을 지정하여 테스트 진행.

응답 코드, 본문에 포함되는 데이터, 헤더, 쿠키, 세션 등 응답에 포함되는

전반적인 데이터들을 테스트.

 

통합 테스트에 사용된 환경설정 Class 들

 

ApiDocumentUtils

 

public class ApiDocumentUtils {
    public static OperationRequestPreprocessor getRequestPreProcessor() {
        return preprocessRequest(prettyPrint());
    }

    public static OperationResponsePreprocessor getResponsePreProcessor() {
        return preprocessResponse(prettyPrint());
    }
}

 

prettyPrint()는 Request와 Response를 API 문서에 조금 더 보기 좋게 표현해준다.

 

 

ObjectMapperUtils

 

public class ObjectMapperUtils {
    private static final ObjectMapper objectMapper = new ObjectMapper();

    public static String asJsonString(final Object obj) {
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static Response actionsSingleResponseToUserDto(ResultActions actions) throws Exception {
        String response = actions.andReturn().getResponse().getContentAsString();
        return objectMapper.registerModule(new JavaTimeModule()).readValue(response, Response.class);
    }

    public static LoginResponse actionsSingleResponseToLoginDto(ResultActions actions) throws Exception {
        String response = actions.andReturn().getResponse().getContentAsString();
        return objectMapper.registerModule(new JavaTimeModule()).readValue(response, LoginResponse.class);
    }
}

 

RequestBody로 전달하게 할 내용을 Json으로 직렬화.

직렬화는 다양한 클래스에서 사용되기 때문에 ObjectMapperUtils 클래스를 만들어 추상화.

생성된 json은 mockMvc에 mockMvc.content(json)으로 설정.

 

ResultActionsUtils

 

public class ResultActionsUtils {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String REFRESH_HEADER = "Refresh";
    private static final String BEARER_PREFIX = "Bearer ";

    public static ResultActions patchRequestWithToken(MockMvc mockMvc,
                                                                String url,
                                                                String accessToken,
                                                                String encryptedRefreshToken) throws Exception {

        return mockMvc.perform(patch(url)
                        .contentType(MediaType.APPLICATION_JSON_VALUE)
                        .header(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken)
                        .header(REFRESH_HEADER, encryptedRefreshToken))
                .andDo(print());
    }

    public static ResultActions getRequestWithToken(MockMvc mockMvc, String url, String accessToken, String encryptedRefreshToken) throws Exception {
        return mockMvc.perform(get(url)
                        .contentType(MediaType.APPLICATION_JSON_VALUE)
                        .header(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken)
                        .header(REFRESH_HEADER, encryptedRefreshToken))
                .andDo(print());
    }

    public static ResultActions patchRequest(MockMvc mockMvc, String url, String encryptedRefreshToken) throws Exception {
        return mockMvc.perform(patch(url)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .header(REFRESH_HEADER, encryptedRefreshToken))
                .andDo(print());
    }

    public static ResultActions patchRequest(MockMvc mockMvc, String url) throws Exception {
        return mockMvc.perform(patch(url)
                        .contentType(MediaType.APPLICATION_JSON_VALUE))
                .andDo(print());
    }

    public static ResultActions getRequest(MockMvc mockMvc, String url, String json) throws Exception {
        return mockMvc.perform(get(url)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(json))
                .andDo(print());
    }

    public static ResultActions postRequestWithAuthCodeAndToken(MockMvc mockMvc, String url, String accessToken, String encryptedRefreshToken, String email, String code) throws Exception {
        return mockMvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                        .header(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken)
                        .header(REFRESH_HEADER, encryptedRefreshToken)
                .param("email", email)
                        .param("code", code))
                .andDo(print());
    }
}

 

  • Request Body는 Json 형식으로 설정.
  • Access Token과 Refresh Token은 header에 설정.
  • @WebMvcTest 진행시 커스텀한 Security가 아닌 기본으로 설정되어 있는 Security(csrf 활성화 -> with(csrf) 추가)

 

UserResponseSnippset

 

public class UserResponseSnippet {

    public static Snippet getPatchSnippet() {

        return requestFields(
                List.of(
                        fieldWithPath("password").type(JsonFieldType.STRING).description("회원 비밀번호"),
                        fieldWithPath("nickname").type(JsonFieldType.STRING).description("회원 닉네임"),
                        fieldWithPath("image").type(JsonFieldType.STRING).description("프로필 이미지 url"),
                        fieldWithPath("address").type(JsonFieldType.STRING).description("회원 주소"),
                        fieldWithPath("introduction").type(JsonFieldType.STRING).description("자기 소개"),
                        fieldWithPath("nation").type(JsonFieldType.STRING).description("회원 국가")
                )
        );
    }

    public static ResponseFieldsSnippet getUserResponseSnippet() {
        return responseFields(
                List.of(
                        fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
                        fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"),
                        fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("작성자 닉네임 및 타입"),
                        fieldWithPath("data.name").type(JsonFieldType.STRING).description("작성자 이름"),
                        fieldWithPath("data.address").type(JsonFieldType.OBJECT).description("회원 주소"),
                        fieldWithPath("data.address.city").type(JsonFieldType.STRING).description("도시"),
                        fieldWithPath("data.address.street").type(JsonFieldType.STRING).description("도로주소"),
                        fieldWithPath("data.address.zipcode").type(JsonFieldType.STRING).description("우편번호"),
                        fieldWithPath("data.gender").type(JsonFieldType.STRING).description("성별")
                )
        );
    }

    public static Snippet emailVerificationsResponseSnippet() {
        return responseFields(
                List.of(
                    fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
                    fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지")
                )
        );
    }

    public static Snippet emailVerificationsFailSnippet() {
        return responseFields(
                List.of(
                        fieldWithPath("status").type(JsonFieldType.NUMBER).description("Http Status"),
                        fieldWithPath("code").type(JsonFieldType.STRING).description("Error Code"),
                        fieldWithPath("message").type(JsonFieldType.STRING).description("Error Message")
                )
        );
    }
}

 

요청 및 응답 본문의 구조를 설명하는 스니펫이다.

Json 응답에 맞도록 작성하지 않으면 오류가 발생한다.

 

 

 

테스트 코드 작성 시 사용한 코딩 스타일은 given, when, then이다.

  • 어떤 값이 주어지고(given)
  • 무엇을 했을 때(when)
  • 어떤 값을 반환한다.(then)

직관적으로 테스트 코드 흐름 파악이 가능하여 코드의 가독성이 향상된다.

 

 

 

User 통합 테스트 Class

 

public class UserIntegrationTest extends BaseIntegrationTest {

    private final String BASE_URL = "/users";
    private final String EMAIL = "email@gmail.com";
    private final String SENDER_EMAIL = "abc@gmail.com";

    @Autowired
    private UserService userService;

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private AES128Config aes128Config;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach // 각 테스트가 실행되기 전에 실행되는 메서드
    void beforeEach(){
        UserDto.SignUp signUpDto = StubData.MockUser.getSignUpDto();
        userService.signUp(signUpDto);
    }

    @AfterEach // 각 테스트가 실행된 후에 실행되는 메서드
    void afterEach(){
        userService.deleteUser(EMAIL);
    }

    @Test
    @DisplayName("회원 조회")
    public void getMemberTest() throws Exception{
        //given
        User user = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(user);

        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);

        //when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
                .build().toUri().toString();


        ResultActions actions = ResultActionsUtils.getRequestWithToken(mockMvc, uri, accessToken, encryptedRefreshToken);

        //then
        actions
                .andExpect(status().isOk())
                .andDo(document("get-user",
                        getResponsePreProcessor(),
                        UserResponseSnippet.getUserResponseSnippet()));
    }

    @Test
    @DisplayName("이메일 코드 성공")
    public void emailCodeSuccessTest() throws Exception{
        //given
        User user = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(user);

        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);

        String authCode = createCode();

        userService.sendCodeToEmailForTest(SENDER_EMAIL, authCode);

        //when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/emails/verifications")
                .build().toUri().toString();

        ResultActions actions = ResultActionsUtils.postRequestWithAuthCodeAndToken(mockMvc, uri, accessToken, encryptedRefreshToken, SENDER_EMAIL, authCode);

        //then
        actions
                .andExpect(status().isOk())
                .andDo(document("email-verification-success",
                        getResponsePreProcessor(),
                        UserResponseSnippet.emailVerificationsResponseSnippet()));
    }

    @Test
    @DisplayName("이메일 코드 실패")
    public void emailCodeFailTest() throws Exception{
        //given
        User user = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(user);

        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);

        String authCode = createCode();

        userService.sendCodeToEmailForTest(SENDER_EMAIL, authCode + "1");

        //when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/emails/verifications")
                .build().toUri().toString();

        ResultActions actions = ResultActionsUtils.postRequestWithAuthCodeAndToken(mockMvc, uri, accessToken, encryptedRefreshToken, SENDER_EMAIL, authCode);

        //then
        actions
                .andExpect(status().isBadRequest())
                .andDo(document("email-verification-fail",
                        getResponsePreProcessor(),
                        UserResponseSnippet.emailVerificationsFailSnippet()));
    }

    private String createCode(){
        int length = 6;
        try{
            Random random = SecureRandom.getInstanceStrong();
            StringBuilder builder = new StringBuilder();
            for(int i = 0; i < length; i++){
                builder.append(random.nextInt(10));
            }
            return builder.toString();
        } catch(NoSuchAlgorithmException e){
            throw new BusinessLogicException(ErrorCode.NO_SUCH_ALGORITHM);
        }
    }

}

 

작성한 테스트 코드는 회원 조회, 이메일 코드 성공/실패가 있다.(이메일 인증 관련은 다음 글에서 설명한다.)

 

 

Auth 통합 테스트 Class

 

public class AuthIntegrationTest extends BaseIntegrationTest {
    private final String BASE_URL = "/auth";
    private final String EMAIL = "email@gmail.com";

    @Autowired
    private UserService userService;

    @Autowired
    private RedisService redisService;

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private AES128Config aes128Config;


    @BeforeEach
    void beforeEach(){
        UserDto.SignUp signUp = StubData.MockUser.getSignUpDto();
        userService.signUp(signUp);
    }

    @AfterEach
    void afterEach(){
        userService.deleteUser(EMAIL);
    }

    @Test
    @DisplayName("로그인 성공")
    public void loginSuccessTest() throws Exception{
        //given
        LoginDto loginSuccessDto = StubData.MockUser.getLoginSuccessDto();
        LoginResponse expectedResponseDto = StubData.MockUser.getLoginResponseDto();

        //when
        String uri  = UriComponentsBuilder.newInstance().path(BASE_URL + "/login")
                .build().toUri().toString();
        String json = ObjectMapperUtils.asJsonString(loginSuccessDto);
        ResultActions actions = ResultActionsUtils.getRequest(mockMvc, uri, json);

        //then
        LoginResponse responseDto = ObjectMapperUtils.actionsSingleResponseToLoginDto(actions);
        assertThat(expectedResponseDto.getEmail()).isEqualTo(responseDto.getEmail());
        assertThat(expectedResponseDto.getNickName()).isEqualTo(responseDto.getNickName());

        actions
                .andExpect(status().isOk())
                .andDo(document("login-success",
                        getRequestPreProcessor(),
                        getResponsePreProcessor()));
    }

    @Test
    @DisplayName("로그인 실패")
    void loginFailTest() throws Exception {
        // given
        LoginDto loginFailDto = StubData.MockUser.getLoginFailDto();

        // when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/login")
                .build().toUri().toString();
        String json = ObjectMapperUtils.asJsonString(loginFailDto);
        ResultActions actions = ResultActionsUtils.getRequest(mockMvc, uri, json);

        // then
        actions
                .andExpect(status().isUnauthorized())
                .andDo(document("login-fail",
                        getRequestPreProcessor(),
                        getResponsePreProcessor()));
    }
    @Test
    @DisplayName("로그아웃")
    public void logoutTest() throws Exception{
        //given
        User testUser = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(testUser);
        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
        redisService.setValues(EMAIL, refreshToken, Duration.ofMillis(10000));

        //when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/logout")
                .build().toUri().toString();
        ResultActions actions = ResultActionsUtils.patchRequestWithToken(mockMvc, uri, accessToken, encryptedRefreshToken);

        //then
        String redisRefreshToken = redisService.getValues(EMAIL);
        String logout = redisService.getValues(accessToken);

        assertThat(redisRefreshToken).isEqualTo("false");
        assertThat(logout).isEqualTo("logout");
        actions
                .andExpect(status().isNoContent())
                .andDo(document("logout"));
    }

    @Test
    @DisplayName("Access token 재발급 성공")
    public void accessTokenReissureSuccessTest() throws Exception{
        //given
        User findUser = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(findUser);
        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String refreshToken = tokenDto.getRefreshToken();
        redisService.setValues(EMAIL, refreshToken, Duration.ofMillis(10000));
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);

        //when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/reissue")
                .build().toUri().toString();
        ResultActions actions = ResultActionsUtils.patchRequest(mockMvc, uri, encryptedRefreshToken);

        //then
        actions
                .andExpect(status().isOk())
                .andDo(document("access-token-reissue-success"));
    }

    @Test
    @DisplayName("Refresh token 불일치로 Access token 재발급 실패")
    void accessTokenReissrueFailTest() throws Exception {
        // given
        User findUser = userService.findUserByEmail(EMAIL);
        CustomUserDetails userDetails = CustomUserDetails.of(findUser);
        TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
        String refreshToken = tokenDto.getRefreshToken();
        String failRefreshToken = refreshToken + "fail";
        redisService.setValues(EMAIL, failRefreshToken, Duration.ofMillis(10000));
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);

        // when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/reissue")
                .build().toUri().toString();
        ResultActions actions = ResultActionsUtils.patchRequest(mockMvc, uri, encryptedRefreshToken);

        // then
        actions
                .andExpect(status().is(404))
                .andDo(document("reissue-fail-by-token-not-same"));
    }

    @Test
    @DisplayName("Header에 Refresh token이 존재하지 않으면 Access token 재발급 실패")
    void accessTokenReissrueFailTest2() throws Exception {
        // when
        String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/reissue")
                .build().toUri().toString();
        ResultActions actions = ResultActionsUtils.patchRequest(mockMvc, uri);

        // then
        actions
                .andExpect(status().is(401))
                .andDo(document("reissue-fail-by-no-refresh-token-in-header",
                        getResponsePreProcessor()));
    }


    private Snippet getFieldErrorSnippetsLong() {
        return responseFields(
                fieldWithPath("error").description("에러 코드"),
                fieldWithPath("error_description").description("에러 상세 설명")
        );
    }
}

 

작성한 테스트 코드는 로그인 성공/실패, 로그아웃, Token관련 테스트 코드가 있다.

 

전체 테스트 구동

 

성공하는 것을 확인할 수 있다.

 

 

AsciiDoctor에 의해 생성된 API 명세서

 

http://localhost:8080/docs/index.html <- 위 경로로 접속 가능하다.

 

보통 테스트 코드들은 마지막에 작성하는 것으로 알고 있는데,

공부할겸 같이 진행하였더니 예외처리부터도 테스트 코드의 성공 여부와 직결되기에

코드 작성에 신중을 가하게 된다.

 

또한, Swagger 설정을 안해도 테스트 코드를 작성함과 동시에 API 명세도 얻을 수 있으므로 좋은 것 같다.

 

토이 프로젝트를 하며 앞으로도 여러 테스트 코드를 작성할텐데 이번 기회에 잘 적용해봐야 겠다.

 

 

본 작업(예외 처리)

 

코드를 작성함에 있어서 예외 처리들을 IllegalStateException으로 처리했는데,

그러다 보니 어느 곳에서 일어난 예외 인지 확인이 어려웠고

테스트 코드를 작성함에 있어서도 문제가 있었다.

 

이대로 계속 되면 나중엔 나비 효과처럼 큰 문제가 될 것 같아 예외처리를 도와주는 클래스를 작성했다.

 

예외 처리는 프로그래밍에서 아주 중요하면서도 아주 어려운 과정이다.

하지만, 상세하고 다양하게 예외를 잡고 처리해준다면, 클라이언트도 그렇고 서버도 더 안정적으로 작동된다.

 

Java 및 Spring에서는 예외 처리와 관련하여 좋은 Annotation을 지원하기 때문에 그것을 이용하였다.

 

1. BusinessLogicException

 

RuntimeException을 상속받아 구현된 비즈니스 로직 예외 처리 클래스이다.

 

RuntimeException은 JVM의 정상적인 작동 중에 발생할 수 있는 예외의 슈퍼 클래스이다.

따라서, 해당 서브 클래스는 unchecked exception이다.(메서드 또는 생성자의 실행에 의해 발생)

 

@Getter
@AllArgsConstructor
public class BusinessLogicException extends RuntimeException{
    ErrorCode errorCode;
}

 

 

 

2. @ExceptionHandler & @ControllerAdvice

 

@ExceptionHandler같은 경우 @Controller, @RestController가 적용된 Bean내에서 발생하는 예외를 잡아

하나의 메서드에서 처리해주는 기능을 한다.

 

  • Controller, RestController에만 적용이 가능(@Service같은 Bean에서는 안된다.)
  • 리턴 타입은 자유.
  • @ExceptionHandler를 등록한 Controller에만 적용

 

@ExceptionHandler가 하나의 클래스에 대한 것이라면, @ControllerAdvice는 모든 @Contorller

즉, 전역에서 발생할 수 있는 예외를 잡아 처리해준다.

 

@RestControllerAdvice
@Slf4j
public class BusinessLogicExceptionHandler {

    @ExceptionHandler(BusinessLogicException.class)
    protected ResponseEntity<ErrorResponseEntity> handleBusinessLogicException(BusinessLogicException e){
        log.error("BusinessException", e);
        return ErrorResponseEntity.toResponseEntity(e.getErrorCode());
    }
}

 

3. ErrorCode(Enum class)

 

발생할 수 있는 예외와 그것들의 HttpStatus 반환값과 메시지를 담고 있는

Enum Class이다. 해당 클래스에 다양한 값을 설정해놓고 작성한

BusinessLogicException 클래스와 @ControllerAdvice가 설정된 BusinessLogicExceptionHandler를 활용해

간단하게 예외를 던지는 코드 작성이 가능하다.

 

@AllArgsConstructor
@Getter
public enum ErrorCode {
    /* 400 BAD_REQUEST : 잘못된 요청 */
    /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */
    INVALID_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "권한 정보가 없는 토큰입니다."),

    /* 404 NOT_FOUND : Resource를 찾을 수 없음 */
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당하는 정보의 사용자를 찾을 수 없습니다."),

    /* 409 : CONFLICT : Resource의 현재 상태와 충돌. 보통 중복된 데이터 존재 */
    DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "데이터가 이미 존재합니다."),

    INVALID_GENDER(HttpStatus.NOT_ACCEPTABLE, "성별이 잘못되었습니다."),

    NOT_ENOUGH_STOCK(HttpStatus.NOT_ACCEPTABLE, "재고가 충분하지 않습니다."),

    INVALID_CANCEL(HttpStatus.NOT_ACCEPTABLE, "이미 배송 출발하여 취소가 불가능합니다."),

    REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰을 찾을 수 없습니다."),

    UNABLE_TO_SEND_EMAIL(HttpStatus.INTERNAL_SERVER_ERROR, "메시지를 발송할 수 없습니다."),

    USER_EXISTS(HttpStatus.CONFLICT, "유저가 이미 존재합니다."),

    NO_SUCH_ALGORITHM(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL CreateCode exception occur"),

    VERIFIED_USER(HttpStatus.CONFLICT, "이미 인증된 유저입니다."),

    INVALID_CODE(HttpStatus.BAD_REQUEST, "잘못된 코드입니다.");

    private final HttpStatus httpStatus;
    private final String message;


}

 

4. ErrorResponseEntity

 

발생한 예외를 ResponseEntity로 변환하여 반환하기 위해 사용되는 클래스이다.

HttpStatus 값, 에러 코드, 에러 메시지를 담아 반환한다.

BusinessLogicExceptionHandler에서 자동적으로 변형되어 반환된다.

 

@Data
@Builder
public class ErrorResponseEntity {
    private int status;
    private String code;
    private String message;

    public static ResponseEntity<ErrorResponseEntity> toResponseEntity(ErrorCode e){
        return ResponseEntity
                .status(e.getHttpStatus())
                .body(ErrorResponseEntity.builder()
                        .status(e.getHttpStatus().value())
                        .code(e.name())
                        .message(e.getMessage())
                        .build());
    }
}

 

 

위와 같이 설정함으로 우리는 비즈니스 로직에 더 집중할 수 있다.

코드도 간단하게 throw new BusinessLogicException(); 으로 호출하면 끝나기 때문에 유지보수에 용이하다.

 

 

글을 마무리하며

 

이전 토이 프로젝트 글에서도 말했지만, 테스트 코드가 중요하다는 것을 깨닫는다.

보안적으로도 그렇지만, 코드가 수정되었을 때 유지보수에 아주 큰 도움을 준다.

 

또한, 테스트 코드를 작성하니 좀 더 예외처리도 체계적으로 작성되는 것 같다.

그래서 여러 Spring Security 관련 오류들도 쉽게 찾고 수정이 가능하다.

이메일 인증관련하여 코드를 이미 작성하고 검증도 했지만, 이 글에 담기엔 글이 너무 길어저

 

다음 포스팅에 담도록 하려한다.

 

화이팅!!

 

 

참고 블로그

 

https://green-bin.tistory.com/

 

개발하는 콩

한 가지를 대하는 태도를 보면, 만 가지를 대하는 태도를 알 수 있다.

green-bin.tistory.com