리팩토링

처음부터 완벽할 순 없다. 앞으로도 코드를 작성하고 리팩토링하는 과정을 꾸준히 해야할 것이다.

지금까지 해온 코드들을 돌아보면 구현을 위주로 하여 지저분한 코드가 많이 보인다.

우선 테스트에 content 입력 시 기존처럼 문자열로 집어넣으면 지저분하고 실수할 여지가 많다.

PostCreate 에 Builder 패턴으로 생성자를 만들고 Jackson 라이브러리의 ObjectMapper 사용해서 리팩토링 해보자.

 

기존 테스트

PostControllerTest

@DisplayName("/posts 요청 시 저장한 Post 를 출력한다.")
@Test
void post1() throws Exception {
    mockMvc.perform(
            post("/posts/request")
                .contentType(APPLICATION_JSON)
                .content("{\"title\" : \"글 제목입니다\", \"content\" : \"글 내용입니다 하하\"}")
        )
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.title").value("글 제목입니다"))
        .andExpect(jsonPath("$.content").value("글 내용입니다 하하"))
        .andDo(print());
}

 

리팩토링 후

PostCreate

@Getter
@ToString
@NoArgsConstructor
public class PostCreate {

    @NotBlank(message = "제목을 입력해주세요.")
    private String title;

    @NotBlank(message = "내용을 입력해주세요.")
    private String content;

    @Builder
    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 

ㄴ @Builder

Lombok 이 제공하는 빌더 애노테이션이다. 빌더패턴과 관련된 내용은 여기에 정리해두었다.

빌더 패턴 없이 title, content 를 매개변수로 받는 생성자 사용 시 이 둘의 타입이 같으므로

잠재적인 문제가 발생할 수 있으므로 빌더 패턴을 사용하기로 했다.

 

PostControllerTest

@Autowired
private ObjectMapper objectMapper;

@DisplayName("/posts 요청 시 저장한 Post 를 출력한다.")
@Test
void post1() throws Exception {
    PostCreate request = PostCreate.builder()
            .title("글 제목입니다")
            .content("글 내용입니다 하하")
            .build();

    ObjectMapper objectMapper = new ObjectMapper();
    String json = objectMapper.writeValueAsString(request);

    mockMvc.perform(
            post("/posts/request")
                .contentType(APPLICATION_JSON)
                .content(json)
        )
        .andExpect(status().isOk())
        .andExpect(content().string("{}"))
        .andDo(print());
}

 

ㄴ ObjectMapper

Jackson 에서 제공해주는 객체를 직렬화 및 역직렬화 해주는 라이브러리이다.

ObjectMapper 는 스프링부트에서 빈을 제공해주므로 의존성 주입을 받아 이용한다.

ObjectMapper 를 이용해서 Post 객체를 직렬화 하여 json 형식으로 변경한 문자열을 입력해

더 깔끔한 테스트코드로 리팩토링하였다.

 

 

마찬가지로 ErrorResponse, PostControllerAdvice, Post 등 다른 클래스들도

Builder 패턴을 사용해 리팩토링을 진행하였고 앞으로는 이 Builder 패턴을 기본으로 이용할 것이다.

배움에는 끝이 없다

요즘 개발자 디코방에서 정말 많은걸 배우고 있다. 

질문방에 올라온 글만 봐도 많은걸 배울 수 있고 이번에 참가한 코드리뷰 모임이나 뜬금포로 진행하는 가벼운 스터디, 토론 구경하면서 키워드만 챙겨도 많은걸 배운다.

연차가 꽤 있는 분도 있어서 DM 으로 물어보면 답변도 잘 해주셔서 최근엔 정말 배움의 연속이다.

하루하루 공부하는 시간이 모자라다니 ㅋㅋ 미래에 내가 이 글을 볼 시점에도 이 열정이 식지않기를...

 

글쓰기 너무 어렵다

스프링 복습할겸 호돌맨 강의 따라 게시판 만들면서 배운거 복습하는데 지식을 글로 옮기는건 정말 어렵구나.

최대한 깔끔하고 간결하게 설명하고 싶은데 어디부터 어디까지 설명해야할지 범위 두는것도 어렵고

제목 짓기부터 어느 카테고리로 저장할지, 어떻게 어느정도 길이로 표현할지 등 생각할게 너무 많다.

일단 게시판 만들기 과정 정리하면서 최대한 글쓰기 스타일을 정리해보자.

 

공부할 시간이 모자라

진짜 학창시절 이후로 코딩 공부하기 전까진 이런 고민을 다시 할 줄은 몰랐다.

스프링 공부도 해야하고, 코테도 꾸준히 해야하고, CS 공부도 해야하고 공부할게 정말 너무 많다.

시간도 많이 들어서 같이 공부하기엔 시간이 너무 모자라다.아직은 너무도 까마득하지만 그래도 꾸준히 하면 언젠가는 빛을 볼 수 있으리라 믿는다.

 

CS 공부도 슬슬 해야지

최근 코테와 스프링 공부를 하면서 느낀게 아마 코테, 스프링, CS 중 시간대비 가장 큰 진척을 볼 수 있는게 CS 공부일것 같다.

물론 해보기 전의 오만한 생각일 수도 있는데 면접 질문 정도의 CS 수준에서는 맞는말이 아닐까?

결국 취업하려면 CS 공부해서 면접 질문에 답변도 잘해야지. 일단 내일부터라도 당장 조금씩 해봐야겠다.

게시글 저장 구현

이제부터 게시판의 가장 기본적인 기능인 CRUD 의 시작이다. 우선 게시글 저장부터 구현해보자. 

JpaRepository 인터페이스를 사용하여 자동 주입되는 JpaRepository 구현체의 단건 저장 기능을 사용할것이다.

컨트롤러에서 바로 Repository 를 호출해도 되지만 PostService 를 만들어서 서비스 계층에서 호출하는게 바람직하다.

가장 간단한 방법으로 게시글 저장을 구현하고 테스트까지 해보자.

 

컨트롤러에서 클라이언트로 어떻게 응답을 내려줄것인가?

[클라이언트에서 요구하는 응답 케이스]

  1.  저장한 데이터 Entity 를 response 로 반환한다.
  2.  저장한 데이터의 primary_id 를 response 로 반환한다.
     -> Client 에서는 수신한 id 로 글 조회 API 를 통해서 데이터를 수신받음
  3.  응답 필요 없다.
     -> 클라이언트에서 모든 글 데이터 context 를 잘 관리함

이처럼 클라이언트 개발자마다 실력도 스타일도 다 다르기 때문에 요구하는 응답값이 다 다르다.

따라서 서버에서 반드시 이렇게 할겁니다 !! 라고 하는건 안좋다. 서버에서 유연하게 대응하는게 좋다.
한 번에 일괄적으로 잘 처리되는 케이스는 없다. 결국 잘 관리되는 형태가 중요하기 때문에 코드를 잘 짜야한다.

 

PostController

요청 데이터를 PostCreate 객체로 받아 서비스로 넘겨주고 등록한 Post 정보를 반환한다.

엔티티 자체를 응답으로 보내주는건 좋지 않지만 우선은 간략하게 구현하겠다.

@RestController
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping("/posts")
    public Post post(@RequestBody @Valid PostCreate request) {
        return postService.write(request);
    }
}

 

PostCreate

@Getter
@ToString
@NoArgsConstructor
public class PostCreate {

    @NotBlank(message = "제목을 입력해주세요.")
    private String title;

    @NotBlank(message = "내용을 입력해주세요.")
    private String content;

    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

ㄴ @NoArgsConstructor

기본 생성자를 만들어주는 Lombok 애노테이션이다.

Build설정이 Gradle 로 되어있으면 기본 생성자 없이도 데이터를 객체에 매핑시켜주는 역직렬화가 잘 되지만

Build설정이 IntelliJ 로 되어 있으면 기본 생성자 없이는 역직렬화가 안된다.

 

PostService

컨트롤러에서 받은 요청데이터를 바탕으로 Post 객체를 만들어 Repository 로 넘겨준다.

그 후 컨트롤러에 저장한 Post 정보를 반환한다.

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    public Post write(PostCreate request) {
        Post post = new Post(request.getTitle(), request.getContent());
        return postRepository.save(post);
    }
}

 

Post

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

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

    private String title;

    @Lob
    private String content;

    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

ㄴ @NoArgsConstructor

기본 생성자를 만들어주는 Lombok 애노테이션이다.

JPA 에서 엔티티는 기본 생성자가 있어야 DB 와 매핑을 할 수 있기 때문에 필요하다.

다른 곳에서 남용되는것을 막기 위해 제한자 설정을 AccesLevel.PROTECTED 로 한다.

 

PostRepository

JpaRepository 인터페이스에 의존성 주입된 JpaRepository 구현체에 구현돼있는 save() 메서드를 사용하여

DB에 Post 를 저장한다.

public interface PostRepository extends JpaRepository<Post, Long> {

}

 


PostServiceTest

글 작성 시 DB에 Post 를 저장하고 제대로 저장됐는지 확인하는 테스트이다.

@SpringBootTest
class PostServiceTest {

    @Autowired
    private PostService postService;

    @Autowired
    private PostRepository postRepository;

    @BeforeEach
    void clean() {
        postRepository.deleteAll();
    }

    @DisplayName("글 작성")
    @Test
    void test() {
        // given
        PostCreate postCreate = PostCreate.builder()
            .title("글 제목입니다")
            .content("글 내용입니다 하하")
            .build();

        // when
        postService.write(postCreate);
        Post post = postRepository.findAll().get(0);

        // then
        assertEquals(1L, postRepository.count());
        assertEquals("글 제목입니다", post.getTitle());
        assertEquals("글 내용입니다 하하", post.getContent());
    }
}

 

테스트를 수행하면 테스트가 성공함을 확인할 수 있다.

 


PostControllerTest

처음 테스트는 컨트롤러에서 응답이 변경됐기 때문에 글 작성 후 제대로 응답을 내려주는지 확인하는 테스트이다.

2번째 테스트는 DB에 Post 를 저장하고 제대로 저장됐는지 확인하는 테스트이다.

@SpringBootTest
@AutoConfigureMockMvc
class PostControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private PostRepository postRepository;

    @BeforeEach
    void clean() {
        postRepository.deleteAll();
    }
    
    @DisplayName("/posts 요청 시 저장한 Post 를 출력한다.")
    @Test
    void post1() throws Exception {
        // when
        mockMvc.perform(
                MockMvcRequestBuilders.post("/posts")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"title\" : \"글 제목입니다\", \"content\" : \"글 내용입니다 하하\"}")
            )
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("글 제목입니다"))
            .andExpect(jsonPath("$.content").value("글 내용입니다 하하"))
            .andDo(print());
    }

    @DisplayName("/posts 요청 시 DB에 값이 저장된다.")
    @Test
    void post3() throws Exception {
        // when
        mockMvc.perform(
                MockMvcRequestBuilders.post("/posts")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"title\" : \"글 제목입니다\", \"content\" : \"글 내용입니다 하하\"}")
            )
            .andExpect(status().isOk())
            .andDo(print());
        Post post = postRepository.findAll().get(0);

        // then
        assertEquals(1L, postRepository.count());
        assertEquals("글 제목입니다", post.getTitle());
        assertEquals("글 내용입니다 하하", post.getContent());
    }
}

ㄴ @SpringBootTest

기존의 @WebMvcTest 는 프레젠테이션 계층과 관련된 빈들만 의존성 주입을 해준다.

테스트를 진행하기 위해서는 Repository 호출이 필요하기 때문에 해당 애노테이션을 사용하였다.

ㄴ @AutoConfigureMockMvc

@SpringBootTest 에는 MockMvc 에 의존성을 주입해주는 기능이 없다.

따라서 테스트를 진행하기 위해 MockMvc 의존성 주입이 가능한 해당 애노테이션을 사용하였다.

ㄴ @BeforeEach

모든 테스트 간의 초기 조건은 항상 동일하고 순서가 달라져도 결과는 항상 같아야하기 때문에

각각의 테스트가 시작하기 전에 Repository 의 데이터를 모두 삭제해야한다.

해당 애노테이션을 사용하면 각각의 테스트를 시작하기 전에 지정해둔 메서드를 실행할 수 있다.

 

 

테스트를 수행하면 테스트가 성공함을 확인할 수 있다.

BeanValidation

검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고 표준화 한 것이 바로 Bean Validation 이다.

스프링에서 BeanValidation 을 사용하려면 우선 gradle 에 의존성을 추가해야한다.

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

 

스프링 MVC는 어떻게 Bean Validator를 사용?

스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면

자동으로 Bean Validator를 인지하고 스프링에 통합한다.

 

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.

스프링 부트는 gradle 에 spring-boot-starter-validation 의존성을 추가하면 자동으로

BeanValidator 를 글로벌 Validator로 등록한다.

이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.

검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.

 

PostController

@PostMapping("/posts")
public String post(@RequestBody @Valid PostCreate params) throws Exception {
    log.info("params = {}", params.toString());
    return "Hello";
}

ㄴ @Valid

위처럼 컨트롤러에서 검증할 객체 앞에 애노테이션 @Valid 를 사용한다.

 

PostCreate

DTO 의 검증하고자 하는 필드에 BeanValidation 에서 제공하는 검증 애노테이션을 사용한다.

여기서는 빈값 / 공백(" ")만 있는 경우를 허용하지 않는 @NotBlank 를 사용한다.

@Setter
@Getter
@ToString
public class PostCreate {

    @NotBlank
    private String title;

    @NotBlank
    private String content;

    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

ㄴ 검증 애노테이션

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다.

 

이제 결과를 살펴보자.

 

 

스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 400 예외가 발생하기 때문에 컨트롤러가 호출되지도 않았다.

BeanValidation 을 사용하여 잘못된 값이 입력되는건 막았지만 어떤 부분이 잘못 됐는지 알 수가 없다.

에러 메시지를 확인할 수 있는 방법은 없을까?

 


BindingResult

BeanValidation 는 스프링이 제공하는 검증 오류를 보관하는 객체이다.

검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다

BindingResult 를 사용하면 요청 데이터를 PostCreate 객체에 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

그럼 이제 이 BindingResult 를 이용해서 컨트롤러 로직을 수정하고 JSON 을 검증하는 테스트코드도 개선해보자.

 

PostController

@PostMapping("/posts")
public Map<String, String> post(
			@RequestBody @Valid PostCreate params, 
			BindingResult result
			) throws Exception {
            
    if (result.hasErrors()) {
        List<FieldError> fieldErrors = result.getFieldErrors();
        FieldError fieldError = fieldErrors.get(0);
        String fieldName = fieldError.getField();
        String errorMessage = fieldError.getDefaultMessage();

        Map<String, String> error = new HashMap<>();
        error.put(fieldName, errorMessage);
        return error;
    }
    return Map.of();
}

 

PostCreate

@Setter
@Getter
@ToString
public class PostCreate {

    @NotBlank(message = "제목을 입력해주세요.")
    private String title;

    @NotBlank(message = "내용을 입력해주세요.")
    private String content;

    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

ㄴ @NotBlank(message = "")

괄호 안에 원하는 메시지를 입력하면 defaultMessage 로 설정 가능하다.

 

PostControllerTest

    @DisplayName("/posts 요청 시 title 값은 필수다.")
    @Test
    void post() throws Exception {
        mockMvc.perform(
                MockMvcRequestBuilders.post("/posts")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(
                    "{\"title\" : null, \"content\" : \"글 내용입니다 하하\"}"
                    )
            )
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("제목을 입력해주세요."))
            .andDo(print());
    }

ㄴ andExpect(jsonPath("$.field").value("value"));

MockMvc의 jsonpath() 를 이용하면 Json 형식의 API Response를 검증할 수 있다.

이번 테스트에서는 title 값이 비었으니 @NotBlank 에서 검증 오류가 발생하고

응답값 중 title 이라는 field 에 "제목을 입력해주세요." 라는 문자열이 들어올것이다.

 

 

테스트를 돌려보면 성공한다.

MockMvc 의 jsonPath 를 이용하면 JSON 을 검증하는 더 풍부한 테스트를 작성할 수 있다.

'백엔드 > Spring' 카테고리의 다른 글

Filter  (0) 2023.09.29
Interceptor  (0) 2023.09.29
@ModelAttribute 와 @RequestBody 써보기  (0) 2023.09.21
DispatcherServlet  (0) 2023.08.01
Thread Pool  (0) 2023.07.25

남은 문제점

  1.  매번 메서드마다 값을 검증해야한다.
    (BeanValidation 이전의 필드 단위로 추가할때보단 낫겠지만) 
    -> 개발자가 까먹을 수 있고 검증 부분에서 버그가 발생할 여지가 높다.
  2.  세 번 이상의 반복적인 작업은 피해야한다.
  3.  응답값에 HashMap 대신 따로 응답 클래스를 만들어주는게 좋다.
  4.  여러개의 에러처리가 힘들다. (filedErrors 에서 하나만 가지고 옴)

지금까지 만든 컨트롤러의 검증 방식은 아직 위와 같은 문제점이 남아있다. 

컨트롤러에 메서드가 추가될 때마다 검증 로직을 작성해야하고 이는 결국 반복 작업이 되며 잠재적 버그의 위험으로 남는다.

직전 게시글에서도 언급했듯 세 번 이상의 반복적인 작업은 피하는게 옳다.

이는 코드 뿐 아니라 개발에 관한 모든것에 해당하는 사항이고 따라서 자동화를 고려해야한다.

 


ControllerAdvice 와 ExceptionHandler

이러한 문제에 대해 꾸준히 고민하던 우리의 선배 개발자들은 좋은 방안을 마련해두었다.

스프링에서는 @ExceptionHandler와 @ControllerAdvice 라는 강력한 기능을 제공한다.

이 두 애노테이션은 컨트롤러에서 발생한 예외를 한 곳에서 처리해줘 우리의 고민을 해결해줄 것이다.

이를 이용해서 우리의 컨트롤러와 테스트코드를 수정해보자.

 

PostControllerAdvice

예외 발생 시 ErrorResponse 상태코드, 기본 에러 메시지, 예외가 발생한 필드명 에러 메시지들을 담아서 반환한다.

@RestControllerAdvice
public class PostControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ErrorResponse invalidRequestHandler(MethodArgumentNotValidException e) {

        ErrorResponse response = new ErrorResponse("400", "잘못된 요청입니다.");
        for (FieldError fieldError : e.getFieldErrors()) {
            response.addValidation(fieldError.getField(), fieldError.getDefaultMessage());
        }
        return response;
    }
}

 

ㄴ @RestControllerAdvice

@ControllerAdvice + @ResponseBody 의 역할을 한다.

 

ㄴ @ExceptionHandler

이 애노테이션으로 컨트롤러에서 발생 할 때 처리할 예외를 정한다.

 

ㄴ @ResponseStatus 

예외 처리 시 응답값으로 보내 줄 상태코드 기본 메시지를 설정한다.

 

ErrorReposnse

예외가 발생한 필드명과 에러 메시지를 담기 위한 객체이다.

@Getter
@RequiredArgsConstructor
public class ErrorResponse {

    private final String code;
    private final String message;

    private Map<String, String> validation = new HashMap<>();

    public void addValidation(String fieldName, String errorMessage) {
        this.validation.put(fieldName, errorMessage);
    }
}

 

PostController

@PostMapping("/posts")
public Map<String, String> post(@RequestBody @Valid PostCreate params) throws Exception {
	return Map.of();
}

 

PostControllerTest

title 에 null 을, content 에 공백을 포함한 빈문자열(" ")을 입력하면 응답 값으로 상태코드 400과

필드명("title", "content") 에러 메시지가 넘어오는지 확인하는 테스트이다.

@DisplayName("/posts 요청 시 title 값은 필수다.")
@Test
void post() throws Exception {
    mockMvc.perform(
            MockMvcRequestBuilders.post("/posts")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\" : null, \"content\" : \" \"}")
        )
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.code").value("400"))
        .andExpect(jsonPath("$.message").value("잘못된 요청입니다."))
        .andExpect(jsonPath("$.validation.title").value("제목을 입력해주세요."))
        .andExpect(jsonPath("$.validation.content").value("내용을 입력해주세요."))
        .andDo(print());
}

테스트 결과를 확인하면 예상했던대로 값들이 잘 넘어오는것을 알 수 있다.

요청받은 데이터를 검증하는 이유

  1.  Client 개발자가 깜빡하거나 실수로 값을 안보낼 수도 있다.
  2.  Client bug 로 값이 누락될 수 있다.
  3.  외부에 나쁜 사람이 값을 임의로 조작해서 보낼 수 있다.
  4.  DB 에 값을 저장할 때 의도치 않은 오류가 발생할 수 있다.

위처럼 여러가지 이유로 서버 개발자의 불안한 마음을 편하게 하기 위해서 데이터 검증을 해야한다.

검증 없이는 아래와 같이 title 에 빈 문자열( "" ) 을 넣어도 테스트는 통과한다.

 

PostControllerTest

    @DisplayName("/posts 요청 시 title 값은 필수다.")
    @Test
    void post() throws Exception {
        mockMvc.perform(
                MockMvcRequestBuilders.post("/posts")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"title\" : \"\", \"content\" : \"글 내용입니다 하하\"}")
            )
            .andExpect(status().isOk())
            .andExpect(content().string("Hello"))
            .andDo(print());

    }

 

하지만 이는 제목에 빈값을 허용하지 않는 개발자의 의도와 다르기 때문에 우리는 데이터 검증을 해야한다.

 


검증은 어떻게 할까?

컨트롤러에 params 객체를 통해 전달된 title 값을 검증하는 로직을 작성해보자. 얼추 아래처럼 될 것이다. 

위와 동일한 테스트를 시행해보자.

 

PostController

@PostMapping("/posts")
public String post(@RequestBody PostCreate params) throws Exception {
    log.info("params = {}", params.toString());
    String title = params.getTitle();
    if (title == null || title.equals("")) {
        throw new Exception("타이틀 값이 없어요!");
    }
    String content = params.getContent();
    if (content == null || title.equals("")) {
        // error
    }
    return "Hello";
}

 

예상대로 예외가 터진다. 하지만 이 방법은 많은 문제점을 가지고 있다.

 

  1.  우선 검증이 필요한 필드마다 노가다로 검증 코드를 작성해야해서 빡세다.
  2.  누락될 염려가 있다. (만약 필드가 100가지라면?)
  3.  생각보다 검증해야할 게 많다 (꼼꼼하지 않을 수 있다)
    ex) 만약 입력된 title 값이 공백이 포함된 빈 문자열(" ") 이라면? 혹은 수십억글자라면?

특히 3번째가 가장 중요한데, 필드가 늘어감에 따라 코드 줄 수가 늘어나면 모든 예외 사항을 확인하기는 매우 힘들다.

무언가 3번 이상 반복 작업을 할 때 내가 뭔가 잘못하고 있는건 아닐지 의심해봐야한다.

 


BeanValidation

검증 로직은 우리만 고민하는게 아닌 많은 개발자들이 꾸준히 고민해오던 문제이다.

이 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고 표준화 한 것이 바로 Bean Validation 이다.

BeanValidation 에 관련된 내용은 여기에 정리해두었다.

BeanValidation 을 적용하여 컨트롤러와 테스트를 작성해보자.

 

PostController

@Valid 와 BindingResult 를 적용하여 PostCreate 객체에 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

BindingResult 에 검증 오류값이 저장되면 해당 필드명에러 메시지를 map 에 넣어서 응답으로 반환한다.

@PostMapping("/posts")
public Map<String, String> post(
			@RequestBody @Valid PostCreate params, 
			BindingResult result
			) throws Exception {
            
    if (result.hasErrors()) {
        List<FieldError> fieldErrors = result.getFieldErrors();
        FieldError fieldError = fieldErrors.get(0);
        String fieldName = fieldError.getField();
        String errorMessage = fieldError.getDefaultMessage();

        Map<String, String> error = new HashMap<>();
        error.put(fieldName, errorMessage);
        return error;
    }
    return Map.of();
}

 

PostCreate

DTO 의 검증하고자 하는 필드에 BeanValidation 에서 제공하는 검증 애노테이션을 사용한다.

여기서는 빈값 / 공백(" ")만 있는 경우를 허용하지 않는 @NotBlank 를 사용하고 defaultMessage 를 설정했다.

@Setter
@Getter
@ToString
public class PostCreate {

    @NotBlank(message = "제목을 입력해주세요.")
    private String title;

    @NotBlank(message = "내용을 입력해주세요.")
    private String content;

    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 


PostControllerTest

title 값에 null 을 입력하면 응답 값으로 필드명("title")에러 메시지가 넘어오는지 확인하는 테스트이다.

    @DisplayName("/posts 요청 시 title 값은 필수다.")
    @Test
    void post() throws Exception {
        mockMvc.perform(
                MockMvcRequestBuilders.post("/posts")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(
                    "{\"title\" : null, \"content\" : \"글 내용입니다 하하\"}"
                    )
            )
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("제목을 입력해주세요."))
            .andDo(print());
    }

 

테스트를 돌려보면 성공한다.

MockMvc 의 jsonPath 를 이용하면 JSON 을 검증하는 더 풍부한 테스트를 작성할 수 있다.

jsonPath 로 검증하는법은 더 공부해봐야겠다.

둘의 차이점

@ModelAttribute 와 @RequestBody 는 각각 다루는 HTTP 요청 데이터 차이가 있다.

 

@ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 파라미터, HTML Form)를 다룰 때 사용한다. 

@RequestBody 는 HTTP 메시지 바디의 데이터를 객체로 변환하며 주로 API JSON 요청을 다룰 때 사용한다.

 

둘은 DTO 객체를 통해 데이터를 받을 때도 차이가 있는데 요약하자면 다음과 같다.

  • URL 쿼리파라미터 혹은 x-www-form-urlencoded@ModelAttribute 로 받는다.
  • JSON 은 @RequestBody 로 받는다.

 

x-www-form-urlencoded와 JSON 의 차이점은 여기에 정리해두었다.

 

1. @ModelAttribute

아래 같은 HTTP 요청 파라미터를 다룬다.

 

URL 쿼리 파라미터

http://localhost:8080/request?username=hello&age=20

 

HTML Form  

message body:
username=hello&age=20

 

PostController

데이터를 받는 DTO 객체 PostCreate 를 하나 만들고 요청 데이터가 잘 받아지는지 확인해볼것이다.

DTO 에 값이 잘 담겼는지는 로그를 통해 확인한다. POST 요청이 성공하면 "Hello" 문자열을 반환한다.

@Slf4j
@RestController
public class PostController {

    @PostMapping("/posts/model")
    public String postModelAttribute(@ModelAttribute PostCreate params) {
        log.info("params = {}", params.toString());
        return "Hello";
    }
}

 

참고로 스프링의 기본 설정은 @ModelAttribute 이기 때문에 아래처럼 생략해도 정상 동작한다.

@Slf4j
@RestController
public class PostController {

    @PostMapping("/posts/model")
    public String postModelAttribute(PostCreate params) {
        log.info("params = {}", params.toString());
        return "Hello";
    }
}

 

PostCreate

public class PostCreate {

    public String title;
    public String content;

    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }

    @Override
    public String toString() {
        return "PostCreate{" +
            "title='" + title + '\'' +
            ", content='" + content + '\'' +
            '}';
    }
}

 

PostControllerTest

("/posts") 로 POST 요청 시 상태코드 200 과 응답값으로 "Hello" 가 반환되는지 확인하는 테스트를 작성한다.

여기서는 HTML Form 에 쿼리 파라미터 형식으로 전달한다.

URL 에서 쿼리 스트링 형식으로 전달하는것도 @ModelAttribute 로 받을 수 있는데

해당 케이스는 이 글의 컨트롤러 테스트를 참고한다.

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest
class PostControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @DisplayName("/posts 요청 시 Hello 를 출력한다.")
    @Test
    void postModelAttribute() throws Exception {
        mockMvc.perform(
            post("/posts")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .param("title", "글 제목입니다")
                .param("content", "글 내용입니다 하하")
            )
            .andExpect(status().isOk())
            .andExpect(content().string("Hello"))
            .andDo(print());
    }
}

테스트는 성공하고 위 사진의 맨 윗줄의 params 에 값이 잘 담겼음을 확인할 수 있다.

 

2. @RequestBody

아래 같은 HTTP 메시지 바디 를 다룬다.

message body:
{
       "username": "hello",
       "age": 20
}

 

PostController

위와 같이 DTO 에 값이 잘 담겼는지는 로그를 통해 확인한다.

POST 요청이 성공하면 "Hello" 문자열을 반환한다. PostCreate 는 위의 ModelAttribute 케이스와 동일하다.

@Slf4j
@RestController
public class PostController {

    @PostMapping("/posts/request")
    public String postModelRequestBody(@RequestBody PostCreate params) {
        log.info("params = {}", params.toString());
        return "Hello";
    }
}

 

PostControllerTest

("/posts") 로 POST 요청 시 상태코드 200 과 응답값으로 "Hello" 가 반환되는지 확인하는 테스트를 작성한다.

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest
class PostControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @DisplayName("/posts 요청 시 Hello 를 출력한다. @requestBody")
    @Test
    void postModelRequestBody() throws Exception {
        mockMvc.perform(
            post("/posts")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"title\" : \"글 제목입니다\", \"content\" : \"글 내용입니다 하하\"}")
            )
            .andExpect(status().isOk())
            .andExpect(content().string("Hello"))
            .andDo(print());
    }
}

테스트는 성공하고 위 사진의 맨 윗줄의 params 에 값이 잘 담겼음을 확인할 수 있다.

'백엔드 > Spring' 카테고리의 다른 글

Interceptor  (0) 2023.09.29
BeanValidation 써보기  (0) 2023.09.22
DispatcherServlet  (0) 2023.08.01
Thread Pool  (0) 2023.07.25
Servlet  (0) 2023.07.25

x-www-form-urlencoded와 json

x-www-form-urlencoded 와 json 는 content-type 의 한 종류이다.

여기서 content-type 이란 HTTP 요청 데이터를 전송할 때 헤더에 담기는 형식이다. 

테스트코드나 postman 등을 사용하여 보낸 요청을 확인할 때 content-type 을 설정한다.

주로 사용하는 2가지인 x-www-form-urlencoded와 json의 차이를 간단하게 살펴보자.

 

1. application/x-www-form-urlencoded

key1=value1&key2=value2&key3=value3&....

 

위 처럼 key = value 형식을 '&' 으로 구분지어 데이터를 전달해주는 형식이다.

전에는 이걸 주로 사용했지만 전송하는 데이터가 복잡해지면서 도메인 데이터를 명확하게 표현하는데 한계가 있다.

따라서 요즘엔 JSON 을 많이 사용한다.

 

2. application/json

{
     "key1" : "value1",
     "key2" : "value2",
     "data" : {
                   "key3" : "value3"
                  }
}

 

위 처럼 데이터를 전달하는 형식이다.

x-www-form-urlencoded 형식에서는 data 같은 새로운 객체의 데이터를 따로 구분지어 표시해주기 힘들지만

JSON 형식에서는 온전히 표현해줄 수 있기 때문에 최근에는 JSON 형식을 주로 사용한다.

'네트워크' 카테고리의 다른 글

[네트워크] CORS  (0) 2023.12.02
[네트워크] JWT 란  (2) 2023.10.04
[네트워크] HTTP 요청 데이터  (0) 2023.07.25
웹 서버(WS)와 WAS 및 분리 이유  (0) 2023.04.03
[네트워크] REST API 란?  (0) 2023.03.27

+ Recent posts