{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스 No. 3</h1>
{{>layout/footer}}
View Resolver
Mustache starter로 인한 View Resolver가 하는 일
컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
prefix: src/main/resources/templates
suffix: .mustache
index를 반환하는 경우 'src/main/resources/templates/index.mustache'로 전환
IndexControlerTest
기본 페이지 테스트 코드
restTemplate.getForObject("/")를 호출하는 경우 index.mustache에 포함된 코드 확인
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void loadMainPage() {
// when
String body = this.restTemplate.getForObject("/", String.class);
// then
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
}
}
SpringBoot 1.x 버전을 사용하는 경우 Hibernate 5.2.10 버전 이상을 사용하도록 설정 필요
SpringBoot 2.x 버전을 사용하는 경우 별다른 설정이 없이 바로 적용가능
BaseTimeEntity
BaseTimeEntity 클래스
domain 패키지에 생성
모든 Entity의 상위 클래스로 사용하여 createDate, modifiedDate를 자동으로 관리
@MappedSuperclass
JPA Entity 클래스들이 BaseTimeEntity를 사용할 경우 필드들(createDate, modifiedDate)도 컬럼으로 인식하도록 한다.
@EntityListeners(AuditingEntityListener.class)
BaseTimeEntity 클래스에 Auditing 기능을 포함
@CreatedDate
Entity가 생성되어 저장될 때 자동 저장
@LastModifiedDate
조회한 Entity의 값을 변경할 때 시간이 자동 저장
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
domain 클래스에서 BaseTimeEntity 상속
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
...
}
JPA Auditing 어노테이션들을 활성화 할 수 있도록 Application 클래스에 활성화 어노테이션 추가
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String\[\] args) {
SpringApplication.run(Application.class, args);
}
}
JPA Auditing 테스트 코드
PostsRepositoryTest 클래스
LocalDateTime.of()로 날짜 설정
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup() {
postsRepository.deleteAll();
}
// getBoard
@Test
public void regBaseTimeEntityTest() {
// given
LocalDateTime now = LocalDateTime.of(2020,5,28,19,28,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);
}
}
createDate, modifiedDate 확인으로 정상적으로 JPA Auditing이 적용되었음을 알 수 있다.
앞으로 생성되는 Entity들은 BaseTimeEntity를 상속받아 등록일/수정일을 자동화할 수 있다.
생성자로 Bean을 주입받아 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함
해당 컨트롤러에 새로운 서비스를 추가하거나, 기존 컴포넌트를 제거하는 등의 상황이 발생해도 생성자 코드를 수정하지 않아도 된다.
@RequiredArgsConstructor
@RestController
public class PostsAPIController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
service/PostsService
Service Layer
트랜잭션
도메인 간 순서 보장 (@Transactional)
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto) {
return postsRepository.save(requestDto.toEntity()).getId();
}
}
PostsControllerTest
Posts.save 테스트
@WebMvcTest 대신 @SpringBootTest와 TestRestTemplate을 사용
@WebMvcTest는 JPA 기능이 작동하지 않는다.
@SpringBootTest와 TestRestTemplat을 통해 Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 사용
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsAPIControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@After
public void tearDown() throws Exception {
postsRepository.deleteAll();
}
@Test
public void testSave() {
// 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);
}
}
로그 확인
WebEnvironment.RANDOM_PORT를 통해 tomcat의 포트가 3421로 구동된 것을 확인
insert 쿼리 실행 확인
Update / findById API 만들기
PostsResponseDto
PostsReponseDto
Entity의 필드 중 일부를 사용하므로 생성자로 Entity를 받아 필드에 대입
모든 필드를 가진 생성자가 필요하지 않으므로 Dto는 Entity를 받아 처리
@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();
}
}
PostsAPIController
PostsAPIController
Posts의 id값과 update할 PostsUpdateRequestDto 값을 json 데이터 타입으로 전달하여 호출
해당 Posts의 값을 조회하기 위한 findById 메서드를 정의
@RequiredArgsConstructor
@RestController
public class PostsAPIController {
private final PostsService postsService;
// save
@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);
}
}
PostsService
PostsService
update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.
JPA의 영속성 컨텍스트, 엔티티를 영구 저장하는 환경
JPA의 엔티티 매니저가 활성화된 상태(Spring Data JPA의 기본 옵션)로 트랜잭션안에서 데이터베이스에서 데이터를 가져오는 경우 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영
Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다. (더티 체킹)
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
...
@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);
}
}
PostsAPIControllerTest
PostsAPIControllerTest
Update 기능을 테스트 하기 위해서 savePosts를 통해 Insert 쿼리 호출
Update할 값을 설정하여 PostsUpdateRequestDto를 통해 데이터를 만들기
restTemplate.exchange(url.toString(), HttpMethod.PUT, requestEntity, Long.class)로 update 실행
assertThat을 통해 정상 호출 확인
postsRepository.findAll()를 이용하여 데이터 호출
입력된 값이 기대값과 같은지 확인 후 완료
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsAPIControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@After
public void tearDown() throws Exception {
postsRepository.deleteAll();
}
// save
@Test
public void testUpdate() {
// 변경하기 전 데이터 입력
Posts savePosts = postsRepository.save(
Posts.builder()
.title("title")
.content("content")
.author("author")
.build()
);
Long updateId = savePosts.getId();
String exceptedTitle = "title2";
String exceptedContent = "content2";
// 데이터 변경을 위한 Dto 생성
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(exceptedTitle)
.content(exceptedContent)
.build();
StringBuilder url = new StringBuilder();
url.append("http://localhost:");
url.append(port);
url.append("/api/v1/posts/");
url.append(updateId);
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
// when
ResponseEntity<Long> responseEntity = restTemplate.exchange(url.toString(), 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(exceptedTitle);
assertThat(all.get(0).getContent()).isEqualTo(exceptedContent);
}
}
MyBatis등에서 Dao라 불리는 DB Layer로 JPA에서는 Repository라 부른다.
JpaRepositoy<Entity 클래스, PK 타입> 를 상속하면 기본적인 CRUD 메서드가 자동으로 생성된다.
@Repository를 추가할 필요가 없다.
다만, Entity 클래스와 기본 Entity Repository 클래스는 같은 위치(domain 패키지)에 있어야 한다.
Entity 클래스는 기본 Repositorty 없이 제대로된 역할을 할 수 없다.
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
Spring Data JPA 테스트 코드 작성
PostsRepositoryTest 클래스
@SpringBootTest
해당 어노테이션을 선언하는 경우 H2 데이터베이스를 자동으로 실행
테스트도 H2 데이터베이스를 기반으로 실행된다.
@After
Junit에서 단위 테스트가 끝날 때마다 수행되는 메서드를 지정
보통 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해서 사용
여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트 실패할 수 있다.
postsRepository.save
테이블 posts에 insert/update 쿼리를 실행
id 값이 있다면 update, id 값이 없다면 insert 쿼리가 실행된다.
postsRepository.findAll
테이블 posts에 있는 모든 데이터를 조회해오는 메서드
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void getBoard() {
// given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(
Posts.builder()
.title(title)
.content(content)
.author("seok@gmail.com")
.build()
);
// when
List<Posts> postsList = postsRepository.findAll();
// then
Posts posts = postsList.get(0);
Assertions.assertThat(posts.getTitle()).isEqualTo(title);
Assertions.assertThat(posts.getContent()).isEqualTo(content);
}
}
쿼리로그 확인 방법
src/main/resources 경로의 application.properties 파일에 설정 추가
# jpa setting
spring.jpa.show_sql=true
create table 쿼리에 id bigint generated by default as identity라는 옵션으로 생성
이는 H2의 쿼리 문법이 적용되었기 때문이다.
H2 쿼리 문법에서 MySQL 버전으로 변경
# jpa setting
spring.jpa.show_sql=true
# change QueryLog format from h2 to mysql
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
build.gradle에 라이브러리 추가와 Enable annotation.processing체크는 프로젝트마다 설정해야 한다.
lombok으로 Refactoring
web 패키지에 dto 패키지를 추가
dto 패키지에 HelloResponseDto 추가
@Getter
선언된 모든 필드의 get 메서드를 생성
@RequiredArgsContructor
선언된 모든 final 필드가 포함된 생성자를 생성
final이 없는 필드는 생성자에 포함되지 않는다.
@Getter
@RequiredArgsConstructor
public class HelloResponseDto {
private final String name;
private final int amount;
}
lombok 테스트
테스트 코드
assertThat
assertj라는 테스트 검증 라이브러리의 검증 메서드
검증하고 싶은 대상을 메서드 인자로 받는다.
메서드 체이닝이 지원되어 isEqualTo와 같이 메서드를 이어서 사용할 수 있다.
isEqualTo
assertj의 동등 비교 메서드
assertThat에 있는 값과 isEqualTo의 값을 비교해서 같은 경우 성공
assetj vs Junit
CoreMatchers와 달리 추가적으로 라이브러리가 필요하지 않다.
Junit의 assertThat을 쓰게 되면 is()와 같이 CoreMatchers 라이브러리가 필요하다.
자동완성이 좀 더 확실하게 지원
IDE에서는 CoreMatchers와 같은 Matcher 라이브러리의 자동완성 지원이 약하다.
HelloResponseDtoTest
given, when, then의 순서로 테스트 코드를 작성
Given
HelloResponseDto 클래스에 생성자로 주입될 값을 설정
테스트 기반 환경을 구축하는 단계
When
HelloResponseDto 클래스 생성 및 초기화
테스트 하고자 하는 행위 선언
Then
테스트의 결과를 검증
assertThat 메서드를 통해 HelloResponseDto instance의 name, amount의 값을 확인
assertThat 성공으로 lombok의 @Getter를 통해 get 메서드, @RequiredArgsContructor로 생성자가 자동으로 생성
import org.assertj.core.api.Assertions;
import org.junit.Test;
public class HelloResponseDtoTest {
@Test
public void lombok_test() {
// Given
String name = "test";
int amount = 1000;
// when
HelloResponseDto dto = new HelloResponseDto(name, amount);
// then
Assertions.assertThat(dto.getName()).isEqualTo(name);
Assertions.assertThat(dto.getAmount()).isEqualTo(amount);
}
}
HelloController에 ResponseDto 사용
HelloController
@RequestParam
외부에서 API로 넘긴 파라미터를 가져오는 어노테이션
name (@RequestParam("name"))이란 이름으로 넘긴 파라미터를 메서드 파라미터 name(String name)에 저장
@RestController
public class HelloController {
...
@GetMapping("/hello/dto")
public HelloResponseDto helloDto(
@RequestParam("name") String name,
@RequestParam("amount") int amount) {
return new HelloResponseDto(name, amount);
}
}
HelloController 테스트
HelloControllerTest
param
API 테스트 할 때 요청 파라미터를 설정
단, 그 값은 String만 허용
숫자/날짜 등의 데이터도 등록하는 경우 문자열로 변경이 필요 (String.valueOf(value))
jsonPath
JSON 응답값을 필드별로 검증할 수 있는 메서드
$를 기준으로 필드명을 명시
여기서는 name, amount를 검증하니 $.name, $.amount로 검증
@RunWith(SpringRunner.class)
@WebMvcTest
public class HelloControllerTest {
@Autowired
private MockMvc mvc;
...
@Test
public void return_helloDto() throws Exception {
String name = "hello";
int amount = 1000;
mvc.perform(
get("/hello/dto")
.param("name", name)
.param("amount", String.valueOf(amount))
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is(name)))
.andExpect(jsonPath("$.amount", is(amount)));
}
}
결과 테스트
MockHttpServletResponse.Body
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json;charset=UTF-8"]
Content type = application/json;charset=UTF-8
Body = {"name":"hello","amount":1000}
Forwarded URL = null
Redirected URL = null
Cookies = []