[3장] 스프링 부트에서 JPA로 데이터베이스 다뤄보자
JPA: 자바 표준 ORM
- ORM: 객체를 매핑
- SQL Mapper: 쿼리를 매핑
패러다임 불일치
- 관계형 데이터베이스: 어떻게 데이터를 저장하는지에 초점
- 객체지향 프로그래밍: 기능과 속성을 한 곳에서 관리하는 기술
- => 서로의 패러다임이 다른데 객체를 DB에 저장하려니 문제 발생
👉 JPA: 객체지향 프로그래밍 언어와 관계형 데이터베이스 중간에서 패러다임 일치를 시켜주는 기술
- SQL에 종속적인 개발을 하지 않을 수 있게 됨
- 객체 중심으로 개발 => 생산성 향상 & 유지 보수 용이해짐
Spring Data JPA
JPA는 인터페이스이기 때문에 구현체가 필요함
- Hibernate
- Eclipse Link
하지만, Spring에서 JPA를 사용할 때는 이 구현체들을 직접 다루지는 않음
구현체들을 좀 더 쉽게 사용할 수 있도록 추상화시킨 Spring Data JPA라는 모듈을 이용해서 JPA 기술을 다룸
- JPA ← Hibernate ← Spring Data JPA
Hibernate와 큰 차이가 없지만 Spring Data JPA를 만들어 사용을 권장하는 이유
- 구현체 교체의 용이성 : Hibernate 외에 다른 구현체로 쉽게 교체하기 위함
- 저장소 교체의 용이성 : 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함
게시판 만들기
Spring Data JPA 적용
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2'
build.gradle에 JPA와 h2 데이터베이스 의존성 추가
- spring-boot-starter-data-jpa
- 스프링부트용 Spring Data Jpa 추상화 라이브러리
- 스프링부트 버전에 맞춰서 자동으로 JPA관련 라이브러리들의 버전을 관리해줌
- h2
- 인메모리 관계형 데이터베이스
- 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됨
Posts 클래스
package com.example.techeerteama1.springboot.domain.posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@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;
}
}
- @Entity
- JPA의 어노테이션
- 테이블과 링크될 클래스임을 나타냄
- 카멜케이스 => 언더스코어 네이밍 (SalesManager => sales_manager) 기본적으로 해줌
- @Getter
- 롬복의 어노테이션
- 클래스 내 모든 필드의 Getter 메소드를 자동생성
- @NoArgsConstructor
- 롬복의 어노테이션
- 기본 생성자 자동 추가
- @Id
- 해당 테이블의 PK 나타냄
- @GenerationValue
- PK의 생성 규칙을 나타냄
- GenerationType.AUTO 가 기본값
- identity와 auto의 차이점
- JPA는 영속성 컨텍스트에서 객체를 관리하기 위해서는 무조건 PK값이 있어야 함
- (KEY값으로 해당 객체의 값을 넣기 때문)
- auto의 경우, 애플리케이션에서는 그 값이 얼마인지 알지 못하기 때문에 DB에 insert문을 날려야만 id(PK)값을 알 수 있음
- 하지만, identity의 경우, 기본키 생성을 DB에 위임하기 때문에 id가 null일 경우 해당 객체의 Id를 DB의 auto_increment를 가져와서 할당함
- @Column
- 테이블의 칼럼
- 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 됨
- 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용하기 위함
- @Builder
- 해당 클래스의 빌더 패턴 클래스를 생성
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
📎 @Setter는 하지 않는 이유
- 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수 없어, 차후 변경 시 매우 복잡해짐
- 따라서, Entity 클래스에서는 절대 Setter 메소드를 만들지 않음
PostsRepository 인터페이스
package com.example.techeerteama1.springboot.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
- JpaRepository: Posts 클래스로 Database를 접근하게 해줌
- 인터페이스 생성 + JpaRepository<Entity 클래스, PK타입>을 상속하면 기본적인 CRUD 메소드가 자동 생성됨
- Entity 클래스와 기본 Repository 인터페이스는 함께 위치해야 함!
Spring Data JPA 테스트 코드 작성하기
PostsRepositoryTests 클래스
package com.example.techeerteama1.springboot.domain.posts;
import static org.assertj.core.api.Assertions.assertThat;
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.test.context.junit.jupiter.SpringExtension;
import java.util.List;
@ExtendWith(SpringExtension.class)
@SpringBootTest
class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@AfterEach
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void 게시글저장_불러오기() {
//given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("yeonjy@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);
}
}
책에는 JUnit4를 써서인지 @After으로 나와있는데 JUnit5에서는 @AfterEach라고 한다.
- @AfterEach
- JUnit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
- 보통 테스트간 데이터 침범을 막기 위해 사용
- 여러 테스트가 동시에 수행되면 H2에 데이터가 그대로 남아있어서 다음 테스트 실행 시 테스트가 실패할 수도 있음
- postsRepository.save
- 테이블 posts에 insert/update 쿼리를 실행
- id값이 있다면 update, 없다면 insert 쿼리가 실행됨
- postsRepository.findAll
- 테이블 posts에 있는 모든 데이터를 조회
- @SpringBootTest
- H2 데이터베이스 자동으로 실행됨

📎 실제로 실행된 쿼리의 형태는?
- 쿼리 로그 ON/OFF 가능
- Java 클래스로 구현 가능하지만, 스프링 부트에서는 아래의 방법 권장
- application.properties 등의 파일로 한 줄의 코드로 설정
resources 디렉토리에 해당 파일 생성 후 아래의 코드 입력
spring.jpa.show_sql=true


이후 다시 테스트를 실행하면 위와 같은 쿼리 로그 확인 가능
id bigint generated by default as identity라는 옵션으로 create table된다.
이는 H2의 쿼리 문법이 적용되었기 때문!
=> H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 떄문에 이후 디버깅을 위해서 출력되는 쿼리 로그를 MySQL버전으로 변경 => 아래의 코드 추가
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
🧨위의 코드를 추가하고 다시 테스트를 실행하니 아래와 같은 오류가 발생했다.
org.springframework.dao.InvalidDataAccessResourceUsageException: could not prepare statement;
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "POSTS" not found (this database is empty);
https://github.com/jojoldu/freelec-springboot2-webservice/issues/67#issuecomment-832692299
P.100 properties 추가 시 작동 오류 · Issue #67 · jojoldu/freelec-springboot2-webservice
안녕하세요~ 조졸두님 책으로 공부하고 있는 베니라고 합니다! 100 페이지에서 application.properties에 MySQL5InnoDBDialect 추가 시 밑에 이미지와 같이 나오고 있습니다! 그리고 해당 클래스를 보면 /** A D
github.com
위의 코드를 그대로 입력했더니 auto_increment 로그가 뜨면서 테스트도 통과했다.

등록/수정/조회 API 만들기
API를 만들기 위해 필요한 클래스 3개
- Request 데이터를 받을 DTO
- API 요청을 받을 Controller
- 트랜잭션 도메인 기능 간의 순서를 보장하는 Service
📎Service에서 비지니스 로직 처리 X
Service는 트랜잭션, 도메인 간 순서 보장 역할만!!
비지니스 로직을 처리하는 곳은 Domain!!
PostsApiController
package com.example.techeerteama1.springboot.web;
import com.example.techeerteama1.springboot.service.posts.PostsService;
import com.example.techeerteama1.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 requestDto) {
return postsService.save(requestDto);
}
}
PostsService
package com.example.techeerteama1.springboot.service.posts;
import com.example.techeerteama1.springboot.domain.posts.PostsRepository;
import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
}
📎 Controller와 Service에서 @Autowired를 안 쓴 이유
- 스프링에서 Bean을 주입받는 방식
- @Autowired
- setter
- 생성자
👉 가장 권장하는 방식은 생성자로 주입받는 방식!!
=> 생성자는 어디있나?!
@RequiredArgsConstructor가 final이 선언된 모든 필드의 인자값으로 하는 생성자 생성해줌!
PostsSaveRequestDto
package com.example.techeerteama1.springboot.web.dto;
import com.example.techeerteama1.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();
}
}
📎 Entity와 거의 유사한 형태임에도 Dto 클래스를 생성한 이유
- Entity 클래스는 절대 Request/Response 클래스로 사용해서는 안 됨
- Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스
- 화면 변경은 아주 사소한 기능인데, 이를 위해 테이블과 연관된 Entity 클래스를 변경하는 것은 너무 큰 변경
- Entity 클래스는 많은 클래스와 연관되어있음 => Request/Response용 DTO는 View를 위한 클래스이기 때문에 자주 변경될 것
- 따라서, View Layer와 DB Layer의 역할 분리를 철저히 하는 것 권장
HelloApiControllerTests
package com.example.techeerteama1.springboot.web;
import com.example.techeerteama1.springboot.domain.posts.Posts;
import com.example.techeerteama1.springboot.domain.posts.PostsRepository;
import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto;
import static org.assertj.core.api.Assertions.assertThat;
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.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;
import java.util.List;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@AfterEach
public void tearDown() throws Exception {
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);
}
}
HelloControllerTest와 달리, @WebMvcTest를 사용하지 않은 이유는 @WebMvcTest가 JPA 기능이 작동하지 않기 때문
=> JPA 기능까지 한번에 테스트할 때는 @SpringBootTest를 사용

수정/조회 기능 구현
PostsApiController
package com.example.techeerteama1.springboot.web;
import com.example.techeerteama1.springboot.service.posts.PostsService;
import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@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);
}
}
PostsResponseDto
package com.example.techeerteama1.springboot.web.dto;
import com.example.techeerteama1.springboot.domain.posts.Posts;
import lombok.Getter;
@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();
}
}
PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣음
굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리함
PostsUpdateRequestDto
package com.example.techeerteama1.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;
}
}
Posts 클래스에 추가
public void update(String title, String content) {
this.title = title;
this.content = content;
}
PostsService 클래스에 추가
@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;
}
public PostsResponseDto findById(Long id) {
Posts entity = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
return new PostsResponseDto(entity);
}
}
- update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없음
- JPA의 영속성 컨텍스트 때문에 가능
- 영속성 컨텍스트: 엔티티를 영구 저장하는 환경; 일종의 논리적 개념
- JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태임
- 이 상태에서 해당 데이터를 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영
- = dirty checking 더티 체킹
- JPA의 영속성 컨텍스트 때문에 가능
PostsApiController에 추가
@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();
String url = "http://localhost: " + port + "/api/v1/posts/" + updateId;
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
//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).getContent()).isEqualTo(expectedContent);
}