# JPA, MYSQL 연결
책에서는 H2 Database를 사용하지만, 이미 MYSQL이 깔려있으므로 굳이 h2를 다시 깔 필요가 없어서 MYSQL을 사용했다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'mysql:mysql-connector-java'
build.gradle에 이렇게 추가해준다.
그리고 교재에는 따로 h2 연결 설정을 해주지 않던데... 원래 이래도 되는지는 모르겠으나(김영한님 강좌에서는 h2 설정을 따로 해줬었다), MYSQL은 설정을 해줘야하므로 설정해줬다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/springaws
username: root
password: ???
jpa:
hibernate:
ddl-auto: create
show-sql: true
특히 ddl-auto를 create로 해주지 않으니까 MYSQL은 기본설정이 none이라 Table이 없다는 에러가 떴었다.
## Posts 엔티티 추가
package com.thuthi.springboot.domain.posts;
import com.thuthi.springboot.domain.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
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(Long id, String title, String content, String author) {
this.id = id;
this.title = title;
this.content = content;
this.author = author;
}
}
columnDefinition은 처음 써보는데... inferred된 type이 아닌 수동으로 타입을 지정해줄 때 사용한다고 한다.
## SpringDataJpa 사용 및 테스트 코드 작성
package com.thuthi.springboot.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
package com.thuthi.springboot.domain.posts;
import static org.assertj.core.api.Assertions.*;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTes t;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@SpringBootTest
class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@AfterEach
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void 게시글저장_불러오기() throws Exception {
// given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("whquddn55@gmail.com")
.build());
// when
List<Posts> postsList = postsRepository.findAll();
// then
Posts posts = postsList.get(0);
assertThat(posts.getTitle()).isEqualTo(title);
assertThat(posts.getContent()).isEqualTo(content);
}
}
# 등록/수정/조회 API 만들기
결정적으로 내가 기초부터 다시 시작하려는 이유 중에 하나다.
이전 프로젝트에서 내가 짠 코드들을 보면 모두 Service Layer에 비지니스 로직이 들어가 있음을 알 수 있다.
Service Layer는 여러 도메인들의 비지니스 로직의 순서를 정해주는 그 이상도 이하도 아닌 점을 명심하자.
핵심 비지니스 로직은 모두 도메인에 들어있어야한다!
## 등록 API 만들기
package com.thuthi.springboot.web;
import com.thuthi.springboot.service.posts.PostsService;
import com.thuthi.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(
@RequestBody PostsSaveRequestDto postsSaveRequestDto) {
return postsService.save(postsSaveRequestDto);
}
}
package com.thuthi.springboot.service.posts;
import com.thuthi.springboot.domain.posts.Posts;
import com.thuthi.springboot.domain.posts.PostsRepository;
import com.thuthi.springboot.web.dto.PostsSaveRequestDto;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
@Transactional
public class PostsService {
private final PostsRepository postsRepository;
public Long save(PostsSaveRequestDto postsSaveRequestDto) {
return postsRepository.save(postsSaveRequestDto.toEntity()).getId();
}
}
package com.thuthi.springboot.web.dto;
import com.thuthi.springboot.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@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();
}
}
package com.thuthi.springboot.web;
import static org.assertj.core.api.Assertions.assertThat;
import com.thuthi.springboot.domain.posts.Posts;
import com.thuthi.springboot.domain.posts.PostsRepository;
import com.thuthi.springboot.web.dto.PostsSaveRequestDto;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@AfterEach
void tearDown() {
postsRepository.deleteAll();
}
@Test
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
ResponseEntity<Long> responseEntity = restTemplate
.postForEntity(url, requestDto, Long.class);
// then
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(title);
assertThat(all.get(0).getContent()).isEqualTo(content);
}
}
저번 장에서는 WebMvcTest로 테스트를 진행했는데, 해당 어노테이션은 JPA 기능이 작동하지 않고, 오직 Controller와 ControllerAdvice등 외부 연동과 관련된 부분만 활덩화 되기 때문에 JPA를 테스트하려면 사용할 수 없다.
따라서 TestRestTemplate을 이용해서 테스트 해야한다.
## 수정/조회 기능 추가
public class PostsApiController {
private final PostsService postsService;
@PutMapping("/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);
}
}
package com.thuthi.springboot.web.dto;
import com.thuthi.springboot.domain.posts.Posts;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class PostsResponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsResponseDto(Posts posts) {
this.id = posts.getId();
this.title = posts.getTitle();
this.content = posts.getContent();
this.author = posts.getAuthor();
}
}
package com.thuthi.springboot.web.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
public class Posts extends BaseTimeEntity {
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
public class PostsService {
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;
}
public PostsResponseDto findById(Long id) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
return new PostsResponseDto(posts);
}
}
## 수정/조회 테스트 추가
class PostsApiControllerTest {
@Test
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();
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
// when
ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
// then
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
@Test
public void Posts_가져온다() throws Exception {
// given
String title = "title";
String content = "content";
String author = "author";
Posts savedPosts = postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author(author)
.build());
Long id = savedPosts.getId();
String url = "http://localhost:" + port + "/api/v1/posts/" + id;
// when
ResponseEntity<PostsResponseDto> postsResponseDtoResponseEntity = restTemplate.getForEntity(url, PostsResponseDto.class, id);
// then
assertThat(postsResponseDtoResponseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(postsResponseDtoResponseEntity.getBody()).isNotNull();
assertThat(postsResponseDtoResponseEntity.getBody().getId()).isEqualTo(id);
assertThat(postsResponseDtoResponseEntity.getBody().getTitle()).isEqualTo(title);
assertThat(postsResponseDtoResponseEntity.getBody().getContent()).isEqualTo(content);
assertThat(postsResponseDtoResponseEntity.getBody().getAuthor()).isEqualTo(author);
}
}
책과 다르게 GET /api/v1/posts/{id} 테스트 코드도 작성했는데, 조금 문제가 있다.
책에서는 PostsResponseDto에 기본 생성자(NoArgs, AllArgs)를 정의하지 않고 Entity를 받아서 내부적으로 변환하는 생성자만 존재한다.
그런데 restTemplate에서 리턴값을 계산할 때 Jackson 라이브러리를 사용하게 되고, Jackson 라이브러리는 Java의 Reflection 기술을 사용해서 필드명들을 알아내게 된다. 이 기술을 사용하려면 기본 생성자가 반드시 1개 이상 필요하므로 에러가 나게 된다.
따라서 PostsResponseDto에 @NoArgsConstructor어노테이션을 추가해줬다.
# JPA Auditing으로 생성시간/수정시간 자동화하기
Java8에서 추가된 LocalDate, LocalDateTime은 이전에 모던 자바 인 액션으로 공부한 경험이 있으므로 생략.. 하지 말고 다시 한 번 상기해보면,
- Java8이전의 Date와 Calendar클래스는 불변객체가 아니라 멀티스레딩 환경에서 문제 발생 여지가 크다.
- Calendar의 Month 값 설계가 잘 못 되었다(10월을 나타내는 value가 9임...)
package com.thuthi.springboot.domain;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import lombok.Getter;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
public class Posts extends BaseTimeEntity {
...
}
class PostsRepositoryTest {
@Test
public void BaseTimeEntity_등록() throws Exception {
// 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(">>>>>>>>>>>> createdAt=" + posts.getCreatedAt() + ", updatedAt=" + posts.getUpdatedAt());
assertThat(posts.getCreatedAt()).isAfter(now);
assertThat(posts.getUpdatedAt()).isAfter(now);
}
}
@EnableJpaAuditing
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
BaseTimeEntity 클래스에 보면 처음보는 어노테이션이 많아서 한 번 정리해보았다.
- @MappedSuperclass
- JPA Entity 클래스가 해당 클래스를 상속받으면 해당 클래스의 모든 필드들을 Column으로 인식하도록 한다.
- @CreatedDate & @LastModifiedDate
- springframework.data.annotation 패키지에서 불러온다
- Spring에서 실행된다(Spring Auditing 기술이다)
- 객체가 생성되거나 수정될 때 입력된다.
- microseconds 단위로 입력된다.
- 사용자가 활성화 하고 설정해야한다(@EnableJpaAuditing, @EntityListeners.class)
- @CreatedTimestamp & @UpdateTimestamp
- hibernate.annotation 패키지에서 불러온다.
- DB로 넘어갈 때 실행된다(JPA(hibernate)가 실행한다)
- milliseconds 단위로 입력된다.
저번에 했던 프로젝트를 보면 @CreatedDatedDate와 @UpdateTimestamp를 쓴 것을 알 수 있다... 두 기술을 굳이 섞어쓴.. ㅠㅜ