반응형

저번 시간에는 로그인 및 회원가입에 대한 서비스 로직을 작성하였습니다.

이번 시간에는, 작성된 서비스 로직을 이용하여 로그인과 회원가입의 웹 계층 API 구현을 해보도록 하겠습니다.

 

 

구현을 시작하기에 앞서, 요청 객체를 검증하기 위한 dependency를 미리 추가해두도록 하겠습니다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

 

먼저 일관성 있는 응답 구조를 위해 응답 객체를 작성해보도록 하겠습니다.

컨트롤러 계층에서 응답을 위해서만 사용될 것이므로, controller.response 패키지에 Response 클래스를 작성해줍니다.

package kukekyakya.kukemarket.controller.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;

@JsonInclude(JsonInclude.Include.NON_NULL) // 1
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 2
@Getter // 3
public class Response {
    private boolean success;
    private int code;
    private Result result;

    public static Response success() { // 4
        return new Response(true, 0, null);
    }

    public static <T> Response success(T data) { // 5
        return new Response(true, 0, new Success<>(data));
    }

    public static Response failure(int code, String msg) { // 6
        return new Response(false, code, new Failure(msg));
    }
}

Response 객체는 요청 성공 여부와 응답 코드, 응답 데이터를 가지고 있습니다.

요청이 성공하면, 응답 코드는 0의 값을 가지게 됩니다.

요청이 실패하면, 특정한 응답 코드를 가지게 되고, 실패 원인을 식별하는데 사용됩니다.

1. null 값을 가지는 필드는, JSON 응답에 포함되지 않도록 합니다.

2. 스태틱 펙토리 메소드(4~6)를 이용하여 인스턴스를 생성하므로, 생성자의 접근 제어 레벨은 private으로 설정해줍니다.

3. 응답 객체를 JSON으로 변환하려면 getter가 필요합니다.

4. 요청은 성공했으나, 응답해야할 별다른 데이터가 없을 때 사용합니다. 필드가 null 값을 가지는건 썩 내키지않지만, 웹 계층에서만 사용되기도 하고, @JsonInclude 어노테이션으로 null 값을 제외할 수 있으니, 코드의 간소화를 위해 일단 허용하도록 하겠습니다.  

5. 성공했을 때는 응답 데이터도 반환해줍니다.

6. 실패했을 때는 실패 메시지도 반환해줍니다.

 

 

Result 인터페이스와 Success, Failure 클래스는 다음과 같습니다.

package kukekyakya.kukemarket.controller.response;

interface Result {
}
package kukekyakya.kukemarket.controller.response;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class Success<T> implements Result {
    private T data;
}
package kukekyakya.kukemarket.controller.response;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class Failure implements Result {
    private String msg;
}

Response와 함께 controller.response 패키지에 작성하였고, 이에 대한 별도의 설명은 생략하겠습니다.

 

 

 

사실 @JsonInclude 어노테이션은 처음 사용해보기에, 이를 검증해보는 학습 테스트를 작성해보도록 하겠습니다.

test 디렉토리에서 learning 패키지에 WebMvcTest를 작성해줍니다.

package learning;

import kukekyakya.kukemarket.controller.response.Response;
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.junit.jupiter.MockitoExtension;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(MockitoExtension.class)
public class WebMvcTest {

    @InjectMocks TestController testController;
    MockMvc mockMvc; // 1

    @Controller // 2
    public static class TestController {
        @GetMapping("/test/ignore-null-value")
        public Response ignoreNullValueTest() {
            return Response.success();
        }
    }

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(testController).build(); // 3
    }

    @Test
    void ignoreNullValueInJsonResponseTest() throws Exception {
        mockMvc.perform( // 4
                get("/test/ignore-null-value"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result").doesNotExist());
    }
}

이번에도 Mockito를 이용하여 테스트를 진행하였습니다.

1. 컨트롤러로 요청을 보내기 위해 MockMvc를 이용합니다.

2. 테스트 용도의 간단한 컨트롤러를 작성해줍니다. 작성된 @GetMapping에서는, 단순히 아까 정의한 Response.success()를 반환해줍니다. result 필드가 포함되어있지 않다는 것을 검증해야합니다.

3. Mockito를 이용하여 TestController를 띄워줍니다. 이제 MockMvc로 컨트롤러에 요청을 보내서 테스트할 수 있습니다.

4. MockMvc.perform으로 요청을 보내고, 그 결과를 검증할 수 있습니다. "/test/ignore-null-value"로 get 요청을 보낸 뒤, 응답 상태 코드 200과 응답 JSON에 result 필드가 없음을 확인하였습니다.

 

 

이제 Response 응답 객체와 SignService를 이용해서 컨트롤러를 작성해보겠습니다.

controller.sign 패키지에 SignController를 작성해줍니다.

package kukekyakya.kukemarket.controller.sign;

import kukekyakya.kukemarket.controller.response.Response;
import kukekyakya.kukemarket.dto.sign.SignInRequest;
import kukekyakya.kukemarket.dto.sign.SignUpRequest;
import kukekyakya.kukemarket.service.sign.SignService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

import static kukekyakya.kukemarket.controller.response.Response.success;

@RestController // 1
@RequiredArgsConstructor
public class SignController {
    private final SignService signService;

    @PostMapping("/api/sign-up") 
    @ResponseStatus(HttpStatus.CREATED)
    public Response signUp(@Valid @RequestBody SignUpRequest req) { // 2
        signService.signUp(req);
        return success();
    }

    @PostMapping("/api/sign-in") 
    @ResponseStatus(HttpStatus.OK)
    public Response signIn(@Valid @RequestBody SignInRequest req) { // 3
        return success(signService.signIn(req));
    }
}

1. JSON으로 응답하기 위해, @RestController를 선언해줍니다. 객체를 반환하면, JSON으로 변환해줍니다.

2. 회원 가입에 성공하면, 201 상태 코드를 응답합니다. 요청으로 전달받는 JSON 바디를 객체로 변환하기 위해 @RequestBody를 선언해주고, Request 객체의 필드 값을 검증하기 위해 @Valid를 선언해줍니다.

3. 정상적으로 로그인되면, 200 상태 코드와 데이터(여기에선 토큰)를 응답해줍니다.

 

 

***

- 생성 요청에 대한 결과 데이터를 꼭 응답해줘야하는가?

생성 요청에 의해 어떤 데이터를 서버에 만들어내면, 그거에 대한 결과 데이터까지 꼭 응답해줘야하는지가 고민이었습니다.

예를 들어, 사용자가 회원 가입을 성공적으로 끝마치면, 회원가입한 사용자에 대한 정보를 응답으로 보내줘야하는가에 대한 문제입니다.

 

일단 제가 내린 결론은, 그럴 필요가 없다는 것입니다.

정상적인 사용자라면, 자신이 보낸 요청이 어떤 내용인지 알고 있습니다.

어떤 데이터를 보냈는지, 어떻게 처리될 것인지를 기대하고 있습니다.

이러한 까닭에, 굳이 알고 있는 정보에 대해서 다시 응답으로 보내줄 필요는 없다고 생각되었습니다.

지금 작성한 회원가입 API는 다시 응답을 내려줄 필요가 없습니다.

자신이 가입 요청한 데이터가 무엇인지 알고 있고, 알고 있는 사실을 기반으로 로그인을 요청할 것이기 때문입니다.

네트워크 통신 비용은 비싼데, 이를 낭비할 이유는 없습니다.

 

하지만 예외 사항도 있습니다.

어떤 글을 작성했다면, 보통 작성과 동시에 자신의 글 상세 조회 페이지로 이동하게 됩니다.

글을 조회하려면, 적어도 서버에서 생성된 글의 id 정도는 알아야하기 떄문에, 이런 경우에는 글의 id 값이나 리다이렉트할 URL 정도는 알려주어야합니다.

 

또는, 글 생성 결과를 즉시 반환해주는 방법도 있겠습니다. 생성 요청과 조회를 동시에 수행하므로, 글의 id로 요청을 보내서 글 데이터를 다시 조회할 필요가 없습니다. 하지만 이렇게 할 경우, 그 페이지에 또 다른 최신 정보가 같이 포함될 경우, 새로운 최신 정보를 포함하지 못할 수도 있습니다.

 

다른 예시로 들어보면, 댓글을 작성했으면 방금 자신이 작성한 댓글 뿐만 아니라, 새롭게 작성된 댓글들도 알아야합니다. 댓글 생성을 위한 POST 요청의 응답으로 새로운 댓글 리스트를 돌려주는 방법도 있고, 그냥 최신 정보를 처음부터 다시 요청하는 방법도 있겠습니다.

 

정리하면, 생성 요청에 대한 응답 결과가 굳이 필요하지 않다면, 처리 결과에 대한 상태 코드 정도만 응답해주고,

응답 결과가 필요하다면, 상황에 맞게 생성과 조회를 같이 수행하거나 꼭 필요한 데이터만 내려주는 방법, 또는 그냥 새롭게 다시 요청하는 방법이 있겠습니다. 캐시를 이용하면서 다시 요청하더라도 전달되는 데이터를 최소화하는 방법도 있겠고요.

결국 각자의 선택인 것 같습니다.

 

* 주관적인 생각입니다.

***

 

 

***

- 토큰 발급 및 검증 과정에 대해 SignService, TokenService, JwtHandler를 분리한 이유

SignService는 회원가입과 로그인에 대한 로직을 담당합니다.

TokenService는 우리의 서비스에서 사용할 토큰 발급과 검증을 담당합니다.

JwtHandler는 jwt 발급과 검증을 담당합니다.

 

처음에는 SignService가 TokenService의 역할도 함께 수행하거나, TokenService가 JwtHandler의 역할도 함께 수행하는 식으로 구상했었습니다.

 

하지만 SignService가 TokenService의 역할도 수행한다면, 토큰을 발급하기 위한 구체적인 정보를 너무 많이 알아야합니다. 사용되는 key는 무엇인지, 만료 기간은 얼마나되는지, 어떤 방식을 이용해서 토큰이 발급되는지 세세한 사항까지 기억해줘야합니다. 이러한 까닭에, SignService가 알아야할 내용을 줄이고, 토큰을 발급해주는 책임은 TokenService로 넘겨주었습니다. SignService는 결국 로그인을 처리되었을 때의 필요한 토큰만 응답으로 반환해주면 됩니다. 토큰의 세부적인 설정사항까지 알 필요가 없습니다. 만약 토큰의 세부적인 설정사항까지 SignService가 알게 된다면, 어떤 설정 사항이나 방법의 변화가 일어났을 때, 그에 해당하는 부분만 찾아서 변경해주어야하고, 너무 많은 책임을 떠안게 됩니다.

 

또, TokenService가 JwtHandler의 역할도 수행한다면, TokenService는 우리의 서비스에서 사용될 토큰을 발급해주는 책임 외에도, 자신이 발급해야하는 토큰이 jwt라는 것도 알아야하고, jwt를 생성하고 검증하는 세부적인 사항도 알고 있어야합니다. 만약 토큰 발급 방식이 바뀌게 된다면, 실질적으로 토큰을 발급 및 검증해주는 JwtHandler만 다른 구현체(물론, 지금은 단일한 클래스지만)로 바꿔주면 되는데, 너무 많은 책임을 가지게 된 TokenService의 코드에서 실질적인 토큰 생성 및 검증 부분만 찾아서 변경을 해줘야합니다. TokenService는 그냥 특정한 토큰 생성 방법을 이용해서, 액세스 토큰과 리프레시 토큰을 발급해주기만 하면 될 뿐인데, 너무 많은 책임을 가지고 있는 바람에 여러가지 이유로 인해 변경이 일어나게 되는 상황입니다. 

 

이러한 이유로 인해, 서비스에서 사용될 토큰을 발급해주는 하나의 과정은 세 개의 객체가 서로 협력하며 수행하도록 하였습니다. 

주관적인 기준이고, 앞으로 진행하면서 상황에 따라 다시 변경될 수 있습니다.

***

 

 

이제 SignController를 테스트해보겠습니다.

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

package kukekyakya.kukemarket.controller.sign;

import com.fasterxml.jackson.databind.ObjectMapper;
import kukekyakya.kukemarket.dto.sign.SignInRequest;
import kukekyakya.kukemarket.dto.sign.SignInResponse;
import kukekyakya.kukemarket.dto.sign.SignUpRequest;
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.BDDMockito.given;
import static org.mockito.BDDMockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

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

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

    @Test
    void signUpTest() throws Exception {
        // given
        SignUpRequest req = new SignUpRequest("email@email.com", "123456a!", "username", "nickname");

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

        verify(signService).signUp(req);
    }

    @Test
    void signInTest() throws Exception {
        // given
        SignInRequest req = new SignInRequest("email@email.com", "123456a!");
        given(signService.signIn(req)).willReturn(new SignInResponse("access", "refresh"));

        // when, then
        mockMvc.perform(
                post("/api/sign-in")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.data.accessToken").value("access")) // 3
                .andExpect(jsonPath("$.result.data.refreshToken").value("refresh"));

        verify(signService).signIn(req);
    }

    @Test
    void ignoreNullValueInJsonResponseTest() throws Exception { // 4
        // given
        SignUpRequest req = new SignUpRequest("email@email.com", "123456a!", "username", "nickname");

        // when, then
        mockMvc.perform(
                post("/api/sign-up")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.result").doesNotExist());

    }
}

위에서 작성했던 @JsonInclude 학습 테스트와 중복되는 설명은 생략하겠습니다.

1. 객체를 JSON 문자열로 변환하기 위해 선언해줍니다.

2. ObjectMapper.writeValueAsString을 이용하면, 객체를 JSON 문자열로 변환할 수 있습니다. content에 이를 넣어주면, 요청 바디에 담기게 됩니다. contentType으로 APPLICATION_JSON 타입을 지정해줍니다.

3. SignService.signIn의 반환 값으로 준비했던 값을, 응답 JSON에 포함되어있는지 검증해줍니다.

4. 학습 테스트에서 이미 테스트했던 내용이지만, "/api/sign-up"의 응답 결과로 반환되는 JSON 문자열 또한 올바르게 제거되는지 다시 한번 검증해보았습니다.

 

 

컨트롤러 계층에서는, 각 url에 요청이 제대로 전달되는지와 의도한 응답을 받을 수 있는지를 검증하였습니다.

이제 요청 객체인 SignUpRequest와 SignInRequest에 각 필드마다 유효성 검사를 설정해두겠습니다.

 

먼저, SignUpRequest 입니다.

package kukekyakya.kukemarket.dto.sign;

import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;

import javax.validation.constraints.*;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SignUpRequest {

    @Email(message = "이메일 형식을 맞춰주세요.")
    @NotBlank(message = "이메일을 입력해주세요.")
    private String email; // 1

    @NotBlank(message = "비밀번호를 입력해주세요.")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$",
            message = "비밀번호는 최소 8자리이면서 1개 이상의 알파벳, 숫자, 특수문자를 포함해야합니다.")
    private String password; // 2

    @NotBlank(message = "사용자 이름을 입력해주세요.")
    @Size(min=2, message = "사용자 이름이 너무 짧습니다.")
    @Pattern(regexp = "^[A-Za-z가-힣]+$", message = "사용자 이름은 한글 또는 알파벳만 입력해주세요.")
    private String username; // 3

    @NotBlank(message = "닉네임을 입력해주세요.")
    @Size(min=2, message = "닉네임이 너무 짧습니다.")
    @Pattern(regexp = "^[A-Za-z가-힣]+$", message = "닉네임은 한글 또는 알파벳만 입력해주세요.")
    private String nickname; // 4

    public static Member toEntity(SignUpRequest req, Role role, PasswordEncoder encoder) {
        return new Member(req.email, encoder.encode(req.password), req.username, req.nickname, List.of(role));
    }
}

1. email 필드는 공백 또는 누락되면 안되며, 이메일 형식을 갖춰야합니다.

2. password 필드는 공백 또는 누락되면 안되며, 8자리 이상이면서 1개 이상의 알파벳, 숫자, 특수문자를 포함해야합니다. @Pattern을 이용해서 정규표현식을 등록하였습니다.

3. username 필드는 공백 또는 누락되면 안되며, 최소 2글자 이상이면서 한글 또는 알파벳만 사용해야합니다.

4. nickname 필드는 공백 또는 누락되면 안되며, 최소 2글자 이상이면서 한글 또는 알파벳만 사용해야합니다.

만약 이를 위반한다면, MethodArgumentNotValidException 예외가 발생하게 됩니다.

 

다음으로, SignInRequest 입니다.

package kukekyakya.kukemarket.dto.sign;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SignInRequest {

    @Email(message = "이메일 형식을 맞춰주세요.")
    @NotBlank(message = "이메일을 입력해주세요.")
    private String email; // 1

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password; // 2
}

1. SignUpRequest와 동일한 제약 조건입니다.

2. 여기에서는 별다른 비밀번호 정책을 설정하지 않았습니다. 비밀번호 정책은 언제든 바뀔 수 있습니다. 그런데 로그인을 할 때도 비밀번호 정책을 지켜야한다면, 어떤 시점에 올바른 비밀번호를 설정했더라도 정책이 바뀌었을 때 로그인을 못하는 상황이 발생합니다. 따라서 @NotBlank만 선언해주었습니다.

 

 

 

이제 이에 대한 테스트를 진행해보겠습니다.

사실, SignUpRequest와 SignInRequest의 유효성 검사는 컨트롤러 계층에서 MockMvc를 이용하여 수행해도 됩니다.

하지만 컨트롤러를 로드하기 위한 비용이 들기 때문에, 테스트 수행 시간이 비교적 오래걸리게 됩니다.

이 때문에 컨트롤러 계층이 아닌, 별도로 테스트를 수행하였습니다.

 

test 디렉토리에서, 동일한 패키지 경로 내에 SignInRequestValidationTest를 만들어줍니다.

package kukekyakya.kukemarket.dto.sign;

import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Set;

import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;

class SignInRequestValidationTest {
    Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); // 1

    @Test
    void validateTest() {
        // given
        SignInRequest req = createRequest();

        // when
        Set<ConstraintViolation<SignInRequest>> validate = validator.validate(req); // 2

        // then
        assertThat(validate).isEmpty(); // 3
    }

    @Test
    void invalidateByNotFormattedEmailTest() {
        // given
        String invalidValue = "email";
        SignInRequest req = createRequestWithEmail(invalidValue);

        // when
        Set<ConstraintViolation<SignInRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty(); // 4
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue); // 5
    }

    @Test
    void invalidateByEmptyEmailTest() {
        // given
        String invalidValue = null;
        SignInRequest req = createRequestWithEmail(invalidValue);

        // when
        Set<ConstraintViolation<SignInRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankEmailTest() {
        // given
        String invalidValue = " ";
        SignInRequest req = createRequestWithEmail(invalidValue);

        // when
        Set<ConstraintViolation<SignInRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByEmptyPasswordTest() {
        // given
        String invalidValue = null;
        SignInRequest req = createRequestWithPassword(invalidValue);

        // when
        Set<ConstraintViolation<SignInRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankPasswordTest() {
        // given
        String invalidValue = " ";
        SignInRequest req = createRequestWithPassword(" ");

        // when
        Set<ConstraintViolation<SignInRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    private SignInRequest createRequest() { // 6
        return new SignInRequest("email@email.com", "123456a!");
    }

    private SignInRequest createRequestWithEmail(String email) { // 7
        return new SignInRequest(email, "123456a!");
    }

    private SignInRequest createRequestWithPassword(String password) { // 8
        return new SignInRequest("email@email.com", password);
    }
}

코드는 길지만, 비슷한 코드이기 때문에 주석으로 숫자가 달려있는 몇가지 사항에 대해서만 언급하고 넘어가도록 하겠습니다.

1. 검증 작업을 수행하기 위해 Validator를 빌드해줍니다.

2. 검증을 수행합니다. 제약 조건을 위반한 내용들을 응답 결과로 받게 됩니다.

3. 제약 조건을 모두 지키고 올바르게 검증되었다면, 응답 결과는 비어있습니다.

4. 하지만 제약 조건을 위반했다면, 응답 결과는 비어있지 않습니다.

5. 응답 결과에서 제약 조건을 위반한 객체를 꺼내서, given에서 설정해두었던 위반된 값을 가지고 있는지 확인합니다.

6. 정상적인 요청 객체를 생성하는 팩토리 메소드입니다.

7. 전달 받은 email 필드 외에는 정상적인 요청 객체를 생성하는 팩토리 메소드입니다.

8. 전달 받은 password 필드 외에는 정상적인 요청 객체를 생성하는 팩토리 메소드입니다.

email 필드가 이메일 형식을 따르는지 공백으로 비어있거나 아예 없진 않은지, password 필드가 공백으로 비어있거나 아예 없진 않은지 제약 조건을 검사해주었습니다.

 

* 제약 조건을 위반한 내용의 개수까지 정확하게 검증하지 않고 NotEmpty로 검증한 이유는, 어떤 제약 조건을 위반하는지 정확히 기대한 값을 돌려주지 않기 때문입니다. 예를 들어, email 필드에 null 값이 들어간 경우, 2개의 제약 조건을 위반하게 됩니다. 하지만 실제로는 1개의 제약 조건만 응답하게 됩니다. 하지만 email 필드에 공백 값이 들어간 경우, 2개의 제약 조건을 응답하게 됩니다. 또한, 각 제약 조건의 검증 순서는 직접 제어해둔 상황이 아닙니다. 순서를 직접 제어하고 규칙을 찾아낼 수도 있겠지만, 제약 조건이 위반되었다는 사실과 이를 위반한 필드 값이 무엇인지만 검증해도 충분한 테스트가 될 것이라고 생각되었습니다.

 

 

이번에는 SignUpRequestValidationTest를 작성해보겠습니다.

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

package kukekyakya.kukemarket.dto.sign;

import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Set;

import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class SignUpRequestValidationTest {

    Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void validateTest() {
        // given
        SignUpRequest req = createRequest();

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isEmpty();
    }

    @Test
    void invalidateByNotFormattedEmailTest() {
        // given
        String invalidValue = "email";
        SignUpRequest req = createRequestWithEmail(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByEmptyEmailTest() {
        // given
        String invalidValue = null;
        SignUpRequest req = createRequestWithEmail(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankEmailTest() {
        // given
        String invalidValue = " ";
        SignUpRequest req = createRequestWithEmail(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByEmptyPasswordTest() {
        // given
        String invalidValue = null;
        SignUpRequest req = createRequestWithPassword(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankPasswordTest() {
        // given
        String invalidValue = "        ";
        SignUpRequest req = createRequestWithPassword(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByShortPasswordTest() {
        // given
        String invalidValue = "12312a!";
        SignUpRequest req = createRequestWithPassword(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNoneAlphabetPasswordTest() {
        // given
        String invalidValue = "123!@#123";
        SignUpRequest req = createRequestWithPassword(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNoneNumberPasswordTest() {
        // given
        String invalidValue = "abc!@#abc";
        SignUpRequest req = createRequestWithPassword(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNoneSpecialCasePasswordTest() {
        // given
        String invalidValue = "abc123abc";
        SignUpRequest req = createRequestWithPassword(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByEmptyUsernameTest() {
        // given
        String invalidValue = null;
        SignUpRequest req = createRequestWithUsername(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankUsernameTest() {
        // given
        String invalidValue = " ";
        SignUpRequest req = createRequestWithUsername(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByShortUsernameTest() {
        // given
        String invalidValue = "한";
        SignUpRequest req = createRequestWithUsername(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNotAlphabetOrHangeulUsernameTest() {
        // given
        String invalidValue = "송2jae";
        SignUpRequest req = createRequestWithUsername(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByEmptyNicknameTest() {
        // given
        String invalidValue = null;
        SignUpRequest req = createRequestWithNickname(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankNicknameTest() {
        // given
        String invalidValue = " ";
        SignUpRequest req = createRequestWithNickname(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByShortNicknameTest() {
        // given
        String invalidValue = "한";
        SignUpRequest req = createRequestWithNickname(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNotAlphabetOrHangeulNicknameTest() {
        // given
        String invalidValue = "송2jae";
        SignUpRequest req = createRequestWithNickname(invalidValue);

        // when
        Set<ConstraintViolation<SignUpRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }



    private SignUpRequest createRequest() {
        return new SignUpRequest("email@email.com", "123456a!", "username", "nickname");
    }

    private SignUpRequest createRequestWithEmail(String email) {
        return new SignUpRequest(email, "123456a!", "username", "nickname");
    }

    private SignUpRequest createRequestWithPassword(String password) {
        return new SignUpRequest("email@email.com", password, "username", "nickname");
    }

    private SignUpRequest createRequestWithUsername(String username) {
        return new SignUpRequest("email@email.com", "123456a!", username, "nickname");
    }

    private SignUpRequest createRequestWithNickname(String nickname) {
        return new SignUpRequest("email@email.com", "123456a!", "username", nickname);
    }
}

코드가 길고, SignInRequestValidationTest와 크게 다를 점이 없어서 설명은 생략하도록 하겠습니다.

테스트할 때, 개인적으로 생각하는 주의할 점 하나만 짚고 넘어가자면, 어떠한 경계 값을 테스트해봐야한다는 것입니다.

예를 들어, 비밀번호는 8자리 이상이어야합니다. 이를 검증하기 위해서는, 검증이 실패하는 상황에는 7자리를 테스트하고, 검증이 성공하는 상황에는 8자리를 테스트합니다.

이렇게 경계 값을 테스트한다면 더욱 세밀한 테스트를 작성할 수 있을 것이라 생각됩니다.

 

 

 

이번 시간에는, 로그인 및 회원가입에 대한 웹 계층을 작성하며 웹 API를 구현하였습니다.

이제 Authorization, 즉 사용자의 권한에 대한 기능을 작성한다면, 로그인 기능은 얼추 마무리가 됩니다.

하지만 이 기능을 구현하기에 앞서, ExceptionAdvice를 이용하여 예외를 다루는 방법을 먼저 개선해보도록 하겠습니다.

다음 시간에는, ExceptionAdvice로 우리의 애플리케이션에서 던져지는 다양한 예외들을 간편하게 다루는 방법을 알아보도록 하겠습니다.

 

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

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

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

반응형

+ Recent posts