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

블로그 만들기 3번째 시간이다. 이번시간에는 post에 대한 카테고리 와 댓글을 만들어보자.
post에는 여러개의 category를 넣을 수도 있겠지만 여기서는 한개의 category만 선택할 수 있고 댓글 경우에는 여러개의 입력 할 수 있게 만들어 보았다.

3.blog1

물론 컬럼들은 바뀔 수 있으나 큰 흐름은 대충 위와 같다. 만약 카테고리도 여러개를 넣고 싶다면 매핑테이블을 만들어서 하면 될 듯하다.

post와 category 와의 관계는 다대일 관계이다. 반대로도 생각할 수 도 있는데 category 와 post의 관계는 일대다 다.
보통의 db의 경우에는 양방향으로 검색이 가능하다. 예를들어 category기준으로 post를 검색할 수도 있고 post기준으로 category를 검색 할 수 도 있다. 하지만 객체의 경우에는 양방향이라는게 없다. 왜냐하면 객체는 항상 단방향이기 때문이다. 단방향이지만 양방향으로 만들 수 있는데 참조하는 쪽에 연관관계를 한개 더 만들면 가능하다. 하지만 이것은 양방향이라기보다 서로 다른 단방향 관계 2개다. 그래서 양방향이 없다고 이야기 한것이다. 하지만 여기에서는 서로다른 단반향 관계를 양뱡향이라고 하겠다.
category 엔티티를 보자.

@Entity
public class Category {

  @Id
  @GeneratedValue
  private Long id;

  private String name;

  private LocalDateTime regDate;

  @OneToMany(mappedBy = "category", fetch = FetchType.LAZY)
  private List<Post> post = new ArrayList<>();
  //기타 생성자 getter setter
}

@OneToMany 어노테이션으로 우리는 일대다 관계를 만들 수 있다. 속성 중 mappedBy는 설명할게 좀 있으니 그냥 연관관계의 주인이 아니라고만 알고 있자. fetch의 경우에는 즉시로딩 과 지연로딩을 설정할 수 있다. 즉시로딩일 경우에는 Category를 가져올때 무조건 post도 가져온다는 의미이고 지연로딩일 경우에는 Category를 가져올때 post는 프록시 객체로 가져오고 만약 post를 사용한다면 그때 쿼리해서 가져온다. 말은 쉽지 실제 모르고 개발하면 쉽지 않다. 영속성과 관련도 많이 있어서.. 특별한 경우가 아니라면 지연로딩을 추천하고 있다.

@Entity
public class Post {
  //기타 프로퍼티

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "CATEGORY_ID")
  private Category category;

  //기타 생략
}

우리는 양뱡향으로 설정하기 위해서 위와 같이 post에도 category를 넣었다. @ManyToOne은 다대일이라는 표현이다. 속성은 많지만 다 설명하면 jpa시간이 되버리니 생략한다. @JoinColumn은 외래키를 매핑할 때 사용한다. name이라는 속성에 매핑할 외래 키 이름을 지정해주면 된다. 하지만 이 이노테이션은 생략 가능하다.

@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;

    private String content;

    private LocalDateTime regDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "POST_ID")
    private Post post;
    //기타 생성자 getter setter
}

Comment 경우에는 post 한개당 여러개를 달 수 있으므로 다대일 관계가 된다. 그리고 post 입장에서는 일대다가 된다. 아래와 같이 post에도 추가 하자.

public class Post {
  //...
  @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
  private List<Comment> comments;

  //...
}

그럼 얼추 도메인 모델은 끝이 났다.
category의 경우는 CRUD 모두 있어야 하지만 comment 경우에는 단건 조회와 변경은 굳이 만들지 필요 없을 듯 하다.

@Controller
@RequiredArgsConstructor
@RequestMapping("/categories")
public class CategoryController {

  private final CategoryService categoryService;

  @GetMapping
  public String categories(Pageable pageable, Model model) {
    model.addAttribute("categories", categoryService.findAll(pageable));
    return "category/list";
  }

  @GetMapping("/new")
  public String newCategory(@ModelAttribute CategoryDto categoryDto) {
    return "category/new";
  }

  @GetMapping("/{id}/edit")
  public String edit(@PathVariable Long id, Model model) {
    model.addAttribute("categoryDto", categoryService.findOne(id));
    return "category/edit";
  }

  @PostMapping
  public String createCategory(@ModelAttribute @Valid CategoryDto categoryDto, BindingResult bindingResult) {
    if(bindingResult.hasErrors()){
      return "category/new";
    }
    categoryService.createCategory(new Category(categoryDto.getId(), categoryDto.getName()));
    return "redirect:/categories";
  }

  @PostMapping("/{id}/edit")
  public String modifyCategory(@PathVariable Long id, @ModelAttribute @Valid CategoryDto categoryDto, BindingResult bindingResult) {
    if(bindingResult.hasErrors()){
      return "category/edit";
    }
    categoryService.updateCategory(new Category(id, categoryDto.getName()));
    return "redirect:/categories";
  }

  @PostMapping("/{id}/delete")
  public String deleteCategory(@PathVariable Long id) {
    categoryService.delete(id);
    return "redirect:/categories";
  }
}
@Service
@Transactional
@RequiredArgsConstructor
public class CategoryService {

  private final CategoryRepository categoryRepository;

  public Category createCategory(Category category){
    category.setRegDate(LocalDateTime.now());
    return categoryRepository.save(category);
  }

  public void delete(Long id) {
    categoryRepository.delete(id);
  }

  public void updateCategory(Category category) {
    Category oldCategory = categoryRepository.findOne(category.getId());
    if(oldCategory != null){
      oldCategory.setName(category.getName());
    }
  }

  @Transactional(readOnly = true)
  public Page<Category> findAll(Pageable pageable) {
    return categoryRepository.findAll(pageable);
  }

  @Transactional(readOnly = true)
  public List<Category> findAll() {
    return categoryRepository.findAll();
  }

  public Category findOne(Long id) {
    return categoryRepository.findOne(id);
  }
}
public interface CategoryRepository extends JpaRepository<Category, Long> {
}

기본적인 읽기와 쓰기이다. 굳이 설명은 이 전시간에 했기 때문에 생략하고 소스만 보자.

다음은 Comment의 기본적인 컨트롤러 서비스 레파지토리다.


@Controller @RequestMapping("/comments") @RequiredArgsConstructor public class CommentController { private final CommentService commentService; @PostMapping public String createComment(@ModelAttribute @Valid CommentDto commentDto, BindingResult bindingResult, Model model){ if(bindingResult.hasErrors()){ return "post/post"; } model.addAttribute("comment",commentService.createComment( new Comment(commentDto.getContent(), new Post(commentDto.getPostId())))); return "redirect:/posts/"+commentDto.getPostId(); } @PostMapping("/{postId}/{commentId}") public String deleteComment(@PathVariable Long postId, @PathVariable Long commentId){ commentService.deleteComment(commentId); return "redirect:/posts/"+postId; } }

여기서 중요한것은 comment를 생성할 때 해당하는 연관관계의 post를 넣어 줘야 한다. comment에는 post와의 연관관계가 설정 되어 있다. 그래야 comment의 외래키에 postId가 들어간다. 원래는 postId가 있는지 검증도 해야하고 영속 객체를 넣는게 더 좋은 방법인듯 하다. 하지만 여기서는 무조건 있는 값이 넘어 온다고 가정했다. 그런 로직은 service에 있는게 맞을 듯 하다.
그러면 post의 카테고리도 마찬가지로 동일하게 넣어줘야 한다.

@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,
    new Category(createPost.getCategoryId()));
  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,
    new Category(createPost.getCategoryId())
  ));
  return "redirect:/posts/" +  id;
}

post도 comment와 마찬가지로 생성시에 연관관계의 category를 넣어 줬다. 연관관계의 주인만이 읽기, 쓰기가 모두 가능하다. 주인이 아닌 곳에서는 읽기만 가능하다.
아까 위에서 mappedBy를 잠깐 언급했는데 mappedBy가 있는 곳이 연관관계의 주인이 아니다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "CATEGORY_ID")
private Category category;

위와 같이 Post에 category는 mappedBy가 없으므로 post가 연관관계의 주인이 된다. 물론 연관관계의 주인을 바꿀 수는 있으나 성능과 관리 측면에서 권장하지는 않는다.

계속해서 comment의 service 와 repository 클래스를 보자.

@Service
@Transactional
@RequiredArgsConstructor
public class CommentService {

    private final CommentRepository commentRepository;

    public Comment createComment(Comment comment){
        comment.setRegDate(LocalDateTime.now());
        return commentRepository.save(comment);
    }

    public void deleteComment(Long commentId) {
        commentRepository.delete(commentId);
    }
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
}

여기서 읽기가 없는 이유는 나중에 post에서 바로 불러온다. 그래서 굳이 여기서는 필요 없어서 지웠다.
일단 기본적인 crud는 완성 되었다. 기본적인 것만 추가를 했으니 나중에 더 추가할 내용이 조금 있다. 아직 다 완성 된게 아니다보니 하다가 수정사항이 더 있을 수 있다.

오늘 중요한거는 객체의 일대다 다대일 관계였다.

다음 시간에는 아마도 view templates 인 thymeleaf를 붙어서 뷰를 만들어 보자. 뷰가 나와야 뭔가 조금은 한듯 싶으니..

현재까지의 소스는 여기에 올라가 있다.

  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 와 배포

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

블로그 만들기 두번째 시간이다. 오늘은 무엇을 할까 고민하다가 블로그니까 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 와 배포

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

거의 3주만에 글을 쓴다. 포스팅할 블로그를 만들고 있어서… 시간 날때 마다 잠깐 잠깐 만들었다. 원래는 마이크로서비스를 하려고 했는데 블로그를 먼저 하고 만든 블로그를 마이크로서비스로 할까 생각중이다. 일단 블로그 먼저 하고..
아무튼 뭐 대단한건 아니고 심플한 블로그를 만들 예정이라. 일단 대충 만들어놔서 만든걸 바탕으로 다시 만들 예정이다. 그러면서 이상한 부분은 고치고…
오늘은 프로젝트를 만드는 정도로 일단 끝낼 것이다. 일주일에 한두번? 정도 쓰면 많이 쓰는거고 일주일에 한번 예상해본다.

블로그의 스펙과 간단한 설명을 보자.
제목에도 나와있듯이 Spring boot 1.4를 사용하고 Spring data jpa를 사용한다. 구현체는 당연히 hibernate를 사용할 것이다. 디비는 h2를 사용하고 나중에 메모리 디비 말고 mysql을 설정해서 해보자.
음 그리고 캐시도 사용할 예정인데 이건 그냥 이렇게 쓰면된다? 그정도만 사용할 것이다. 원래는 카페인 캐싱을 사용하려 했는데 설정 방법을 아주 디테일하게 하는걸 몰라서 (일단 찾기가 귀찮) 그냥 JSR-107 JCache로 했다. 구현체는 ehcache로 할 예정이다.
또 뭐가 있나보자. 로그인은 github sso를 이용해서 로그인 할 것이고, 뷰 템블릿은 맨 처음에 mustache로 할려고 했으나 이거 역시 디테일이 부족해서 못하고 다시 thymeleaf3로 할려고 했으나(springsecurity4 인가? 그게 안되는거 같다서 때려쳤다.) 이거 역시 못하고 thymeleaf 2.1 로 다시 내렸다.
그리고 포스팅할 블로그에는 markdown문법으로 하려고 만들긴 했는데 UI이는 영 별로이다. 필자가 UI감각도 없고 만들지도 못해서..
누군가가 markdown을 잘 만들어 놨다.
https://jbt.github.io/markdown-editor 여기 보면 codemirror 까지 있어서 바로바로 뷰를 확인 할 수는 있는데 블로그안에 넣기에는 화면이 작다. 아무튼 UI는 그럭저럭 고친다고 했는데 역시나…
블로그 테마는 clean-blog라고 괜찮은 UI가 있다. 사실 무료이면서 괜찮은 UI가 없는 듯 하다. 있어도 영 마음에 안드는 그런 UI이다. 무료중에 clean-blog가 제일 낫다. 링크는 아래와 같다.
https://startbootstrap.com/template-overviews/clean-blog/

마지막으로 CI 툴과 배포까지 할 예정인데 CI 툴은 semaphoreci 이라는 툴(개인적으로 쓰는거라)을 사용하고 배포할 곳은 무료(하지만 느리고 뭔가 부족하다. 돈내고 사용하면 나쁘지 않은거 같은데 일단 테스트 용도이니 무료로만 사용해서 하겠다.)인 heroku를 사용하겠다.
이것으로 간단하게 설명은 다 한 듯 하다.

그럼 프로젝트를 만들어서 시작해보자. 오늘은 프로젝트를 만들고 hello world만 찍고 마무리하자!

http://start.spring.io/ 여기에서 generate 해도 되고 필자는 인텔리j를 사용하니 그 기준으로 하겠다. spring sts를 사용해도 비슷한 맥략일 듯하다.

1.blog1

파일에 new project를 하면 위와같은 창이 뜬다. 파란색으로 선택된 spring initializr 를 선택하고 Name group Artifact등 설정하고 싶은대로 설정하면 된다.

1.blog2

필자는 위와 같이 설정했다. 다음 next를 누르면 Dependencies를 설정할 수 있다. 일단 web, jpa, h2, lombok를 선택하자. lombok은 선택사항이니 하지 않아도 되거나 만약 모르면 인터넷이 찾아보면 아주 간단하게 설치할 수 있다.

1.blog3

마지막으로 projectName을 설정하면 끝난다. 필자는 위에 썼던 Artifact과 동일하게 썼다. 완료를 누르자. 참고로 maven 관련 쉘들은 지웠다.

패키지 아래의 하나의 클래스가 있는데 이게 메인 클래스이다. Spring boot를 요즘 많이 사용하다보니 굳이 이것까지는 설명 하지 않아도 될 듯 싶은데.. 그래도 모르는 사람을 위해서 간단하게 설명하자면 spring boot는 톰캣이 내장되어 있다. (정확히는 web을 디펜더시 받아야..) 굳이 서버를 셋팅하지 않아도 spring-boot-starter-web만 디펜더시 받는 다면 톰캣이 내장되어 들어 가 있다. 그리고 boot의 핵심이라고 할 수 있는 autoconfiguration 이 있다. @SpringBootApplication이 어노테이션은 마법의 어노테이션이다. 우리가 xml로 또는 javaConfig 기본 설정들이 해당 라이브러리가 클래스 패스에 있으면 자동으로 설정해준다. 이것은 나중에 기회가 된다면 살펴보기로 하고 일단 자동으로 해주는게 많다고 생각하면 된다. 이렇게 좋은 자동 설정이지만 자동설정만으로 모든걸을 할 수 없다. 실제 프로젝트를 해보면 예외상황이 많다. 내가 원하는 설정이 이게 아닐 수도 있고 설정이 되어 있는데 몰라서 다시 설정하는 경우에도 있고 그러다보면 설정이 이상하게 먹고.. 아무튼 변수가 적지 않다. 무작정 도입하는 것보다 좀더 익숙해지고 어노정도 자유롭게 다룰 수 있다면 그때 도입해도 늦지 않다. 잘 모르는 상황에서 도입했다가 더 미궁으로 빠질 수도 있다고 생각한다.

@SpringBootApplication
public class SpringBootCleanBlogApplication {

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

우리는 메인 클래스를 실행만 하면 된다. 이것만으로는 진짜로 잘되는지 알 수 없기에 hello world를 출력해보자.

@SpringBootApplication
@RestController
public class SpringBootCleanBlogApplication {

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

    @GetMapping
    public String hello(){
        return "hello world";
    }
}

우리가 흔히 message를 전송할 때 쓰는 @RestController를 추가 했고 @GetMapping이라는 Spring 4.3에 추가된 어노테이션을 추가 했다. 그리고 단순하게 hello world라는 문자열만 출력하였다.
어디 잘되나 보자. 메인 클래스를 실행하면 서버의 로그가 쭉쭉 출력된다.. 서버가 다 기동이 되었다면 웹브라우저에 http://localhost:8080 을 쳐서 들어가보자.
그럼 빈페이지에 hello world라는 문자열이 출력 된걸 확인 할 수 있다. 완벽하다.
우리는 이렇게 아주 빠르고 심플하게 서버를 띄우는데 성공했다. hello world 찍는데 불과 몇분 걸리지 않았다. 예전에는 spring 설정만 반나절 혹은 길게는 하루가도 걸렸는데 몇 분안에 할 수 있다는게 아주 맘에 쏙 든다.

오늘까지한 소스는 여기에 올려 놨다.

다음 시간에는 뭘할지는 생각해보고 해야겠다. 딱히 떠오르는게 없어서.. 일단 도메인을 만들고.. 그떄 생가하자.

  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 와 배포