Spring Boot RESTFulAPI 샘플

p-q·2023년 11월 21일
0

1. RESTful API 란?

RESTful API는 두 컴퓨터 시스템이 인터넷을 통해 정보를 안전하게 교환하기 위해 사용하는 인터페이스입니다. 대부분의 비즈니스 애플리케이션은 다양한 태스크를 수행하기 위해 다른 내부 애플리케이션 및 서드 파티 애플리케이션과 통신해야 합니다. 예를 들어 월간 급여 명세서를 생성하려면 인보이스 발행을 자동화하고 내부의 근무 시간 기록 애플리케이션과 통신하기 위해 내부 계정 시스템이 데이터를 고객의 뱅킹 시스템과 공유해야 합니다. RESTful API는 안전하고 신뢰할 수 있으며 효율적인 소프트웨어 통신 표준을 따르므로 이러한 정보 교환을 지원합니다.

2. 샘플 설명

Spring Boot, Spring MVC, Spring Test, JUnit, Mockito 등 을 사용하여 User 에 관한 기본적인 REST API 와 단위테스트에 대한 방법을 설명합니다.

3. REST API

Git : https://github.com/Get-bot/springSamepleCode

1. Controller

UserApiController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserApiController {

  private final UserService userService;

  @GetMapping("/{id}")
  public ResponseEntity<ApiResponse<UserDTO>> getUser(@NotNull @PathVariable("id") Long id) {
    return userService.get(id);
  }

  @GetMapping
  public ResponseEntity<ApiResponse<List<UserDTO>>> getUserList() {
    return userService.getList();
  }

  @PostMapping
  public ResponseEntity<ApiResponse<UserDTO>> registerUser(@RequestBody @Valid UserRegisterDTO userRegisterDTO) {
    return userService.register(userRegisterDTO);
  }

  @PutMapping("/{id}")
  public ResponseEntity<ApiResponse<UserDTO>> updateUser(@NotNull @PathVariable("id") Long id, @RequestBody @Valid UserUpdateDTO userUpdateDTO) {
    return userService.update(id, userUpdateDTO);
  }
  
  @DeleteMapping("/{id}")
  public ResponseEntity<ApiResponse<?>> deleteUser(@NotNull @PathVariable("id") Long id) {
    return userService.delete(id);
  }
  
}
  • @RestController : @Controller에 @ResponseBody가 결합된 어노테이션 으로 컨트롤러 클래스 하위 메서드에 @ResponseBody 어노테이션을 붙이지 않아도 문자열과 JSON 등을 전송할 수 있습니다.

2. Entity and DTO

User

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String username;

  private String email;

  private String password;

  public static User setUser(Long id, String username, String email, String password) {
    User user = new User();
    user.id = id;
    user.username = username;
    user.email = email;
    user.password = password;
    return user;
  }

  public static User setRegisterUser(UserRegisterDTO userRegisterDTO) {
    User user = new User();
    user.username = userRegisterDTO.getUsername();
    user.email = userRegisterDTO.getEmail();
    user.password = userRegisterDTO.getPassword();
    return user;
  }

  public void updateWithUpdateDTO(UserUpdateDTO userUpdateDTO) {
    this.email = userUpdateDTO.getEmail();
    if (userUpdateDTO.getPassword() != null && !userUpdateDTO.getPassword().equals(this.password)) {
      this.password = userUpdateDTO.getPassword();
    }
  }
}

Entity 내부에 정적 메소드를 선언하여 캡슐화 하고 팩토리 메소드 역할을 하게하여 User 인스턴스를 생성하는 방법을 명확하게 하였습니다.

UserDTO

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
  private Long id;
  private String username;
  private String email;

  public static UserDTO setUserDTO(User user) {
    return new UserDTO(user.getId(), user.getUsername(), user.getEmail());
  }

}

UserRegisterDTO

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserRegisterDTO {

  @NotEmpty
  private String username;

  @Email
  @NotEmpty
  private String email;

  @NotEmpty
  private String password;

  public static UserRegisterDTO setUserRegisterDTO(String username, String email, String password) {
    UserRegisterDTO user = new UserRegisterDTO();
    user.setUsername(username);
    user.setEmail(email);
    user.setPassword(password);
    return user;
  }
}

UserUpdateDTO

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserUpdateDTO {

  @NotEmpty
  private String email;
  private String password;
}

Responce 할 데이터에 Entity 그대로를 사용하지 않고 DTO 를 사용 하는것은 데이터를 캡슐화하여 필요한 정보만 클라이언트에 전송되도록 하는 API 설계에 필수적인 방법입니다. 이 접근 방식은 개인정보 보호와 보안을 유지하고, 내부 데이터 구조와 외부 표현을 분리하며, 맞춤형 API 응답을 허용합니다. 또한 DTO는 데이터 전송을 필수적인 데이터로 제한하여 대역폭 사용량을 줄이고 데이터 직렬화의 유연성을 제공합니다. 전반적으로 DTO는 보다 효율적이고 유연하며 강력한 API 설계에 필요한 요소입니다.

3. Repository

JPA UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

  boolean existsByUsername(String username);
  boolean existsByEmail(String email);
  
}

4. Serivce

* UserService Interface

public interface UserService {

  ResponseEntity<ApiResponse<UserDTO>> get(Long id);

  ResponseEntity<ApiResponse<UserDTO>> register(UserRegisterDTO userRegisterDTO);

  ResponseEntity<ApiResponse<List<UserDTO>>> getList();

  ResponseEntity<ApiResponse<UserDTO>> update(Long id, UserUpdateDTO userUpdateDTO);

  ResponseEntity<ApiResponse<?>> delete(Long id);

}

UserServiceImpl

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{

  private final UserRepository userRepository;

  @Override
  public ResponseEntity<ApiResponse<UserDTO>> get(Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(this::userNotFoundException);

    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "get user", UserDTO.setUserDTO(user)));
  }

  @Override
  @Transactional
  public ResponseEntity<ApiResponse<UserDTO>> register(UserRegisterDTO userRegisterDTO) {
    checkIfUsernameExists(userRegisterDTO.getUsername());

    User user = userRepository.save(User.setRegisterUser(userRegisterDTO));
    URI url = URI.create("/api/v1/users/" + user.getId());
    return ResponseEntity.created(url).body(ApiResponse.setApiResponse(true, "add user", UserDTO.setUserDTO(user)));
  }

  @Override
  public ResponseEntity<ApiResponse<List<UserDTO>>> getList() {
    List<User> userList = userRepository.findAll();
    List<UserDTO> userDTOList = userList.stream()
        .map(UserDTO::setUserDTO)
        .collect(Collectors.toList());

    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "userList find", userDTOList));
  }

  @Override
  @Transactional
  public ResponseEntity<ApiResponse<UserDTO>> update(Long id, UserUpdateDTO userUpdateDTO)  {

    User user = userRepository.findById(id)
        .orElseThrow(this::userNotFoundException);

    if (!user.getEmail().equals(userUpdateDTO.getEmail())) {
      checkIfEmailExists(userUpdateDTO.getEmail());
    }

    user.updateWithUpdateDTO(userUpdateDTO);
    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "user update", UserDTO.setUserDTO(user)));
  }

  @Override
  @Transactional
  public ResponseEntity<ApiResponse<?>> delete(Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(this::userNotFoundException);

    userRepository.delete(user);

    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "delete user", null));
  }

  private void checkIfUsernameExists(String username) {
    if (userRepository.existsByUsername(username)) {
      throw new IllegalArgumentException("이미 존재하는 유저 이름입니다.");
    }
  }

  private void checkIfEmailExists(String email) {
    if (userRepository.existsByEmail(email)) {
      throw new IllegalArgumentException("이미 존재하는 이메일 입니다.");
    }
  }

  private NotFoundException userNotFoundException() {
    return new NotFoundException("존재하지 않는 유저입니다.");
  }

5. Exception Handler

1. API paylod

APIErrorResponse

@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class ApiError {
  private HttpStatus status;
  private String message;
  private String debugMessage;
  private LocalDateTime timestamp;

  public static ApiError setApiError(HttpStatus status, String message, Throwable ex) {
    ApiError apiError = new ApiError();
    apiError.status = status;
    apiError.message = message;
    apiError.debugMessage = ex.getLocalizedMessage();
    apiError.timestamp = LocalDateTime.now();
    return apiError;
  }
}

NotFoundExceptin

public class NotFoundException extends RuntimeException{
  public NotFoundException(String message) {
    super(message);
  }
}

GlobalExceptionHandler

@RestControllerAdvice
public class GlobalExceptionHandler {
  @ExceptionHandler(IllegalArgumentException.class)
  protected ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.BAD_REQUEST, ex.getMessage(), ex);
    return buildResponseEntity(apiError);
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  protected ResponseEntity<Object> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.BAD_REQUEST, ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(), ex);
    return buildResponseEntity(apiError);
  }

  @ExceptionHandler(NotFoundException.class)
  protected ResponseEntity<Object> handleUsernameNotFoundException(NotFoundException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.NOT_FOUND, ex.getMessage(), ex);
    return buildResponseEntity(apiError);
  }

  @ExceptionHandler(NoHandlerFoundException.class)
  protected ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.NOT_FOUND, ex.getMessage(), ex);
    return buildResponseEntity(apiError);
  }

  private ResponseEntity<Object> buildResponseEntity(APIErrorResponse apiError) {
    return new ResponseEntity<>(apiError, apiError.getStatus());
  }
}

GlobalExceptionHandler 를 추가하여 Exception 시 해당하는 오류에 맞게 HttpStatus를 구분하여 Error 응답을 사용자에게 전송합니다.

4. UnitTest

UserApiControllerTest

@WebMvcTest(UserApiController.class)
@WithMockUser(username = "테스트_최고관리자", roles = {"SUPER"})
@DisplayName("유저 API 테스트")
class UserApiControllerTest {
  private static final String API_URL = "/api/v1/users";

  @Autowired
  private MockMvc mockMvc;
  @Autowired
  private ObjectMapper objectMapper;
  @MockBean
  private UserService userService;

  @Test
  @DisplayName("유저 추가 API 실패 테스트")
  public void testAddShouldReturn400BadRequest() throws Exception {

    UserRegisterDTO userRegisterDTO = new UserRegisterDTO();

    userRegisterDTO.setUsername("");
    userRegisterDTO.setPassword("test1234");

    String requestBody = objectMapper.writeValueAsString(userRegisterDTO);

    mockMvc.perform(
            post(API_URL)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody)
        )
        .andExpect(status().isBadRequest())
        .andDo(print());
  }

  @Test
  @DisplayName("유저 추가 API 성공 테스트")
  public void testAddShouldReturn200Request() throws Exception {

    UserRegisterDTO userRegisterDTO = new UserRegisterDTO();

    userRegisterDTO.setUsername("test");
    userRegisterDTO.setEmail("test1234@naver.com");
    userRegisterDTO.setPassword("test1234");

    String requestBody = objectMapper.writeValueAsString(userRegisterDTO);

    Mockito.when(userService.register(any(UserRegisterDTO.class)))
        .thenReturn(ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "add user", UserDTO.setUserDTO(User.setRegisterUser(userRegisterDTO)))));

    mockMvc.perform(
            post(API_URL)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody)
        )
        .andExpect(status().is2xxSuccessful())
        .andExpect(jsonPath("$.data.email").value(userRegisterDTO.getEmail()))
        .andExpect(jsonPath("$.data.username").value(userRegisterDTO.getUsername()))
        .andDo(print());
  }

  @Test
  @DisplayName("유저 조회 API 실패 테스트")
  void getUserTestShouldReturn404NotFound() throws Exception {
    Long userId = 1L;
    String requestUrl = API_URL + "/" + userId;

    when(userService.get(userId)).thenThrow(new NotFoundException("존재하지 않는 유저입니다."));

    mockMvc.perform(
            get(requestUrl)
                .with(csrf())
        )
        .andExpect(status().isNotFound())
        .andDo(print());
  }

  @Test
  @DisplayName("유저 조회 API 성공 테스트")
  void getUserTestShouldReturn200Request() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + "/" + userId;
    String email = "test@naver.com";

    User user = User.setUser(userId, "test", email, "test1234");
    ResponseEntity<ApiResponse<UserDTO>> responseEntity = ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "get user", UserDTO.setUserDTO(user)));

    when(userService.get(userId)).thenReturn(responseEntity);

    mockMvc.perform(
            get(requestURI)
                .with(csrf())
        )
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.data.email").value(email))
        .andDo(print());
  }

  @Test
  @DisplayName("사용자 리스트 조회 API 성공 테스트")
  void getUserListShouldReturn200Request() throws Exception {
    String user1Email = "test1@naver.com";
    String user2Email = "test2@naver.com";
    User user1 = User.setUser(1L, "test1", user1Email, "test1234");
    User user2 = User.setUser(2L, "test2", user2Email, "test1234");

    List<User> userList = List.of(user1, user2);
    ResponseEntity<ApiResponse<List<UserDTO>>> responseEntity = ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "userList find", userList.stream()
        .map(UserDTO::setUserDTO)
        .collect(Collectors.toList())));

    when(userService.getList()).thenReturn(responseEntity);

    mockMvc.perform(
            get(API_URL)
                .with(csrf())
        )
        .andExpect(status().isOk())
        .andExpect(content().contentType("application/json"))
        .andExpect(jsonPath("$.data[0].email").value(user1Email))
        .andExpect(jsonPath("$.data[1].email").value(user2Email))
        .andDo(print());
  }

  @Test
  @DisplayName("사용자 업데이트 API 실패 NotFound 테스트")
  void updateUserShouldReturn404NotFound() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + "/" + userId;

    UserUpdateDTO userUpdateDTO = new UserUpdateDTO();
    userUpdateDTO.setEmail("test1@naver.com");
    userUpdateDTO.setPassword("test1234");

    String requestBody = objectMapper.writeValueAsString(userUpdateDTO);

    when(userService.update(eq(userId), any(UserUpdateDTO.class)))
        .thenThrow(new NotFoundException("해당하는 유저가 없습니다."));

    mockMvc.perform(
            put(requestURI)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
        .andExpect(status().isNotFound())
        .andDo(print());
  }

  @Test
  @DisplayName("사용자 업데이트 API 실패 BadRequest 테스트")
  void updateUserShouldReturn400BadRequest() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + "/" + userId;

    UserUpdateDTO userUpdateDTO = new UserUpdateDTO();

    String requestBody = objectMapper.writeValueAsString(userUpdateDTO);

    mockMvc.perform(
            put(requestURI)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
        .andExpect(status().isBadRequest())
        .andDo(print());
  }

  @Test
  @DisplayName("사용자 업데이트 API 성공 테스트")
  void updateUserShouldReturn200Ok() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + "/" + userId;

    User user = User.setUser(userId, "test", "test@naver.com", "test1234");

    UserUpdateDTO userUpdateDTO = new UserUpdateDTO();
    userUpdateDTO.setEmail("test@naver.com");
    userUpdateDTO.setPassword("test1234");

    Mockito.when(userService.update(eq(userId), any(UserUpdateDTO.class)))
        .thenReturn(ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "user update", UserDTO.setUserDTO(user))));

    String requestBody = objectMapper.writeValueAsString(userUpdateDTO);

    mockMvc.perform(
            put(requestURI)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.data.email").value(user.getEmail()))
        .andDo(print());
  }

  @Test
  @DisplayName("사용자 업데이트 API 실패 NotFound 테스트")
  void deleteUserShouldReturn404NotFound() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + "/" + userId;

    when(userService.delete(userId))
        .thenThrow(new NotFoundException("해당하는 유저가 없습니다."));

    mockMvc.perform(
            delete(requestURI)
                .with(csrf())
        )
        .andExpect(status().isNotFound())
        .andDo(print());
  }

  @Test
  @DisplayName("사용자 삭제 API 성공 테스트")
  void deleteUserShouldReturn200Ok() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + "/" + userId;

    Mockito.when(userService.delete(userId))
        .thenReturn(ResponseEntity.ok().body(ApiResponse.setApiResponse(true, "delete user", null)));

    mockMvc.perform(
            delete(requestURI)
                .with(csrf())
        )
        .andExpect(status().isOk())
        .andDo(print());
  }
}

5. 유닛테스트 문제 사항 / 의문

1. MockMvc 인자 매칭 오류 해결하기

2. @WebMvcTest 작성 시 Spring Security 유의사항

3. @Transactional 내부에서의 Exception

4. REST API 의 모범적인 오류처리

profile
ppppqqqq

0개의 댓글