관리 메뉴

bright jazz music

blog04: 작성글 저장2 - ObjectMapper(jackson)사용, 클래스 분리 본문

Projects/blog

blog04: 작성글 저장2 - ObjectMapper(jackson)사용, 클래스 분리

bright jazz music 2022. 12. 28. 10:55

1. PostsController와 연관된 코드 개선(test클래스, dto 등)

 

ObjectMapper 사용

테스트 케이스의 코드 수정 (jackson을 사용하여 가독성 up)

뿐만 아니라 필드가 늘어남에 따라 값을 수정해야 할 때 실수 발생확률을 낮춘다.

* PostCreate에 생성자, 게터가 있어야 한다.

 

 

//PostController.java

package com.endofma.blog.controller;

import com.endofma.blog.request.PostCreate;
import com.endofma.blog.service.PostService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;


@Slf4j
@RestController
@RequiredArgsConstructor
public class PostController {
    private final PostService postService;

    // 생성자를 통한 주입. 여기서는 롬복 @RequiredArgsConstructor를 사용한다.
    //    public PostController(PostService postService){
    //        this.postService = postService;
    //    }

    @PostMapping("/posts")
//    public Map<String, String> post(@RequestBody @Valid PostCreate request) {
    public void post(@RequestBody @Valid PostCreate request) {
        //Case1. 저장한 데이터 Entity 반환요구. service->Controller-> return Post
        //Case2. 저장한 데이터의 primary_id 반환요구 -> return getId()
        //      클라이언트에서는 수신한 Id를 기반으로 글 조회 API를 통해 데이터 수신
        //Case3. 응답 필요 없음. -> 클라이언트에서 모든 POST 데이터 context를 잘 관리함.
        //Bad case: 서버측에서 정책을 고정하는 경우. 거의 불가능. 차라리 유연하게 대처 필요
        postService.write(request);
//        return Map.of();
    }

}

 

 

 

ObjectMapper를 사용해서 기존의 코드들을 변경하였다.

//PostControllerTest.java
//...

//@WebMvcTest //간단한 웹테스트만 가능. 우린 스프링 전반에 걸쳐 여러 가지를 만들었기 때문에 @SpringBootTest가 필요
@AutoConfigureMockMvc //@WebMvcTest가 없어지면 기존 테스트가 안되므로 @WebMvcTest를 구성하는 애노테이션을 떼내어 붙였다.
@SpringBootTest
class PostControllerTest {



    @Autowired
    private MockMvc mockMvc; ////Could not autowire. No beans of 'MockMvc' type found.
    
    @Autowired
    private PostRepository postRepository; //DB저장 테스트를 위해 주입

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

    @Test
    @DisplayName("/posts 요청시 hello world를 출력한다")
    void test() throws Exception {
        //given
//        PostCreate request = new PostCreate("제목입니다.", "내용입니다.");
        PostCreate request = PostCreate.builder()
                .title("제목입니다.")
                .content("내용입니다.")
                .build();

        //jackson을 사용하여 객체를 Json 형태로 바꿔준다.(json을 처리해 줌)
        ObjectMapper objectMapper = new ObjectMapper();
        String json = objectMapper.writeValueAsString(request);
//        @Autowired private ObjectMapper objectMapper; 이렇게 주입해서도 쓸 수 있다.

        System.out.println(json);

        //expected
        //기본적으로 Content-Type을 application/json으로 보냄. 예전에는 application/x-www-form-urlencoded를 썼다.
        mockMvc.perform(post("/posts")
                        .contentType(MediaType.APPLICATION_JSON) //기본값이라 주석처리
//                        .content("{\"title\": \"제목입니다.\", \"content\": \"내용입니다.\"}") //json 형태로 값 넣어주기
                        //.content에는 byte나 String만 들어가진다. 따라서 jackson으로 Json변환처리를 하지 않은 request를 넣을 수는 없다.

                        .content(json) //jackson을 사용하여 json이 된 객체. 클래스에 게터가 존재해야 한다.
                )
                .andExpect(status().isOk()) //http response 가 200인지
                .andExpect(content().string("{}")) // 내용이 hello world인지
                .andDo(print()); //요청에 대한 전반적인 요약을 출력해준다.

    }


    @Test
    @DisplayName("/posts 요청시 title 값은 필수다")
    void test2() throws Exception {

        //given
        PostCreate request = PostCreate.builder()
                //.title("제목입니다.")
                .content("내용입니다.")
                .build();

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

        //expected
        mockMvc.perform(post("/posts")
                        .contentType(MediaType.APPLICATION_JSON)
                                .content(json)

                )
                .andExpect(status().isBadRequest()) //.OK()
                .andExpect(jsonPath("$.code").value("400")) //json 검증
                .andExpect(jsonPath("$.message").value("잘못된 요청입니다!"))
//                .andExpect(jsonPath("$.validation.title").value("타이틀을 입력하세요!"))
                .andDo(print());

    }



    @Test
    @DisplayName("/posts 요청시 DB에 값이 저장된다.")
    void test3() throws Exception {
        //given
        PostCreate request = PostCreate.builder()
                .title("제목입니다.")
                .content("내용입니다.")
                .build();

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

        //when
        mockMvc.perform(post("/posts")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(json)
                )
                .andExpect(status().isOk())
                .andDo(print());

        //then
        assertEquals(1L, postRepository.count()); //하나의 값이 있을 거라고 예상. 일치

        //DB에 잘 들어갔는지 확인
        Post post = postRepository.findAll().get(0); //가장 처음 데이터 가져옴
        assertEquals("제목입니다.", post.getTitle());
        assertEquals("내용입니다.", post.getContent());
    }
}

 

생성자를 만들어 준 뒤 @Builder를 붙였다.

//PostCreate.java

package com.endofma.blog.request;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.validation.constraints.NotBlank;

@Setter
@Getter
@ToString
public class PostCreate {
    @NotBlank(message = "타이틀을 입력하세요!")
    //@Valid를 @RequestBody 옆에 @valid를 붙임. 그러면 바인딩 되면서 빈 값이 넘어오면 에러발생시킴.
    private String title;

    @NotBlank(message = "내용을 입력하세요!")
    private String content;

    @Builder //클래스에 달면 다른 생성자와 모순이 발생할 수 있기 때문에 생성자에 다는 것을 추천. builder + NoArgsConstructor 에러
    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }
    //빌더의 장점
    // - 가독성 up (값 생성에 대한 유연함)
    // - 필요한 값만 받을 수 있다. // -> (오버로딩 가능한 조건 찾아보기)
    // - 객체의 불변성
}

테스트 결과

 

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

...

2022-12-28 10:51:46.073  INFO 37236 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 1 ms
2022-12-28 10:51:46.090  INFO 37236 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Started PostControllerTest in 3.558 seconds (JVM running for 5.846)
{"title":"제목입니다.","content":"내용입니다."} //sysout으로 출력된 request 내용

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /posts
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"57"]
             Body = {"title":"제목입니다.","content":"내용입니다."}
    Session Attrs = {}

... 성공...

클래스 전체 테스트를 실행해도 pass된다.

 

2. ExceptionController와 연관된 코드 개선

 

//ExceptionController.java

package com.endofma.blog.controller;

import com.endofma.blog.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@ControllerAdvice
public class ExceptionController {

    @ResponseStatus(HttpStatus.BAD_REQUEST) //400
    @ResponseBody //응답을 json으로 보내기 위해 추가
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ErrorResponse invalidRequestHandler(MethodArgumentNotValidException e) {

//        ErrorResponse response = new ErrorResponse("400", "잘못된 요청입니다!");
        ErrorResponse response = ErrorResponse.builder()
                .code("400")
                .message("잘못된 요청입니다!")
                .build();

        for(FieldError fieldError : e.getFieldErrors()){
            response.addValidation(fieldError.getField(), fieldError.getDefaultMessage());

        }

        return response;
    }
}
//ErrorResponse.java
package com.endofma.blog.response;

import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.HashMap;
import java.util.Map;

/*
* {
*   "code": "400",
*   "message": "잘못된 요청입니다."
*   "validation": {
*       "title": "값을 입력해주세요"
*   }
* }
*/
@Getter    //게터가 없으면 값을 가져올 수 없어서 ResponseBody에 넣어주질 못함.(빈 바디)
//@RequiredArgsConstructor
public class ErrorResponse {
    private final String code;
    private final String message;
    private final Map<String, String> vaidation = new HashMap<>(); //final로 선언하면 생성자 파라미터가 3개가 됨. 맵 대신 클래스로 받아보자...

    @Builder
    public ErrorResponse(String code, String message){
        this.code = code;
        this.message = message;
    }
    public void addValidation(String fieldName, String errorMessage){
//        ValidationTuple validationTuple = new ValidationTuple(fieldName, errorMessage);
        this.vaidation.put(fieldName, errorMessage);
    }

//    Map을 안 쓰기 위해서 내부 클래스를 만들었으니 이걸로 위의 Map을 대체해 보자..현재는 안 되어 있음
//    @RequiredArgsConstructor
//    private class ValidationTuple{
//        private final String fieldName;
//        private final String errorMessage;
//}

}
//Post.java : 엔티티

package com.endofma.blog.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PUBLIC) //엔티티는 기본 생성자가 필요하다.
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @Lob //DB에서 큰 크기의 파일을 저장할 때 사용하는 타입(Large Object). 자바에서 긴 스트링 값으로 넘어가는 값을 DB에서 저장할 수 있도록 사용.
    private String content;

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

}

 

//PostControllerTest.java

package com.endofma.blog.controller;

import com.endofma.blog.domain.Post;
import com.endofma.blog.repository.PostRepository;
import com.endofma.blog.request.PostCreate;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; //이게 맞음
//import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; 원래 이걸로 했음.


//@WebMvcTest //간단한 웹테스트만 가능. 우린 스프링 전반에 걸쳐 여러 가지를 만들었기 때문에 @SpringBootTest가 필요
@AutoConfigureMockMvc //@WebMvcTest가 없어지면 기존 테스트가 안되므로 @WebMvcTest를 구성하는 애노테이션을 떼내어 붙였다.
@SpringBootTest
class PostControllerTest {



    @Autowired
    private MockMvc mockMvc; ////Could not autowire. No beans of 'MockMvc' type found.
    
    @Autowired
    private PostRepository postRepository; //DB저장 테스트를 위해 주입

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

    @Test
    @DisplayName("/posts 요청시 hello world를 출력한다")
    void test() throws Exception {
        //given
//        PostCreate request = new PostCreate("제목입니다.", "내용입니다.");
        PostCreate request = PostCreate.builder()
                .title("제목입니다.")
                .content("내용입니다.")
                .build();

        //jackson을 사용하여 객체를 Json 형태로 바꿔준다.(json을 처리해 줌)
        ObjectMapper objectMapper = new ObjectMapper();
        String json = objectMapper.writeValueAsString(request);
//        @Autowired private ObjectMapper objectMapper; 이렇게 주입해서도 쓸 수 있다.

        System.out.println(json);

        //expected
        //기본적으로 Content-Type을 application/json으로 보냄. 예전에는 application/x-www-form-urlencoded를 썼다.
        mockMvc.perform(post("/posts")
                        .contentType(MediaType.APPLICATION_JSON) //기본값이라 주석처리
//                        .content("{\"title\": \"제목입니다.\", \"content\": \"내용입니다.\"}") //json 형태로 값 넣어주기
                        //.content에는 byte나 String만 들어가진다. 따라서 jackson으로 Json변환처리를 하지 않은 request를 넣을 수는 없다.

                        .content(json) //jackson을 사용하여 json이 된 객체. 클래스에 게터가 존재해야 한다.
                )
                .andExpect(status().isOk()) //http response 가 200인지
                .andExpect(content().string("{}")) // 내용이 hello world인지
                .andDo(print()); //요청에 대한 전반적인 요약을 출력해준다.

    }


    @Test
    @DisplayName("/posts 요청시 title 값은 필수다")
    void test2() throws Exception {

        //given
        PostCreate request = PostCreate.builder()
                //.title("제목입니다.")
                .content("내용입니다.")
                .build();

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

        //expected
        mockMvc.perform(post("/posts")
                        .contentType(MediaType.APPLICATION_JSON)
                                .content(json)

                )
                .andExpect(status().isBadRequest()) //.OK()
                .andExpect(jsonPath("$.code").value("400")) //json 검증
                .andExpect(jsonPath("$.message").value("잘못된 요청입니다!"))
//                .andExpect(jsonPath("$.validation.title").value("타이틀을 입력하세요!"))
                .andDo(print());

    }



    @Test
    @DisplayName("/posts 요청시 DB에 값이 저장된다.")
    void test3() throws Exception {
        //given
        PostCreate request = PostCreate.builder()
                .title("제목입니다.")
                .content("내용입니다.")
                .build();

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

        //when
        mockMvc.perform(post("/posts")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(json)
                )
                .andExpect(status().isOk())
                .andDo(print());

        //then
        assertEquals(1L, postRepository.count()); //하나의 값이 있을 거라고 예상. 일치

        //DB에 잘 들어갔는지 확인
        Post post = postRepository.findAll().get(0); //가장 처음 데이터 가져옴
        assertEquals("제목입니다.", post.getTitle());
        assertEquals("내용입니다.", post.getContent());
    }
}
//PostService.java

package com.endofma.blog.service;

import com.endofma.blog.domain.Post;
import com.endofma.blog.repository.PostRepository;
import com.endofma.blog.request.PostCreate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class PostService {
//    @Autowired // 필드 인젝션을 사용하는 것은 바람직하지 않다. 따라서 생성자를 사용해서 생성해준다.
//    private PostRepository postRepository;

    private final PostRepository postRepository;

//    /생성자를 사용한 필드 인젝션을 사용하면 생성자가 새로 생긴다. 그러나 롬복의 @RequiredArgsConstructor를 사용할 수도 있다.
//    public PostService(PostRepository postRepository) {
//        this.postRepository = postRepository;
//    }


    public void write(PostCreate postCreate) {
//        postRepository.save(postCreate); 이렇게는 들어가지 않는다. postCreate는 dto 형태이지 엔티티가 아니기 때문이다.
//        따라서 일반 클래스인 postCreate를 엔티티 형태로 변환해 줘야 한다

//        Post post = new Post(postCreate.getTitle(), postCreate.getContent());
        Post post = Post.builder()
                .title(postCreate.getTitle())
                .content(postCreate.getContent())
                .build();
//        post.title = postCreate.getTitle(); Post의 접근 제한자를 public으로 바꿔서 이렇게 하는 것은 좋지 않다. 값이 변경될 수 있다.
//        따라서 필드의 접근 제한자를 private으로 바꾼 뒤 생성자를 통해 생성하도록 만드는 것이 좋다.

        postRepository.save(post);
    }
}

 

3. PostService 테스트

 

PostService와 관련 코드가 잘 작동하는지 테스트한다.

ctrl+shift+ t 누르면 테스트케이스가 생성된다.

 

//PostServiceTest.java

package com.endofma.blog.service;

import com.endofma.blog.domain.Post;
import com.endofma.blog.repository.PostRepository;
import com.endofma.blog.request.PostCreate;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class PostServiceTest {

    @Autowired
    private PostService postService;

    @Autowired
    private PostRepository postRepository;

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

        //when
        postService.write(postCreate);

        //then
        Assertions.assertEquals(1L, postRepository.count());
        Post post = postRepository.findAll().get(0);
        assertEquals("제목입니다.", post.getTitle());
        assertEquals("내용입니다.", post.getContent());

    }

}

 

테스트 결과

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

2022-12-29 08:19:04.690  INFO 37656 --- [    Test worker] c.endofma.blog.service.PostServiceTest   : Starting PostServiceTest using Java 11.0.12 on DESKTOP-8H1PTVG with PID 37656 (started by markany-hjcha in D:\personal\blog)
2022-12-29 08:19:04.691  INFO 37656 --- [    Test worker] c.endofma.blog.service.PostServiceTest   : No active profile set, falling back to 1 default profile: "default"
2022-12-29 08:19:05.252  INFO 37656 --- [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2022-12-29 08:19:05.298  INFO 37656 --- [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 37 ms. Found 1 JPA repository interfaces.
2022-12-29 08:19:05.706  INFO 37656 --- [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2022-12-29 08:19:05.903  INFO 37656 --- [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2022-12-29 08:19:05.965  INFO 37656 --- [    Test worker] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]
2022-12-29 08:19:06.022  INFO 37656 --- [    Test worker] org.hibernate.Version                    : HHH000412: Hibernate ORM core version 5.6.14.Final
2022-12-29 08:19:06.205  INFO 37656 --- [    Test worker] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {5.1.2.Final}
2022-12-29 08:19:06.347  INFO 37656 --- [    Test worker] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
2022-12-29 08:19:06.874  INFO 37656 --- [    Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2022-12-29 08:19:06.881  INFO 37656 --- [    Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2022-12-29 08:19:07.330  WARN 37656 --- [    Test worker] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2022-12-29 08:19:07.864  INFO 37656 --- [    Test worker] c.endofma.blog.service.PostServiceTest   : Started PostServiceTest in 3.469 seconds (JVM running for 5.714)
2022-12-29 08:19:08.201  INFO 37656 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2022-12-29 08:19:08.202  INFO 37656 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
2022-12-29 08:19:08.208  INFO 37656 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2022-12-29 08:19:08.214  INFO 37656 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
BUILD SUCCESSFUL in 7s
4 actionable tasks: 2 executed, 2 up-to-date
AM 8:19:08: Execution finished ':test --tests "com.endofma.blog.service.PostServiceTest"'.

//테스트 성공
Comments