> Hello World !!!

     

@syaku

spring boot jpa 스프링 부트 블로그 만들기 #1 - blog

스프링 부트 블로그 만들기 #1

Github: https://github.com/syakuis/syaku-blog

모든글

변경사항

  • 2018-10-24 - 리팩토링

    • Post 인터페이스 제거 대신 PostEntity Setter 비활성화한다. 필요할때 활성화한다.

    • Entity Date 형식을 LocalDateTime 형식으로 변경.

    • 날짜 값 자동 주입을 PrePresist 대신 @CreationTimestamp로 변경.

    • id long 에서 Long 형식으로 변경.

이번 포스팅부터 블로그 프로그램을 개발할 것이다. 포스트 작성시 기본적으로 필요한 항목은 아래와 같다.

  • 번호

  • 제목

  • 내용

  • 등록일

  • 수정일

< / > PostEntity.java

package org.syaku.blog.post.domain;

@Data
@Setter(AccessLevel.NONE)
@Builder
public class PostEntity {
 private Long id;
 private String subject;
 private String contents;
 private LocalDateTime creationDateTime;
 private LocalDateTime modificationDateTime;
}

@Date@Builder 롬복 주석을 사용했다. Data 주석은 자동으로 아래의 기능을 생성한다.

getId
getSubject
getContents
getCreationDateTime
getModificationDateTime
setId
setSubject
setContents
setCreationDateTime
setModificationDateTime
equals
hashCode
canEqual
toString

그리고 Builder 주석은 빌더 패턴을 구현해준다. 그래서 아래와 같이 사용할 수 있다.

PostEntity post = PostEntity.builder().id(1).subject("제목").build();

// 사용할 수 없다.
PostEntity post = new PostEntity();

필요하다면 @Setter(AccessLevel.NONE) 를 추가하면 불변 객체를 만들 수 있다.

이 포스트 클래스는 JPA 구현체를 통해 상태가 관리되고 데이터 상태를 유지하기 위해 DBMS (H2) 를 사용한다.

JPA 에 대한 설명은 인터넷에 쉽게 찾아볼 수 있다. 여러 장점 중에 내가 선호하는 딱한가지를 말한다면 myBatis 보다 간결한 코드를 작성할 수 있고 반복적인 코드를 줄일 수 있다. (보일러 플레이트를 제거할 수 있다.) 하지만 둘다 배워두는 것이 좋다고 생각한다.

개발스팩

spring data jpa

H2

두개의 라이브러리 의존성을 포함한다.

compile "org.springframework.boot:spring-boot-starter-data-jpa"
compile "com.h2database:h2"

포스트 엔티티 클래스를 영속성 컨텍스트가 관리할 수 있도록 다시 작성한다.

package org.syaku.blog.post.domain;

@Entity
@Table(name = "POST")
@NoArgsConstructor
@AllArgsConstructor
@Data
@Setter(AccessLevel.NONE)
@Builder
public class PostEntity {
 @Id
 @Column
 @SequenceGenerator(
   name = "POST_ID_GEN",
   sequenceName = "POST_ID_SEQ",
   allocationSize = 1)
 @GeneratedValue(generator = "POST_ID_GEN", strategy = GenerationType.SEQUENCE)
 private Long id;
 @Column
 private String subject;
 @Column
 @Lob
 private String contents;
 @Column
 private LocalDateTime creationDateTime;
 @Column
 private LocalDateTime modificationDateTime;
}

영속성 컨텍스트에 관리되는 엔티티는 빈 생성자가 꼭 필요하며 영속성 상태에서 데이터 상태를 수정할 수 있도록 setter 가 필요하다. 그래서 @NoArgsConstructor, @AllArgsConstructor 를 추가하였다.

하지만 영속성 상태가 아닐때에는 변경하지 못하게 해야한다. 객체를 항상 변경되지 않도록 불변 객체를 만드는 것이 문제를 최소화하는 해결책이기도 하다. 또한 캡슐화를 통해 클래스 사용자가 알지 않아도 되는 기능을 제거하므로 혼란을 줄 일수 있다.

또한 이런 도메인 설계는 이론으로 하는 것이 아니라 경험으로 이해하는 것이 아주 좋은 학습 방법이다. 그러니 단순하게 넘기지말고 설계를 항상 고민하려고 노력해야 한다.

그래서 불변객체를 형성하기 위해 @Setter(AccessLevel.NONE) 추가하였다. 이렇게 설정하면 롬복은 Setter 를 만들지 않는 다. 나중에 필요할때 접근 제어를 변경할 것이다.

인텔리J 에서 인터페이스를 생성하는 방법.

컨텍스트메뉴를 활성화한다. Refactor > Extract > Interface

컨텍스트 메뉴는 마우스 오른쪽 버튼을 눌럿을때 활성화되는 메뉴이다.

이제 엔티티를 상태를 관리해보자. 우선 테스트부터 적성한다.

< / > PostServiceTest.java

package org.syaku.blog.post.service;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class PostServiceTest {
 @Autowired
 private PostRepository postRepository;

 @Test
 public void 전체데이터가져오기() {
   assertFalse(postRepository.findAll().isEmpty());
}
}

시작부터 오류가 발생한다. 우선 PostRepository 라는 클래스는 없으니 만들자. 난 이전에 mybatis 사용할때 접미사를 DAO 라고도 명명했고 JPA 로 넘어오면서 Repository 명명했다.

spring data jpa 에서 제공하는 CrudRepository 인터페이스를 상속받으면 기본적인 crud 를 제공하기 때문에 추가로 기능을 작성할 필요가 없다. 직접 작성하고 싶다면 아래와 같이 할 수 있다.

package org.syaku.blog.post.repository;

public interface PostRepository extends Repository<PostEntity, Long> {
 List<PostEntity> findAll();
}

이렇게 인터페이스를 작성하면 알아서 시스템 내부적으로 프록시로 클래스 구현체를 생성한다.

이제 오류는 제거됐다. 테스트를 실행해본다. 데이터가 없어서 오류가 발생한다. 임시 데이터를 넣어준다.


 @Before
 public void setup() {
   postRepository.save(
     PostEntity.builder()
      .subject("제목")
      .contents("내용")
      .build());
}

이제 테스트를 실행하면 성공한다.

테스트는 하나의 기능(메소드) 마다 초기화되는 것을 원칙으로 하므로 @Before 는 테스트 하나가 실행될때마다 호출된다.

그리고 H2 데이터베이스를 기본으로 사용하고 있어서 따로 dataSource 설정하지 않아도 되고 H2 는 기본적으로 메모리 데이터베이스를 사용하므로 작업이 끝나면 모두 제거되어서 신경쓰지 않아도 된다.

엔티티에는 데이터에 대한 아무런 제약조건이 없다. 일반적으로 데이터베이스 테이블에는 제약조건이 있기 마련이다. 꼭 필요한 항목에 데이터가 없다면 문제가 발생할 수 있다. 모든 문제는 사전에 차단해야 한다. 아래와 같이 제약조건을 추가하고 테스트를 실행하자.

@Column(nullable = false)
private Long id;

@NotNull
@Column(nullable = false, length = 255)
private String subject;

@Column(name = "CREATION_DATETIME", nullable = false)
private LocalDateTime creationDateTime;

실패한 테스트가 된다.

NULL not allowed for column "CREATION_DATETIME"; SQL statement:

생성일에 날짜가 null 이기 때문에 발생한 오류이다.

자바 코드에 작성된 조건을 잠시 살펴본다. @Column(nullable = false) 테이블에 데이터가 저장될때 null 일수 없다. @NotNull 주석은 클래스 필드는 null 일 수 없다는 제약 조건이다. 우선 순위는 클래스 필드를 우선 검사되고 그후 컬럼 조건이 검사된다.

다시 원래 작업으로 돌아와서 실패 테스트를 해결해보자.

테이블에 데이터가 추가될때 생성일자를 등록해야된다. 수정될때는 생성날짜가 변경되면 안된다. 그럴때 아래와 같은 주석으로 해결할 수 있다.

  @PrePersist
 public void before() {
   this.creationDateTime = LocalDateTime.now();
}
 
 혹은
 
 @Column(name = "CREATION_DATETIME", nullable = false, updatable = false)
 @CreationTimestamp
 private LocalDateTime creationDateTime;

@Perpersist 는 영속성 상태에서 persist() 가 호출되기 전에 실행된다. 그리고 Column 주석에 updateable 속성은 업데이트 작업시 새로운 날짜를 설정할지 여부를 지정할 수 있다.

다시 테스트를 실행하면 성공적인 테스트가 진행된다. 나머지 테스트가 필요하다면 직접 작성해보자.

이제 컨트롤러를 생성하고 테스트를 진행해본다. 테스트 작성은 이번만 설명하고 다음에 필요할 경우외에만 설명한다.

자바 파일명이 끝에 Test 로 끝나는 건 모두 src/test 경로에 작성하고 아닌 것은 src/main 경로에 작성한다.

참고

https://docs.spring.io/spring/docs/5.1.1.RELEASE/spring-framework-reference/testing.html#spring-mvc-test-server

https://docs.spring.io/spring-boot/docs/2.0.5.RELEASE/reference/htmlsingle/#boot-features-testing-spring-boot-applications-testing-with-mock-environment

package org.syaku.blog.post.web;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class PostControllerTest {
 @Autowired
 private MockMvc mvc;

 @Autowired
 private PostService postService;

 @Before
 public void setup() {
   assertNotNull(postService);
}

 @Test
 public void test() throws Exception {
   this.mvc.perform(get("/")).andExpect(status().isOk())
    .andExpect(content().string("Hello World"));
}
}

PostService 클래스가 없으니 작성한다.

package org.syaku.blog.post.service;

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

 @Autowired
 public void setPostRepository(PostRepository postRepository) {
   this.postRepository = postRepository;
}
}

GET "/post" 와 일치하는 기능이 없기 때문에 오류가 발생한다. 컨트롤러를 작성한다.

package org.syaku.blog.post.web;

@RestController
@RequestMapping("/post")
public class PostController {
 @Autowired
 private PostService postService;
 
 @GetMapping("")
 public List<PostEntity> list() {
   return postService.getPostList();
}
}

@RestController@Controller 차이는 Controller 가 먼저 개발되어 RestController 로 개발하면 코드 수가 좀 더 적게 든다. @ResponseBody 를 생략할 수 있다.

@RequestMapping@GetMapping 차이도 비슷하다. 먼저 개발되어 후자를 사용하면 개발할때 코드 수를 줄일 수 있다. method 를 생략할 수 있다.

PostService 에 getPostList 기능이 없으니 추가한다.

< / > PostService.java

  public List<PostEntity> getPostList() {
   return Collections.unmodifiableList(postRepository.findAll());
}

자바 컬랙션을 이용하여 PostEntity 를 불변 리스트로 생성하였다. 테스트를 실행한다.

데이터가 없으니 문제없이 성공한다. 일단 기본적인 구동이 되는 지를 확인했고 이제 모든 기능을 구현해보자.

아래의 test 기능에 모든 코드를 작성했으나 원래 하나하나 작성하고 테스트해야 한다.

참고 : 스프링5 테스트 메뉴얼

< / > PostControllerTest.java

  @Before
 public void setup() {
   assertNotNull(postService);
   objectMapper = new ObjectMapper();
   objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
   objectMapper.registerModule(new JavaTimeModule());
}

 @Test
 public void test() throws Exception {
   // 쓰기 테스트
   this.mvc.perform(
     post("/post")
      .content(objectMapper.writeValueAsString(PostEntity.builder().subject("제목").contents("내용").build()))
      .contentType(MediaType.parseMediaType(MediaType.APPLICATION_JSON_UTF8_VALUE))
  )
    .andExpect(content().contentType(MediaType.parseMediaType(MediaType.APPLICATION_JSON_UTF8_VALUE)))
    .andExpect(status().isOk());

   List<PostEntity> posts = postService.getPostList();

   // 목록 테스트
   this.mvc.perform(
     get("/post")
  )
    .andExpect(content().contentType(MediaType.parseMediaType(MediaType.APPLICATION_JSON_UTF8_VALUE)))
    .andExpect(content().json(objectMapper.writeValueAsString(posts)))
    .andExpect(status().isOk());

   PostEntity post = postService.getPost(1);

   // 보기 테스트
   this.mvc.perform(get("/post/{id}", 1))
    .andExpect(content().contentType(MediaType.parseMediaType(MediaType.APPLICATION_JSON_UTF8_VALUE)))
    .andExpect(content().json(objectMapper.writeValueAsString(post)))
    .andExpect(status().isOk());

   this.mvc.perform(delete("/post/{id}", 1))
    .andExpect(status().isOk());

   assertTrue(postService.getPostList().isEmpty());
}

perform 요청 결과를 출력하고 싶으면 .andDo(print()) 추가해주면 된다.

jackson 에서 LocalDateTime 이 JSON 으로 파싱될때 날짜값이 배열로 생성된다. 이를 yyyy-MM-dd'T'HH:mm:ss.SSSZ 포맷으로 변경하기 위한 설정이다.

objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
objectMapper.registerModule(new JavaTimeModule());

만약 Date 타입을 사용할때는 jackjson 라이브러리를 사용하여 아래와 같이 원하는 포맷을 JSON 데이터를 파싱할 수 있다.

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd@HH:mm:ss.SSSZ")
private Date creationDate;

요청 url 에 일치하는 기능이 없으니 컨트롤러에 구현한다.

< / > PostController.java

  @GetMapping("/{id}")
 public PostEntity view(@PathVariable("id") long id) {
   return postService.getPost(id);
}

 @PostMapping("")
 public PostEntity post(@RequestBody PostEntity post) {
   return postService.save(post);
}

 @DeleteMapping("/{id}")
 public void delete(@PathVariable("id") long id) {
   postService.delete(id);
}

서비스에 기능이 없으니 구현하다.

  public PostEntity save(PostEntity postEntity) {
   return postRepository.save(postEntity);
}

 public PostEntity getPost(long id) {
   return postRepository.findById(id);
}

 public void delete(long id) {
   postRepository.deleteById(id);
}

리포지토리에도 기능을 구현한다.

  PostEntity findById(long id);
 PostEntity save(PostEntity postEntity);
 void deleteById(long id);

하나씩 작성하고 테스트를 실행한다.

일반적으로 목록 전체를 한번에 가져오지 않는 다. 데이터가 많아지면 부하가 발생하기 때문이다. 그래서 페이지처리를 해야 한다. 이번에는 목록에 대해 몇가지 기능을 구현한다.

  • 목록 정렬

  • 페이지처리 및 페이지네비게이션 구현

  • 포스트 검색 기능

목록 정렬은 최근 등록된 글이 먼저 나오고 나중에 등록된 글이 뒤에 나오도록한다. 이때 날짜로 정렬하면 안된다. 같은 날짜에 등록될 수도 있다. 그래서 id 로 정렬한다.

우선 테스트를 작성하자. 하지만 테스트를 하려면 대상이 있어야 한다. 그래서 미리 많은 데이터를 등록하고 테스트가 진행되도록 한다.

< / > PostListTest.java

package org.syaku.blog.post;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class PostListTest {
 @Autowired
 private PostService postService;

 /**
  * 여러 개의 포스트를 미리 등록하고 테스트한다.
  */
 @Before
 public void setup() {
   StringBuilder stringBuilder = new StringBuilder();
   for (int i = 0; i < 1000; i++) {
     postService.save(PostEntity.builder()
      .subject(stringBuilder.append("a").append(i).append(i % 2 == 0 ? "search" : "").toString())
      .contents("내용").build());
     stringBuilder.setLength(0);
  }
}

 @Test
 public void 등록_내림차순_정렬_테스트() {
   List<Post> posts = postService.getPostList();
assertTrue(posts.size() == 1000);
   
   Post firstPost = posts.get(0);
   assertEquals(lastPost.getSubject(), "a999" );
}
}

setup 기능에 많은 데이터가 저장될 수 있도록 구현하였고 서비스와 리포지토리에 없는 기능을 아래와 같이 구현했다.

  // Service class
 public List<PostEntity> save(List<PostEntity> postEntities) {
   return postRepository.saveAll(postEntities);
}
 
 // Repository class
 List<PostEntity> saveAll(List<PostEntity> postEntities);

하지만 saveAll 에서 타입이 잘못됐다는 오류가 발생했다. spring-data-jpa 라이브러리의 인터페이스인 CrudRepository 를 참고하니 Iterable 타입만 가능한 것 같다. 타입을 수정하고 아래과 같이 구현했다.

  // Repository class
 List<PostEntity> saveAll(Iterable<PostEntity> postEntities);

 @Before
 public void setup() {
   List<PostEntity> postEntities = new ArrayList<>();
   for (int i = 0; i < 1000; i++) {
     postEntities.add(PostEntity.builder().subject("a"+i).contents("내용").build());
  }
   
   this.postService.save(postEntities);
}

수정하고 테스트를 실행한다. 두번째 assertEquals 에서 오류가 발생한다.

내림차순으로 정렬하면 첫번째 데이터는 a999 여야 한다. 정렬되도록 수정해본다.

자바 클래스에 Comparable 상속받아 아래와 같이 하는 것이 일반적인 자바의 정렬이다.

  /* 오름차순 정렬 참고 (내림차순은 반대로하면 된다.)
 return 1 = this > parameter
 return -1 = this < parameter
 return 0 = this == parameter */

 @Override
 public int compareTo(PostEntity o) {
   if (id > o.getId()) {
     return 1;
  } else if (id < o.getId()) {
     return -1;
  } else {
     return 0;
  }
}

하지만 우린 jpa 를 하고 있기 때문에 데이터를 요청할때 정렬 쿼리로 요청해야 한다.

jpa 에서 정렬하려면 Sort 라는 클래스를 사용할 수 있다.

정렬을 추가하면서 서비스 클래스를 수정했다.

  private List<Post> getImmutable(List<PostEntity> postEntities) {
   return Collections.unmodifiableList(
     postEntities.stream().collect(Collectors.toList()));
}

 /**
  * 포스트 전체 데이터를 불변 목록으로 반환한다.
  * @return List
  */
 public List<Post> getPostList() {
   return this.getPostList(new Sort(Sort.Direction.DESC, "id"));
}

 public List<Post> getPostList(Sort sort) {
   return getImmutable(postRepository.findAll(sort));
}

이어서 리포지토리에는 기능을 추가한다.

List<PostEntity> findAll(Sort sort);

테스트를 실행한다. (수정하면 항상 테스트를 실행해야 한다. 잊지말자!)