블로그 만들기 두번째 시간이다. 오늘은 무엇을 할까 고민하다가 블로그니까 Post기준으로 점차 확대하는 방향으로 나가야겠다고 생각했다. 오늘은 JPA 이야기가 많은거 같아서 JPA라고 제목도 붙었다.

우리는 JPA라는 것을 사용할 것이다. JPA는 하나씩 하나씩 차근차근 보면 답이 없다. 왜냐하면 JPA또한 공부할게 많다. 아 JPA가 이런거구나 정도만 알면 성공한거다. 혹자들은 Spring만큼 공부할 양이 많다고 하니 책을 사서 공부하는 편이 낫다. 예제의 경우 아주 쉽게 나와 있지만 실무 프로젝트에선 그런 쉬운 로직이 많이 없다. 단지 예제일뿐이다. (물론 있긴 하지만) 그리고 또한 영속성 비영속성 준영속성 등등 기타 JPA 상태도 잘 알아야 한다.
아주 조금만 JPA에 대해서 살펴보자. JPA는 Java Persistent API의 약자로 영속성 관리와 ORM을 위한 표준 기술이다. 실제로 인터페이스만 있고 구현체들은 우리가 흔히 알고 있는것이 hibernete, 조금생소하지만 openJPA, EclipseLink등 도 있다. 재미 삼아 이야기 하지만 실제 JPA보다 hibernate가 먼저 나왔다. EJB를 쓰던시절에 어느 한 개발자가 EJB의 엔티티 빈이 거지같다며 집에서 만든게 hibernate이다. 만들고 나서 사람들이 hibernate로 몰리자 java진영에서 내놓은게 JPA이다. 물론 hibernate만든 개발자도 직접 참여했고 하이버네이트 기준으로 JPA를 만들었다고 해도 과언이 아니다. 실제 API문서도 거의 동일하다. 로드 존슨이 EJB가 싫어 뚝딱뚝딱 spring을 만들고 EJB가 싫어 뚝딱뚝딱 hibernate를 만들고… EJB를 써보지 않아서 왜 안좋은지는 모른다. 말만 들어서 그냥 안좋은가부다 하고 있다. 아무튼 서론이 너무 길다.

우리는 Spring data jpa를 사용할 예정이라 maven에 다음과 같이 추가했다. 첫번째 시간에 generate 하면서 선택했었다

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

spring-boot-starter-data-jpa와 메모리 디비인 h2를 디펜더시 받았다. 만약 jpa만 있고 데이터베이스가 없다면 서버 기동시 에러가 발생한다. 왜냐하면 spring-boot의 자동설정과 관련이 있는데 datasource를 설정하고 연결해야 되는데 드라이버가 없어 에러 발생한다. 기본적으로 spring-boot-stater-data-jpa 를 디펜더시 받으면 하이버네이트, spring-data-jpa, jdbc등 관련 라이브러리들을 자동으로 디펜더시 한다. xml에 디펜더시할 작업들이 많이 사라진다.

@Data
@Entity
public class Post {

  @Id
  @GeneratedValue
  private Long id;

  @NotNull
  private String title;

  @Lob
  @NotNull
  private String content;

  @Lob
  private String code;

  @Enumerated(EnumType.STRING)
  private PostStatus status;

  private LocalDateTime regDate;

  Post(){
  }
}

lombok을 사용했으니 디펜더시와 사용법은 인터넷에서 찾아보자.
Post entity이다. 실제 디비와 매핑되는 도메인은 @Entity라고 명시해 줘야한다. 그래야 데이터베이스와 매핑된다. @Id는 말그대로 key를 나타내고 @GeneratedValue 키의 전략을 나타낸다. 기본적으로는 특정 데이터 베이스이 맞게 자동으로 선택되는 전략이다. 이외에도 시퀀스, 아이덴티티, 테이블등 JPA에서는 4가지 전략이 있다. @Lob은 text 타입으로 사용하기 위해 지정했고 자동으로 테이블이 만들어질때 text타입이거나 각각 데이터베이스에 맞게 만들어 진다.

Post에는 select, insert, update, delete(실제로는 update) 등이 있어야 한다. select경우에는 단건 조회 전체 조회가 있어야 하며 전체 조회를 할 경우에는 페이징 처리도 해야한다. repository부터 살펴보자.

public interface PostRepository extends JpaRepository<Post, Long> {
  Post findByIdAndStatus(Long id, PostStatus status);
}

레파지토리는 위의 인터페이스가 끝이다. 아주 심플하다. 원래는 findOne만 써도 id를 기준으로 가져 오는 것이 있는데 여기서는 status 값도 있기에 findByIdAndStatus() 메서드를 만들었다.
상속받은 JpaRepository에는 더 많은 메서드가 존재한다.

2.blog1

hierarchy 구조로 보자면 위와 같은 그림이다. 기본적으로 구현체는 SimpleJpaRepository를 사용하고 findByIdAndStatus이 경우에는 메서드 이름을 읽어 JPQL로 변환시켜 준다.

@Service
@Transactional
@RequiredArgsConstructor
public class PostService {
  private final PostRepository postRepository;

  public Post createPost(Post post) {
    post.setRegDate(LocalDateTime.now());
    return postRepository.save(post);
  }

  public Post updatePost(Long id, Post post) {
    Post oldPost = postRepository.findByIdAndStatus(id, PostStatus.Y);
    if(oldPost == null){
      throw new NotFoundException(id + " not found");
    }

    oldPost.setContent(post.getContent());
    oldPost.setCode(post.getCode());
    oldPost.setTitle(post.getTitle());
    return oldPost;
  }

  public void deletePost(Long id) {
    Post oldPost = postRepository.findByIdAndStatus(id, PostStatus.Y);
    if(oldPost == null){
      throw new NotFoundException(id + " not found");
    }
    oldPost.setStatus(PostStatus.N);
  }

  public Post findByIdAndStatus(Long id, PostStatus status) {
    Post post = postRepository.findByIdAndStatus(id, status);
    if(post == null){
      throw new NotFoundException(id + " not found");
    }
    return post;
  }
}

다음은 서비스 클래스이다. 여기서는 딱히 설명할 것이 없는듯한데.. 우리가 흔히 쓰던 비지니스 로직이 들어가 있다. 생성하고 조회하는건 별다른 문제가 없어보이지만 여기서 눈에 띄는것은 updatePost메서드와 deletePost메서드이다. 조회를 한뒤에 업데이트를 날리는 메서드가 없다. 이것을 이해하려면 JPA의 영속성에 대해 알아야 한다. 영속성에 대해서는 양이 많으니 여기서는 이렇게만 알고 있자. Transaction 범위에서는 영속상태의 객체를 만들수 있다. 일단 이것만 기억하자. 영속상태인 객체가 변경되고 트랜잭션이 끝나면 변경된 객체의 속성을 감지하여 update 쿼리를 날린다. 이것을 변경감지라고 한다. 그래서 update메서드는 존재 하지 않고 영속상태의 객체에 변경만 해주었다.

@Controller
@RequiredArgsConstructor
@RequestMapping("/posts")
public class PostController {

  private final PostService postService;

  @GetMapping("/{id}")
  public String findByPost(@PathVariable Long id, Model model) {
    Post post = postService.findByIdAndStatus(id, PostStatus.Y);
    if(post == null){
      throw new NotFoundException(id + " not found");
    }
    model.addAttribute("post", post);
    return "post/post";
  }

  @GetMapping("/new")
  public String newPost(PostDto postDto) {
    return "post/new";
  }

  @GetMapping("/edit/{id}")
  public String editPost(@PathVariable Long id, Model model) {
    Post post = postService.findByIdAndStatus(id, PostStatus.Y);
    if(post == null){
      throw new NotFoundException(id + " not found");
    }
    PostDto createPost = new PostDto();
    createPost.setTitle(post.getTitle());
    createPost.setCode(post.getCode());
    createPost.setContent(post.getContent());
    createPost.setId(id);
    model.addAttribute("editPost", createPost);
    return "post/edit";
  }

  @PostMapping
  public String createPost(@ModelAttribute @Valid PostDto createPost, BindingResult bindingResult, Model model) {
    if(bindingResult.hasErrors()){
      return "post/new";
    }
    Post post = new Post(createPost.getTitle(),
      createPost.getContent(),
      createPost.getCode(),
      PostStatus.Y);
    Post newPost = postService.createPost(post);
    model.addAttribute("post", newPost);
    return "redirect:/posts/" +  newPost.getId();
  }

  @PostMapping("/{id}/edit")
  public String modifyPost(@PathVariable Long id, @ModelAttribute("editPost") @Valid PostDto createPost, BindingResult bindingResult) {
    if(bindingResult.hasErrors()){
      return "post/edit";
    }
    postService.updatePost(id, new Post(
      createPost.getTitle(),
      createPost.getContent(),
      createPost.getCode(),
      PostStatus.Y
    ));
    return "redirect:/posts/" +  id;
  }

  @PostMapping("{id}/delete")
  public String deletePost(@PathVariable Long id){
    postService.deletePost(id);
    return "redirect:/#/";
  }
}

다음은 Controller 클래스이다. 여기서는 파라미터로 받은 객체들을 매핑해서 Service로 호출하는 그런로직이다. 필자의 경우는 컨트롤러에서 비지니스 로직은 넣지않고 파라미터 체크나 매핑정도만 한다. 실제 개발을 할때도 그정도만 해준다. 물론 가끔 비지니스 로직이 있을 때도 있는데 거의 대부분은 Service 계층으로 넘긴다. 이건 개발자 마음이긴하나 대부분 비지니스들은 트랜잭션이 한꺼번에 묶여있어야 하므로 Service에서 하는게 좋다고 생각한다.

여기서 볼것은 Mapping어노테션이다. 첫 번째 시간에도 말했지만 Spring 4.3부터 @RequestMapping(method = RequestMethod.GET) 이 어노테이션 대신 @GetMapping으로 바꿔 쓸 수 있다. @GetMapping을 보면 아래와 같이 되어 있다.

@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
  //...
  //...
}

@RequestMapping(method = RequestMethod.GET)을 어노테이션을 사용하고 있다.

그리고 여기서 주의할 점은 우리는 LocalDatetime을 쓰고 있다. 아직 Jpa에서는 LocalDatetime을 지원하지 않는다. 아마도 Jpa2.1이 나오고 java8이 릴리즈 되어기 때문이다. 그래서 데이터베이스와 LocalDatetime을 매핑 시켜줘야 하는데 아래와 같이 추가 하자.

@SpringBootApplication
@EntityScan(basePackageClasses = {SpringBootCleanBlogApplication.class, Jsr310JpaConverters.class})
public class SpringBootCleanBlogApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootCleanBlogApplication.class, args);
    }
}

Jsr310JpaConverters 클래스는 Spring에 있는 클래스이며 클래스안에 static class 로 LocalDateTimeConverter가 있다. 나는 따로 구현하고 싶다면 LocalDateTimeConverter를 복사해 커스텀하게 만들면 된다. 그러면 굳이 EntityScan을 할필요는 없다.

Controller까지 완성되었다. 테스트를 해보자.

@RunWith(SpringRunner.class)
@WebMvcTest(PostController.class)
public class PostControllerTest {

  @Autowired
  private MockMvc mvc;

  @MockBean
  private PostService postService;

  @Test
  public void findByPost() throws Exception {
    given(this.postService.findByIdAndStatus(anyLong(), anyObject())).willReturn(new Post("제목", "컨텐츠","마크다운", PostStatus.Y));
    MvcResult mvcResult = this.mvc.perform(get("/posts/{id}", 1))
      .andExpect(status().isOk())
      .andReturn();

    Post post = (Post) mvcResult.getModelAndView().getModel().get("post");
    assertThat(post.getTitle()).isEqualTo("제목");
    assertThat(post.getContent()).isEqualTo("컨텐츠");
    assertThat(post.getCode()).isEqualTo("마크다운");
    assertThat(post.getStatus()).isEqualTo(PostStatus.Y);
  }



  @Test
  public void newPost() throws Exception {
    this.mvc.perform(get("/posts/new"))
      .andExpect(status().isOk())
      .andExpect(view().name("post/new"))
      .andReturn();
  }

  @Test
  public void editPost() throws Exception {
    given(this.postService.findByIdAndStatus(anyLong(), anyObject())).willReturn(new Post("제목", "컨텐츠","마크다운", PostStatus.Y));
    MvcResult mvcResult = this.mvc.perform(get("/posts/edit/{id}", 1))
      .andExpect(status().isOk())
      .andReturn();

    PostDto postDto = (PostDto) mvcResult.getModelAndView().getModel().get("editPost");
    assertThat(postDto.getTitle()).isEqualTo("제목");
    assertThat(postDto.getContent()).isEqualTo("컨텐츠");
    assertThat(postDto.getCode()).isEqualTo("마크다운");
  }

  @Test
  public void editPostNotFoundException() throws Exception {
    given(this.postService.findByIdAndStatus(1L, PostStatus.Y)).willReturn(new Post("제목", "컨텐츠","마크다운", PostStatus.Y));
    this.mvc.perform(get("/posts/edit/{id}", 2))
      .andExpect(status().isNotFound());
  }

  @Test
  public void createPost() throws Exception {
    Post post = new Post(1L, "제목1", "컨텐츠1","마크다운1", PostStatus.Y);
    given(postService.createPost(any())).willReturn(post);

    this.mvc.perform(post("/posts")
      .param("title","제목1")
      .param("content","컨텐츠1")
      .param("code","마크다운1"))
      .andExpect(status().isFound())
      .andExpect(header().string(HttpHeaders.LOCATION, "/posts/1"));
  }

  @Test
  public void createPostValid() throws Exception {
    this.mvc.perform(post("/posts")
      .param("title","제목1")
      .param("code","마크다운1"))
      .andExpect(view().name("post/new"));

  }

  @Test
  public void modifyPost() throws Exception {
    Post post = new Post(1L, "제목2", "컨텐츠2","마크다운2", PostStatus.Y);
    given(postService.updatePost(any(),any())).willReturn(post);

    this.mvc.perform(post("/posts/{id}/edit", 1L)
      .param("title","제목2")
      .param("content","컨텐츠2")
      .param("code","마크다운2"))
      .andExpect(status().isFound())
      .andExpect(header().string(HttpHeaders.LOCATION, "/posts/1"));
  }

  @Test
  public void deletePost() throws Exception {
    doNothing().when(postService).deletePost(anyLong());
    this.mvc.perform(post("/posts/{id}/delete", 1L))
      .andExpect(status().isFound())
      .andExpect(header().string(HttpHeaders.LOCATION, "/#/"));

  }
}

각각의 테스트 메서드이다. 실제 데이터는 아니고 가짜객체들을 모아서 테스트를 진행했다. @WebMvcTest는 Spring Boot 1.4에 추가된 어노테이션으로 간단하게 Web만 Test를 할 수 있다. 딱 Controller만 설정하기 때문에 때문에 테스트 시간이 짧다. 만약 다른 의존성이 있다면 테스트하기 아직 조금 까따로운거 같다. 많이 해보지는 않아서.. SpringBootTest로 많이 했기때문에 익숙지 않다.

given() 메서드와 willReturn() 메서드는 가짜 객체들 만들기 위함이다. 예를 들어 given(this.postService.findByIdAndStatus(anyLong(), anyObject())).willReturn(new Post(“제목”, “컨텐츠”,”마크다운”, PostStatus.Y)) 이 코드는 postService.findByIdAndStatus 호출하면 return 값으로 new Post(“제목”, “컨텐츠”,”마크다운”, PostStatus.Y)를 받아 올거라는 가짜로 만든 객체다. 실제 postService 메서드는 호출하지 않으며 AOP를 통해 가짜 객체만 넣어서 리턴한다.
그냥 postService.findByIdAndStatus 이 메서드에서 이런 값을 넣으면 이런값이 나와 어떠한 비지니스를 테스트하는 그런 테스트 코드다. 말이 이상하네. 아무튼 직접해보면 더 잘 알거 같다. 테스트를 돌려보면 우리가 원하는 초록색 불이 들어온다.
일단 테스트 까지 되었다.

오늘은 여기까지 하고 다음시간에 Post에 대한 Category와 Comment를 만들어보자. 아마도 JPA 얘기가 많을 듯 하다.
오늘 만든거 까지는 여기에 올라가 있다.

  1. [spring-boot] 블로그를 만들자 (1)

  2. [spring-boot] 블로그를 만들자. (2) JPA

  3. [spring-boot] 블로그를 만들자. (3) Category 와 Comment

  4. [spring-boot] 블로그를 만들자. (4) thymeleaf

  5. [spring-boot] 블로그를 만들자. (5) markdown, catetory

  6. [spring-boot] 블로그를 만들자. (6) 댓글과 Navigation

  7. [spring-boot] 블로그를 만들자. (7) 캐시와 에러페이지

  8. [spring-boot] 블로그를 만들자. (8) GitHub login

  9. [spring-boot] 블로그를 만들자. (9) CI 와 배포