본격적으로 게시판 CRUD 기능을 만드려 한다.
필요한 영역들은 다음과 같다
@Getter
@NoArgsConstructor
@Entity
@Table(name = "posts")
public class Posts extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto increment
private Long id;
@Column(length = 500, nullable = false)
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
private String author;
@Builder
public Posts(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
public void update(String title , String content) {
this.title = title;
this.content = content;
}
}
이 Posts클래스는 실제 DB테이블과 매칭되는 클래스이다.
JPA를 사용할 시, DB데이터에 실제 쿼리를 날려 작업하기보다는, 이 Posts라는 엔티티 클래스의 수정을 통해 작업한다
빌더를 사용시, 어느 필드에 어떤 값을 채워야 할지 명확히 지정할 수 있다.
public Posts toEntity() {
return Posts.builder()
.title(title)
.content(content)
.author(author)
.build();
}
Spring data Jpa를 사용한다면 JPA의 Entity Manager가 기본으로 활성화 된다. 이때 한 트랜잭션 내에서 데이터베이스에서 데이터를 가져오면, 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 해당 데이터의 값을 변경하면, 트랜잭션이 끝나는 시점에 데이터베이스의 해당 테이블에 변경분을 반영한다.
이는 즉슨, 엔티티 객체의 값을 변경하는 것이 Update 쿼리를 날리는 것과 같은 효과를 준다는 것이다.
다시 반복해서 설명하자면,
트랜잭션 시작
엔티티 조회
엔티티의 값 변경
트랜잭션 커밋
순서로 더티체킹(=상태 변화 감지)이 이루어진다.
이 더티체킹은 엔티티 조회 시 스냅샷 생성 → 트랜잭션 끝나는 시점에, 스냅샷과 비교하여 다른점이 있다면 Update 쿼리 전달 의 구조로 이루어진다.
public interface PostsRepository extends JpaRepository<Posts,Long> { // JpaRepository<Entity, Pk클래스> 를 상속받으면 CRUD메소드 자동 생성
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
}
DB레이어에 접근하는 Repository이다. MyBatis등에서는 Dao(Data Access Object)라고 부른다.
주석에 쓰여있듯, 인터페이스 생성 후 JpaRepostiroy<Entity클래스, Pk클래스> 를 상속받으면 CRUD메소드가 자동 생성된다.
제공하는 메서드들은 다음과 같다
Iterable<S> saveAll(Iterable<S> entities)
List<S> findAll(Example<S> example)
문서를 확인하면, 이보다 더 다양한 데이터 조작 메서드들이 있음을 알 수 있다. 필요한 것을 사용하면 될 듯하다.
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
@PostMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById (@PathVariable Long id) {
return postsService.findById(id);
}
@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}
}
@RequiredArgsConstructor // 생성자로 Bean을 주입받는다
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
@Transactional
public Long update(Long id, PostsUpdateRequestDto requestDto) {
Posts posts = postsRepository.findById(id).orElseThrow
(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
posts.update(requestDto.getTitle(), requestDto.getContent());
return id;
}
@Transactional
public void delete(Long id) {
Posts posts = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
// 존재하는 Posts인지 확인하기 위해 엔티티 조회 후 삭제
postsRepository.delete(posts);
}
public PostsResponseDto findById(Long id) {
Posts entity = postsRepository.findById(id).
orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id =" + id));
return new PostsResponseDto(entity);
}
@Transactional(readOnly = true) //트랜젝션 범위는 유지하나, 조회 기능만 남겨서 조회 속도를 개선함
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream()
.map(posts -> new PostsListResponseDto(posts))
.collect(Collectors.toList());
}
}
Stream?
데이터의 흐름
PostsSaveRequestDto
, PostsUpdateRequestDto
, PostsResponseDto
, PostsListResponseDto
총 네개의 dto가 필요하다@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {
private String title;
private String content;
private String author;
@Builder
public PostsSaveRequestDto(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
public Posts toEntity() {
return Posts.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
⇒ Update는 저자가 같은 상황에서 제목과 내용을 바꾸는 것이기에 Dto의 코드가 약간 다르다, 이처럼 dto는 용도에 따라 변경이 자주 일어난다.
@Getter
public class PostsResponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
Service에서 entity를 찾아서 매개변수로 넣어준다. 굳이 생성자가 필요하지 않고, 값을 리턴해주면 되기에 Entity를 받아서 처리한다.
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@After // 단위 테스트 끝난 이후
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void PostSave_findAll() {
//given
String title = "테스트 게시글";
String content = "테스트 본문";
/* save 메서드로 insert/update 쿼리 실행 : id 값 있으면 update, 없으면 insert */
postsRepository.save(Posts.builder() // .필드(값) ... build()
.title(title)
.content(content)
.author("test@gmail.com")
.build());
//when
List<Posts> postsLists = postsRepository.findAll();
//then
Posts posts = postsLists.get(0);
assertThat(posts.getTitle()).isEqualTo(title);
assertThat(posts.getContent()).isEqualTo(content);
}
@Test
public void BaseTimeEntity_등록() {
//given
LocalDateTime now = LocalDateTime.of(2019,6,4,0,0,0);
postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
//when
List<Posts> postsList = postsRepository.findAll();
//then
Posts posts = postsList.get(0);
System.out.println(">>>>>> createdDate = " + posts.getCreatedDate()+ ", modifiedDate = " + posts.getModifiedDate());
assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}
}
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@Before
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@After
public void tearDown() throws Exception {
postsRepository.deleteAll();
}
@Test
@WithMockUser(roles="USER") // MockMVC에서만 작동
public void posts_등록() throws Exception {
//given
String title = "title";
String content = "content";
PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
.title(title)
.content(content)
.author("author")
.build();
String url = "http://localhost:" + port + "api/v1/posts";
//when
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
//then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(title);
assertThat(all.get(0).getContent()).isEqualTo(content);
}
@Test
@WithMockUser(roles="USER")
public void posts_수정() throws Exception {
// given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savedPosts.getId();
String expectedTitle = "title2";
String expectedContent = "content2";
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
// when
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
// then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
MockMvcBuilders로 mock인스턴스를 생성하기 위해서는 webAppContextSetup 혹은 standaloneSetup 을 세팅해주어야 한다
public static DefaultMockMvcBuilder webAppContextSetup(WebApplicationContext context) {
return new DefaultMockMvcBuilder(context);
}
주어진 WebApplicationContext를 이용하여 MockMvc생성
public static StandaloneMockMvcBuilder standaloneSetup(Object... controllers) {
return new StandaloneMockMvcBuilder(controllers);
}
Build a MockMvc instance by registering one or more @Controller instances and configuring Spring MVC infrastructure programmatically.
자의적으로 컨트롤러와 구성을 선택하여 mock 인스턴스를 생성한다는 것 같다.
그래서 WebApplicationContext가 무엇인가? 라 한다면..
Context = 빈들이 담겨있는 컨테이너
웹 어플리케이션에 Configuration을 제공하기 위한 인터페이스
ApplicationContext 인터페이스를 상속받아 getServletContext() 메서드를 더하였다.
Mock인스턴스를 생성하기 위하여 어플리케이션 구성정보를 제공하는 역할이라고 이해했다..