학습노트3 - JPA

JPA 란?

Database 접근을 자바에서 객체 지향으로 할수 있도록 하는 기술
이를 ORM 기술이라고 하고 JPA는 자바 진영의 ORM 표준 이름
Spring 에서는 Hiberate -> Spring Data JPA -> 으로 사용 하게 된다

MyBatis

SQL Mapping 기술
DAO 작성 해서 사용

Gradle에 library 추가

implementation ‘org.springframework.data:spring-data-jpa’
implementation ‘com.h2database:h2’

도메인 구조

Domain 패키지를 새로 만들고 안에 Entity와 Repository을 같이 작성 할 것이다.
(더 공부 하려면 최범균 DDD Start 책을 참고 할수 있다고 한다.)
Entity는 DB 테이블 이라고 보면 되고 클래스로 만들어 줘야 한다.
Repository는 DB에 접근해주는기 위한 셋팅 이고 interface로 만들어 줘야 한다.

1. Posts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Getter
@NoArgsConstructor
@Entity
public class Posts {

@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(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;
}
}

public interface PostsRepository extends JpaRepository<Posts, Long> {

}

테스트 코드

  • @SpringBootTest 시 별다른 설정 없으면 H2 데이터베이스 사용 된다
  • deleteAll(), findAll()
  • save(): insert 혹은 update SQL 호출
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

@Autowired
PostsRepository postsRepository;

@After
public void cleanup() {
postsRepository.deleteAll();
}

@Test
public void 게시글저장_불러오기() {
//given
String title = "테스트 게시글";
String content = "테스트 본문";

postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("miromike@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);
}
}

쿼리 확인을 위해 쿼리 로그 열기

  • 이런 설정은 원래 자바 코드 작성 해서 설정 할수 있으니 스프링 부트 에선 application.properties 나 application.yml 으로 설정 가능

spring.jpa.show_sql=true

1
2
3
4
5
6
Hibernate: create table posts (id bigint generated by default as identity, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id))

Hibernate: insert into posts (id, author, content, title) values (default, ?, ?, ?)
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: delete from posts where id=?

H2 문법 -> MySQL로 변경

책: spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

최신 Spring boot 버전 사용시 옵션 아래로 변경 필요

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.hikari.username=sa

1
2
Hibernate: drop table if exists posts
Hibernate: create table posts (id bigint not null auto_increment, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB

등록, 수정, 조회 API 만들기

3개의 클래스 필요

  1. Request 데이터를 받을 DTO
  2. API 요청을 받을 Controller
  3. 트랜잭션, 도메인 기능 간의 순서를 보장할 Service

비지니스 로직은 서비스가 아닌 도메인에 넣는다.
서비스 @Transactional 사용 하고 트랜잭션 스크립트 화 한다.
서비스 메소드는 트랜잭션과 도메인간의 순서만 보장하는 방식으로 한다.

등록 기능 만들기 (책에 오타)

오타 부분: Controller 에 @GetMapping 이 아니고 @PostMapping 이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@RequiredArgsConstructor
@RestController
public class PostsApiController {

private final PostsService postsService;

@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto)
{
return postsService.save(requestDto);
}
}

@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;

@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();

}

}

@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();
}
}

테스트

  • JPA 까지 물려서 써야 함으로 mvctest 안쓰고 대신 SprintBootTest 와 TestRestTemplate을 사용 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

@Autowired
private TestRestTemplate restTemplate;

@Autowired
private PostsRepository postsRepository;

@LocalServerPort
private int port;

@After
public void cleanup() throws Exception
{
postsRepository.deleteAll();
}

@Test
public void 등록_테스트() throws Exception
{
//given
String url = "http://localhost:" + port + "/api/v1/posts";
String title = "test title";
String content = "test content";

PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
.title(title)
.content(content)
.author("test author")
.build();

//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);
}
}

수정, 조회

Controller 에서 서비스 부르는 부분
서비스 에서 Repository 부르는 부분
결과나 반환 데이타를 위해 DTO 추가

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@RequiredArgsConstructor
@RestController
public class PostsApiController {

private final PostsService postsService;

@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto)
{
return postsService.save(requestDto);
}

@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}

@DeleteMapping("/api/v1/posts/{id}")
public Long delete(@PathVariable Long id) {
postsService.delete(id);
return id;
}

@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById(@PathVariable Long id) {
return postsService.findById(id);
}

@GetMapping("/api/v1/posts/list")
public List<PostsListResponseDto> findAll() {
return postsService.findAllDesc();
}

}
  • @Transactional 이 메서드에 없으면 SQL을 호출 하지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RequiredArgsConstructor
@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 post = postsRepository.findById(id)
.orElseThrow(()-> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

post.update(requestDto.getTitle(), requestDto.getContent());

return id;
}
  • 업데이트 하는데 exchange 을 써야 해서 좀 복잡해진다.
  • BODY 에 업데이트 할 DATA을 넣어 보낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Test
public void 업데이트_테스트() throws Exception
{
//given
Posts post = postsRepository.save(Posts.builder()
.title("test title")
.content("test content")
.author("test author")
.build());
Long id = post.getId();
String updatedTitle = "updated Title";
String updatedContent = "updated Content";

PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(updatedTitle)
.content(updatedContent)
.build();

String url = "http://localhost:" + port + "/api/v1/posts/" + id;

HttpEntity<PostsUpdateRequestDto> requestEntity =
new HttpEntity<>(requestDto);

//when
ResponseEntity<Long> responseEntity =
restTemplate.exchange(url, HttpMethod.PUT, (HttpEntity<?>) 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(updatedTitle);
assertThat(all.get(0).getContent()).isEqualTo(updatedContent);
}

데이타베이스 H2 접속 해서 보기

  1. application.resources에 spring.h2.console.enabled-true
  2. http://localhost:8080/h2-console 로 접속 하고 JDBC URL: jdbc:h2:mem:testdb

등록된 글 확인

  1. SELECT * FROM POSTS ;
  2. insert into posts (author, content, title) values (‘author’, ‘content’, ‘title’);
  3. http://localhost:8080/api/v1/posts/1

406 에러가 발생, 디버깅 해보니 PostsResponseDto.java 에 @Getter 가 없었음

Getter 가 없는데 406 Not Acceptable 가 나오다니, 디버깅하기 어려움

JPA Audit 으로 생성 시간/ 수정 시간 자동화 하기

생성 및 수정 시간을 자동으로 넣어 주는 기능 으로 abstract class를 아래 처럼 구현

1
2
3
4
5
6
7
8
9
10
11
12
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

@CreatedDate
private LocalDateTime createdDate;

@LastModifiedDate
private LocalDateTime modifiedDate;

}

그리고 최 상위 Application 에 @EnableJpaAuditing 추가
@EnableJpaAuditing
@SpringBootApplication
public class Application {

그리고 적용 할 Entity에 상속
public class Posts extends BaseTimeEntity

테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void BaseTimeEntity_테스트() {
//given
LocalDateTime now = LocalDateTime.of(2022, 5, 15, 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(">>>>>>>>> createDate=" + posts.getCreatedDate() + ", modifiedDate=" + posts.getModifiedDate());

assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}