1. 왜 리액티브 스프링인가?

리액티브(반응형)이 필요한 이유

  • 증가하는 요청, 부하에 대해서 높은 응답성을 유지해야 한다.
  • 메시지 기반 통신을 바탕으로 탄력성복원력을 확보하하여 높은 응답성을 추구한다.

탄력성

  • 자원을 비례적으로 추가하거나 제거하여 시스템의 처리량의 자동으로 증감 하는 것

복원력

  • 시스템의 실패에도 반응성을 유지하는 것
  • 시스템의 기능 요소를 격리해 모든 내부 장애를 격리하고 독립성을 확보함으로써 달성

메시지 기반 통신(Message-driven)

  • 제한된 리소스의 활용도를 높이기 위해서 비동기 논블로킹 모델을 사용해야 한다.
  • 메시지 브로커를 사용하면 대기열을 모니터링하여 시스템의 부하관리 및 탄력성을 제어할 수 있다.

리액티브 선언문(https://www.reactivemanifesto.org/ko/glossary)

5.중간메모

복습 겸 다시 프로젝트 생성해서 만들어보다가 몇가지 간략히 정리

1. springboot에서 html파일을 templates에서 읽도록 하기

템플릿 엔진과 관련된 의존성을 추가하면 자동으로 /recourses/templates 아래에서 템플릿 파일을 찾도록 하는거 같은데 원래 기본 경로는 /recourses/static 이다.

따라서 별다른 설정을 하지 않고 html 파일을 templates 폴더 밑에 놓고 찾으려고 하면 당연히 404 에러를 볼수 밖에 없다.

html 파일을 templates 아래에 관리하고 싶다면 몇가지 작업을 해야하는데

우선 스프링부트에서 WebMVC 설정을 유지하면서 기능을 확장하기 위해 WebMvcConfigurerimplements 하고 addResourceHandlers 를 오버라이드하여 아래와 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/templates/")
.setCacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES));
}
}

2. application.properties -> application.yml 변환

가독성 측면에서 yaml 파일이 더 좋아보여서 기존 내용을 변환해서 쓰려다보니 naver oauth2 설정을 하면 오류가 발생한다.

redirect-uri: '{baseUrl}/{action}/oauth2/code/{registrationId}'

yaml 에서는 / (슬러시)를 그대로 쓰면 파싱 에러가 난다. 따옴표나 작은 따옴표로 감싸주면 된다.

4.머스테치로 화면 구성하기

4. 머스테치로 화면 구성하기

4.1 서버 템플릿 엔진

템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML문서를 출력하는 소프트웨어를 이야기한다.

서버 템플릿 엔진을 이용한 화면 생성은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달한다.

반면 클라이언트 템플릿 엔진(Vue, React 등)을 이용한 SPA(Single Page Application)은 브라우저에서 화면을 생성한다. 즉, 서버에서 이미 코드가 벗어난 경우라서

서버에서는 Json 혹은 Xml 형식의 데이터만 전달하고 클라이언트에서 조립한다.

최근엔 리액트나 뷰와 같은 자바스크립트 프레임워크에서 서버사이드렌더링을 지원하는 모습을 볼 수 있지만 그건 나중에 생각하자.


머스테치

머스테치는 많은 언어를 지원하는 심플한 템플릿 엔진이다.

스프링 부트에서 공식 지원하는 템플릿 엔진으로 gradle에 의존성 한줄 추가하면 바로 사용할 수 있다.

파일위치는 기본적으로 src/main/resources/templates이며 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩한다.

해당 위치에 index.mustache를 생성한 후 이 머스테치에 URL을 매핑하는데 이는 Controller에서 진행한다.

  • IndexController
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;


@Controller
public class IndexController {

@GetMapping("/")
public String index() {
return "index";
}
}

머스테치 스타터 의존성을 추가했기 때문에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.

즉, 여기선 "index"를 반환하므로 src/main/resources/templates/index.mustache로 전환되어 View Resolver가 처리하게 된다.

(View Resolver는 URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격으로 볼 수 있다.)


화면 구성 시 bootstrap을 사용하는데 공통된 부분에 대해선 layout을 따로 둬서 header와 footer 파일을 각각 만들어서 공통된 코드는 해당 위치에 생성한다.

여기서 페이지 로딩속도를 높이기 위해 css는 header에, js는 footer에 두는데 HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행된다.

즉, head가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 노출되며 특히 js의 용량이 크면 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋다.

header와 footer를 index에 추가하는건 아래와 같다.

1
2
3
4
5
6
7
8
9
10
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" clas="btn btn-primary">글 등록</a>
</div>
</div>
</div>
{{>layout/footer}}

{{> }} 는 현재 머스테치 파일을 기준으로 다른 파일을 가져온다.

화면의 버튼에 API를 호출하는 js파일을 작성하여 footer.mustache에 추가한다.

1
2
3
4
5
6
7
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>

<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>

index.js 호출 코드는 절대경로(/)로 바로 시작하는데 스프링 부트는 기본적으로 src/main/resources/static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들은 URL에서 / 로 설정된다.


  • PostsRepository 인터페이스
1
2
3
4
5
6
public interface PostsRepository extends JpaRepository<Posts, Long> {

@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
}

@Query 어노테이션을 사용하면 SpringDataJpa에서 제공하지 않는 메소드를 쿼리로 직접 작성할 수 있다.

보통 규모가 있는 프로젝트에선 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 Entity 클래스만으로 처리가 어려워 조회용 프레임워크를 추가로 사용한다.

대표적 예로 querydsl, jooq, MyBatis 등이 있는데 해당 프레임워크 중 하나로 조회를 하고 그 외 등록/수정/삭제 등은 SpringDataJpa를 통해 진행한다.

  • PostsService
1
2
3
4
5
6
...
@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
return postsRepository.findAllDesc().stream().map(PostsListResponseDto::new).collect(Collectors.toList());
}
...

@Transactional 어노테이션에 추가된 readOnly 옵션을 true로 주면 트랜잭션 범위는 유지하되, 조회기능만 남겨두어 조회 속도가 개선되기 때문에 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추천한다.


  • IndexController
1
2
3
4
5
6
7
8
9
10
11
12
@RequiredArgsConstructor
@Controller
public class IndexController {

private final PostsService postsService;

@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
return "index";
}
}

Model

  • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다.
  • 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달한다.

REST에서 CURD는 다음과 같이 HTTP Method에 매핑된다.

  • 생성(Create) : POST
  • 읽기(Read) : GET
  • 수정(Update) : PUT
  • 삭제(Delete) : DELETE

3.JPA로 데이터베이스 다루기

3 JPA

Posts 클래스는 실제 DB의 테이블과 매칭될 클래스로 Entity 클래스라고 부른다.

DB데이터에 작업할 경우 실제 쿼리를 날리기 보단 이 Entity 클래스의 수정을 통해 작업

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.jojoldu.book.springboot.domain.posts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

/*


1. @Entity
- 테이블과 링크될 클래스
- 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.
- ex) SalesManager.java -> sales_manager table

아래 3개는 lombok 어노테이션
2. @NoArgsConstructor
- 기본생성자 자동추가
- public Posts() {} 와 같음

3. @Getter
- 클래스 내 모든 필드의 Getter 메소드 자동생성

4. @Builder
- 해당 클래스의 빌더 패턴 클래스를 생성
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
*/

@Getter
@NoArgsConstructor
@Entity
public class Posts {

/*
1. @Id
- PK 필드
2. @GeneratedValue
- PK 생성규칙
- GenerationType.IDENTITY 옵션을 추가해야 auto_increment 된다.
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

/*
1. @Column
- 선언하지 않더라도 해당 클래스의 필드는 모두 컬럼이 된다.
- 옵션을 추가할 때만 선언해도 된다.
*/
@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 클래스는 Setter 메소드를 만들지 않고 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야 한다.

1
2
3
4
5
6
7
8
public class Order{
public void cancelOrder(){
this.status = false;
}
}
public void 주문서비스의_취소이벤트() {
order.cancelOrder();
}

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이고 값 변경이 필요하면 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다.

생성시점에 값을 넣는 방법으로 생성자와 @Builder를 통해 제공되는 빌더 클래스를 사용할 수 있는데 차이점이 있다면

생성자는 new Example(b,a)처럼 파라미터의 위치를 바꾸더라도 실제 실행 전까지 문제를 찾기 어렵다.

1
2
3
4
public Example(String a, String b){
this.a = a;
this.b = b;
}

빌더를 사용한다면 어느 필드에 어떤 값을 채워야할 지 명확하게 인지할 수 있다.

1
2
3
4
Example.builder()
.a(a)
.b(b)
.build();

Posts클래스(Entity) 생성이 끝나면 해당 클래스로 Database를 접근하게 해줄 JpaRepository를 생성한다.

  • src/main/java/com/jojoldu/book/springboot/domain/posts/PostsRepository
1
2
3
4
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostsRepository extends JpaRepository<Posts, Long> {
}

보통 ibatis나 MyBatis 등에서 Dao라고 불리는 DB Layer 접근자를 JPA에선 Repository 라고 부르며 인터페이스로 생성한다.

생성 후, JpaRepository<Entity 클래스, PK 타입> 를 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.

@Repositry 어노테이션을 추가할 필요는 없지만 Entity 클래스와 기본 Entity Repository는 함께 위치해야 한다.

생성한 Repository 를 테스트하기 위해 아래 코드를 작성하며 테스트할 기능은 save, findAll 기능이다.

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.jojoldu.book.springboot.domain.posts;

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;

import static org.assertj.core.api.Assertions.assertThat;

/*
별다른 설정없이 @SpringBootTest를 사용할 경우 H2 데이터베이스를 자동으로 실행한다.
*/
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest {

@Autowired
PostsRepository postsRepository;

/*
1. @AfterEach
- Junit에서 단위테스트가 끝날 때마다 수행되는 메소드를 지정한다.
- 보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용된다.
- Junit4 -> 5로 넘어가면서 After -> AfterEach 로 변경되었다.
*/
@AfterEach
public void cleanup() {
postsRepository.deleteAll();
}

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

/*
2. postsRepository.save
- 테이블 posts에 insert/update 쿼리를 실행한다.
- id값이 있으면 update, 없다면 insert 쿼리가 실행된다.
*/
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("jojoldu@gmail.com")
.build());

/*
3. postsRepository.findAll
- 테이블 posts에 있는 모든 데이터를 조회해오는 메소드
*/
//when
List<Posts> postsList = postsRepository.findAll();

//then
Posts posts = postsList.get(0);
assertThat(posts.getTitle()).isEqualTo(title);
assertThat(posts.getContent()).isEqualTo(content);
}
}

3.4 등록/수정/조회 API 만들기

API를 만들기 위해 총 3개의 클래스가 필요하다.

  • Request 데이터를 받을 Dto
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

여기서 Service는 비지니스 로직을 처리하는 것이 아니라 트랜잭션, 도메인 간 순서 보장의 역할만 한다.

Web, Service, Repository, Dto, Domain 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 **Domain**이다.

기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 한다.

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
@Transactional
public Order cancelOrder(int orderId){

//1) 데이터베이스로부터 주문정보, 결제정보, 배송정보 조회
OrdersDto order = ordersDao.selectOrders(orderId);
BillingDto billing = billingDao.selectBilling(orderId);
DeliveryDto delivery = deliveryDao.selectDelivery(orderId);

//2) 배송 취소를 해야하는지 상태값 확인
String deliveryStatus = delivery.getStatus();

//3) 만약 배송중이라면 배송취소로 변경
if("IN_PROGRESS".equals(deliveryStatus)){
delivery.setStatus("CANCEL");
deliveryDao.update(delivery);
}

//4) 각 테이블에 취소 상태 Update
order.setStatus("CANCEL");
orderDao.update(order);

billing.setStatus("CANCEL");
deliveryDao.update(billing);

return order;
}

모든 로직이 서비스 클래스 내부에서 처리된다면 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 된다.

반면 도메인 모델에서 처리할 경우 아래와 같은 코드가 될 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional
Public Order cancelOrder(int orderId){

//1)
OrdersDto order = ordersDao.selectOrders(orderId);
BillingDto billing = billingDao.selectBilling(orderId);
DeliveryDto delivery = deliveryDao.selectDelivery(orderId);

//2-3)
delivery.cancel();

//4)
order.cancel();
billing.cancel();

return order;
}

order, billing, delivery가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 준다.


스프링에서 Bean을 주입하는 방식은 다음과 같다.

  • @Autowired
  • setter
  • 생성자

가장 권장하는 방식은 생성자로 주입받는 방식이며 @Autowired는 권장하지 않는다.

아래 Service 코드에서 생성자는 직접 쓰지 않고 @RequiredArgsConstructor어노테이션에서 해결해 준다.

final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬북에서 대신 생성해준다.

이처럼 어노테이션을 사용하는 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 변경하는 수고를 덜기 위함이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;


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

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

Controller와 Service에서 사용할 Dto 클래스는 언듯 Entity 클래스와 유사한 형태지만 추가로 생성해야한다.

즉, 절대로 Entity 클래스를 Request/Response 클래스로 사용해선 안된다.

이유는 Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스로 화면 변경은 사소한 변경인데 이를 건들기 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 너무 큰 변경이다.

수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작하며 Entity 클래스가 변경되면 여러 클래스에 영향을 끼지지만

Request/Response 용 Dto는 View를 위한 클래스라 자주 변경이 필요하다.

이처럼 View Layer 와 DB Layer 의 역할을 철저히 분리하는게 좋다.

예를 들어 Controller에서 결과값으로 여러 테이블을 조인해야 하는 경우 Entity 클래스만으로 표현하기 어려운 경우도 있다.

다음은 JPA를 사용한 게시판의 등록 API 테스트 코드이다.

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
49
50
51
52
53
54
55
56
57
58
59
60
package com.jojoldu.book.springboot.web;


import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
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.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;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

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

HelloController와 달리 @WebMvcTest를 사용하지 않는데 @WebMvcTest의 경우 JPA기능이 작동하지 않기 때문인데 Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니

지금 처럼 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest 와 TestRestTemplate을 사용하면 된다.


JPA를 사용할 때 update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.
이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

영속성 컨텍스트란, 엔티티를 영구 저장하는 환경으로 일종의 논리적 개념이라고 보면 되며 JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.

JPA의 엔티티 매니저가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본 옵션) 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. 즉, Entity 객체의 값만 변경하면 별도로 Update쿼리를 날릴 필요가 없는데 이를 더티 체킹(dirty checking)이라고 한다.


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

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함하는데 반복적인 코드를 모든 테이블과 서비스 메소드에 포함하면 너무 귀찮으니 JPA Auditing를 사용해보자.

LocalDate 사용

Java8부터 LocalDate와 LocalDateTime이 등장하여 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고쳤으니 꼭 사용하자.

  • BaseTimeEntity.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;


@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

@CreatedDate
private LocalDateTime createDate;

@LastModifiedDate
private LocalDateTime modifiedDate;
}

BaseTimeEntity클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할이다.

  1. @MappedSuperclass
  • JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdDate, modifiedDate)도 컬럼으로 인식하도록 한다.
  1. @EntityListeners(AuditingEntityListener.class)
  • BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
  1. CreatedDate
  • Entity가 생성되어 저장될 때 시간이 자동 저장된다.
  1. LastModifiedDate
  • 조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.

이후 앞서 만든 Posts 클래스가 BaseTimeEntity를 상속받도록 변경한다.

1
2
3
4
  ...
public class Posts extends BaseTimeEntity {
...
}

마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션을 추가한다.

1
2
3
4
5
6
7
8
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

2.테스트코드 작성하기

JUnit4 -> 5 변경점

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
junit4 -> 5

1. @Test
패키지 위치 변경

org.junit.Test
-> org.junit.jupiter.api.Test

2. @RunWith
Junit5에서 @ExtendWith 로 변경되서 어노테이션명과 패키지위치 변경

org.junit.runner.RunWith
-> org.junit.jupiter.api.extension.ExtendWith

@RunWith
-> @ExtendWith

3. SpringRunner
SpringExtension 으로 변경되서 클래스명과 패키지위치 변경

SpringRunner
-> SpringExtension

org.springframework.test.context.junit4.SpringRunner
-> org.springframework.test.context.junit.jupiter.SpringExtension

4. @After
테스트 메소드가 끝날때마다 수행되는 @After 도 Junit5에서 @AfterEach 로 변경되었기 때문에 어노테이션과 패키지위치 변경

@After
-> @AfterEach

org.junit.After
-> org.junit.jupiter.api.AfterEach

5. @Before
마찬가지로 @BeforeEach 로 변경되서 어노테이션과 패키지위치 변경

@Before
-> @BeforeEach

org.junit.Before
-> org.junit.jupiter.api.BeforeEach

샘플 컨트롤러 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.jojoldu.book.springboot.web;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

//컨트롤러를 JSON을 반환하는 컨트롤러로 만들어준다.
@RestController
public class HelloController {

//HTTP Method인 Get의 요청을 받을 수 있는 API를 만들어 준다.
@GetMapping("/hello")
public String hello() {
return "hello";
}
}

샘플 단위 테스트 코드

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.jojoldu.book.springboot.web;

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.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/*
1. @ExtendWith(SpringExtension.class)
- 테스트를 진행할 때 JUnit에 내장된 실행자 외에 다른 실행자를 실행시킨다.
- 여기서는 SpringExtension 이라는 스프링 실행자를 사용한다.
- 스프링 부트 테스트와 JUnit 사이에 연결자 역할
- JUnit4 -> 5로 넘어오면서 사용하는 어노테이션과 클래스가 각각 @RunWith -> @ExtendWith 로 SpringRunner -> SpringExtension 으로 변경되었다.

2. @WebMvcTest
- 여러 스프링 테스트 어노테이션 중, Web(Spring MVC)에 집중할 수 있는 어노테이션
- 선언할 경우 @Controller, @ControllerAdvice 등을 사용할 수 있다.
- 단, @Service, @Component, @Repository 등은 사용할 수 없다.
*/

@ExtendWith(SpringExtension.class)//1
@WebMvcTest(controllers = HelloController.class)//2
public class HelloControllerTest {

/*
3. AutoWired
- 스프링이 관리하는 빈(Bean)을 주입 받는다.

4. private MockMvc mvc
- 웹 API를 테스트할 때 사용한다.
- 스프링 MVC 테스트의 시작점
- 이 클래스를 통해 HTTP GET, POST 등에 대한 API 테스트를 할 수 있다.
*/
@Autowired//3
private MockMvc mvc;//4

@Test
public void hello가_리턴된다() throws Exception {
String hello = "hello";

/*
5. mvc.perform(get("/hello"))
- MockMvc를 통해 /hello 주소로 HTTP GET 요청을 한다.
- 체이닝이 지원되어 아래와 같이 여러 검증 기능을 이어서 선언할 수 있다.

6. .andExpect(status().isOk())
- mvc.perform의 결과를 검증한다.
- HTTP Header의 Status를 검증한다.
- 우리가 흔히 알고 있는 200, 404, 500 emddml 상태를 검증한다.
- 여기선 OK 즉, 200인지 아닌지를 검증한다.

7. .andExpect(content().string(hello))
- mvc.perform의 결과를 검증한다.
- 응답 본문의 내용을 검증한다.
- Controller에서 "hello"를 리턴하기 때문에 이 값이 맞는지 검증한다.
*/
mvc.perform(get("/hello"))//5
.andExpect(status().isOk())//6
.andExpect(content().string(hello));//7
}
}

2.3 롬북(Lombok)

자바 개발 시 자주 사용하는 코드 Getteer, Setter, 기본생성자, toString 등을 어노테이션으로 자동 생성해준다.

1
2
3
4
dependencies {
// lombok
implementation('org.projectlombok:lombok')
}