스프링 부트와 AWS로 혼자 구현하는 웹 서비스 (~ 124p)

  • https://github.com/Oraindrop/book-practice
    • 위 repository에 실습을 진행 중

Gradle 설정

  • build.gradle, basic spring boot
buildscript {
    ext {
        springBootVersion = '2.1.7.RELEASE'
    }

    repositories {
        mavenCentral()
        jcenter()
    }

    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group 'io.github.oraindrop'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
    jcenter()
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}
  • ext keyword
    • build.gradle 의 전역변수를 선언
  • repositories 의 mavenCentral vs jcenter
    • mavenCentral : 이전부터 많이 사용하는 저장소, 개발자들의 커스텀 라이브러리 업로드가 힘들어 공유가 되지 않는 상황이 발생
    • jcenter : 위 문제를 해소하기 위해 라이브러리 업로드를 간단하게! + 여기 업로드 하면 mavenCetral 에도 업로드 되도록 자동화
    • 그러다 보니 개발자들의 라이브러리가 점점 jcenter 로 이동하고 있다.
  • 가장 아래의 dependencies 의 compile 과 testCompile 의 라이브러리는 의도적으로 version 을 명시하지 않았다.
    • 명시하지 않아야만 맨 위에 작성한 springBootVersion 을 따라가게 된다.

Spring Boot Main Class

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • @SpringBootApplication
    • 스프링 부트의 자동 설정, 스프링 Bean 읽기 및 생성
    • 프로젝트의 최상단 에 위치 해야한다.
      • 해당 어노테이션이 있는 위치부터 설정을 읽어가기 때문
  • SpringApplication.run(Application.class, args);
    • 요 문장으로 인해 내장 WAS 실행
    • 서버에 톰캣을 설치할 필요가 없어짐
    • 스프링 부트로 만들어진 Jar 파일로 실행하면 되게 된다.
    • 내장 WAS 의 장점
      • 언제 어디서나 같은 환경에서 스프링 부트를 배포 할 수 있다.

      • 외장 WAS 의 경우, 종류와 버전 설정을 일치시켜야 함
      • 새로운 서버가 추가될 경우 같은 환경을 구축해야 한다
        • ex) 30 대의 서버 WAS 버전을 올릴 경우, 실수할 여지도 많고 큰 작업이 될 수 있다.

Spring Boot JUnit Test

@WebMvcTest
@RunWith(SpringRunner.class)
public class HelloControllerTest {

    @Autowired
    private MockMvc mvc;

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

        mvc.perform(get("/hello"))
                .andExpect(status().isOk())
                .andExpect(content().string(hello));
    }

}
  • @WebMvcTest
    • 여러 스프링 테스트 어노테이션 중, Web(Spring MVC) 에 집중할 수 있는 어노테이션
    • 선언할 경우 @Controller, @ControllerAdvice 등을 사용할 수 있다.
      • 거의 컨트롤러 테스트를 위한 어노테이션이라고 봐도 무방할 듯
      • 별도의 HTTP 서버 없이 Controller 테스트 진행 가능
    • 단, @Service, @Component, @Repository 등은 사용할 수 없다. (Scan 대상이 아니다.)
    • 그렇기 때문에 전체 응용프로그램 컨텍스트를 스캔하는 @SpringBootTest 에 비해 가볍고 빠르게 테스트가 가능하다.
      • 대신 필요한 나머지 bean 을 직접 세팅해줘야 한다.
  • @RunWith(SpringRunner.class)
    • 테스트 진행 시 @RunWith에 Runner클래스를 설정하면 JUnit에 내장된 Runner대신 그 클래스를 실행한다.
      • JUnit 프레임워크의 테스트 실행방법을 확장할 때 사용하는 것.
      • 테스트 진행 중 테스트가 사용할 애플리케이션 컨텍스트를 만들고 관리하는 작업을 진행해준다.
      • 보통 함께 사용되는 @ContextConfiguration 은 자동으로 만들어줄 애플리케이션 컨텍스트의 설정파일 위치를 지정한 것
      • @RunWith 애노테이션은 각각의 테스트 별로 오브젝트가 생성 되더라도 싱글톤의 ApplicationContext를 보장한다.
        • 각 메서드 마다 애플리케이션 컨텍스트를 띄우는 게 아니라, 최초 1번만 띄워서 한 번 이후 테스트는 빠르다.
    • SpringBoot 테스트와 JUnit 사이의 연결자 역할

Lombok 설정

  • build.gradle 에 dependency 추가.

      ...
    
      dependencies {
          compile('org.springframework.boot:spring-boot-starter-web')
          compile('org.projectlombok:lombok')
          testCompile('org.springframework.boot:spring-boot-starter-test')
      }
    
      ...
    
  • Gradle Refresh

  • Intellij Lombok Plugin 설치

    • Command + Shift + A -> Plugins
    • Marketplace 탭 -> lombok 검색
    • 가장 상단의 Lombok 플러그인 Install, 다되면 Install 버튼 자리가 Restart IDE 로 바뀌고, Restart ㄱㄱ
    • 리스타트 후 “Setting -> Build -> Compiler -> Annotatioin Processors”
      • 여기서 [x] Enable annotation processing Check.

롬복 사용해보기

@Getter
@RequiredArgsConstructor
public class HelloResponseDto {

    private final String name;

    private final int amount;
}
  • @Getter
    • 선언된 모든 필드의 get Method 생성
  • @RequiredArgsConstructor
    • 선언된 모든 final 필드가 포함된 생성자 생성
    • final이 없는 필드는 생성자에 포함하지 않는다.
public class HelloResponseDtoTest {

    @Test
    public void 롬복_기능_테스트() {
        // given
        String name = "TEST";
        int amount = 1000;

        // when
        HelloResponseDto dto = new HelloResponseDto(name, amount);

        // then
        assertThat(dto.getName()).isEqualTo(name);
        assertThat(dto.getAmount()).isEqualTo(amount);
    }
}
  • 해당 테스트 run 시 gradle version check
    • Mac 기준 ./gradlew --version 으로 자신의 버전 체크 가능
      • 5.2.1 이었다.
    • 이 책은 gradle 4 기준으로 예제들이 적혀있다.
    • gradle 5 부터 lombok, Querydsl 등의 플러그인의 설정 방법이 조금 달라졌다. 이 책의 예제를 원활히 수행하기 위해 4버전으로 다운그레이드.
    • ./gradlew wrapper --gradle-version 4.10.2

실제 롬복을 적용한 컨트롤러 만들어보기

@GetMapping("/hello/dto")
public HelloResponseDto helloDto(@RequestParam("name") String name, @RequestParam("amount") int amount) {
    return new HelloResponseDto(name, amount);
}
  • Test

      @Test
      public void helloDto가_리턴된다() throws Exception {
          String name = "hello";
          int amount = 1000;
    
          mvc.perform((get("/hello/dto")
                      .param("name", name)
                      .param("amount", String.valueOf(amount))))
                  .andExpect(status().isOk())
                  .andExpect(jsonPath("$.name", is(name)))
                  .andExpect(jsonPath("$.amount", is(amount)));
      }
    
  • get()
    • import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
  • param()
    • API TEST 의 Request Parameter 추가.
    • param(String s1, String s2)
  • jsonPath
    • import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
    • JSON 응답값을 Filed 별로 TEST
    • $을 기준으로 필드명을 명시 ($.name, $.amount)
  • is
    • import static org.hamcrest.Matchers.is
    • .andExpect(jsonPath("$.name", is(name))) 이 문장을
      • .andExpect(jsonPath("$.name").value(name)) 이렇게도 표현 가능하다.

JPA

  • SQL Mapper 를 사용하다보면, 애플리케이션 코드보다 SQL 이 더 가득가득해진다.
    • 이유는? RDB 와 객체지향의 패러다임 불일치
      • 즉 사상부터 다른 시작점에서 출발하였다.
  • 객체지향에서 부모-자식 관계 조회

      User user = findUser();
      Group group = user.getGroup();
    
    • User 와 Group 의 부모-자식 관계가 명확하다.
  • JPA 를 사용하지 않은 DB 가 결합된 User, Group 조회

      User user = userDao.findUser();
      Group group = groupDao.findGroup(user.getGroupId());
    
    • User 따로, group 따로 조회 해야하므로, USer 와 Group 의 관계가 모호하다.
  • 패러다임 불일치를 해결하기 위해 JPA 등장
    • 개발자는 객체지향으로 프로그래밍을 하고,
    • JPA가 이를 RDB에 맞게 SQL을 대신 생성/실행
    • 개발자는 객체지향 코드로 전부 표현할 수 있으므로, SQL 종속적인 개발을 하지 않아도 됨

Spring Data JPA

  • JPA 는 인터페이스
    • 대표적인 구현체는 Hibernate
    • 이 구현체를 직접 다루는가?
    • ㄴㄴ 한단계 더 추상화 된 Spring Data JPA 모듈을 사용.
    • 즉, JPA <- Hibernate <- Spring Data JPA
  • Why Spring Data JPA?
    • 사실 Hibernate 를 직접쓰는거랑 큰 차이가 없긴함.
    • 다만, 구현체를 Hibernate 에서 다른걸로 바꾸고 싶다면?
      • 구현체 교체의 용이성
      • Slf4j 생각해보면 될 듯
    • 또, mySql 쓰다가 MongoDB 로 교체가 필요하다면?
      • 저장소 교체의 용이성
  • build.gradle
      dependencies {
          ...
          compile('org.springframework.boot:spring-boot-starter-data-jpa')
          compile('com.h2database:h2')
          ...
      }
    
  • package name domain 이란
    • 소프트웨어에 대한 요구사항 / 문제 영역
  • Posts Class
      @Entity
      @Getter
      @NoArgsConstructor
      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
      • 테이블과 링크될 클래스임을 나타냄
      • 카멜케이스 -> 스네이크케이스로 자동으로 변경하여 table 생성
    • @Id
      • 해당 테이블의 PK
    • @GeneratedValue
      • PK 생성 규칙
      • 스프링부트 2.0 이상에서는 GenerationType.IDENTITY 옵션을 추가하면 auto increment 됨
    • @Column
      • 테이블의 컬럼을 나타냄
      • 굳이 선언하지 않더라도, 해당 클래스의 필드는 모두 컬럼이 됨
      • size, type 등을 변경하고 싶은 경우에 사용합니다.
    • 생각해볼 것들
      1. 어노테이션 순서
        • 저자는 어노테이션 순서를 주요 어노테이션 을 클래스에 가깝게 두고 있다.
          • 이를테면 @Getter, @NoArgsConstructor, @Entity 순서인 것이다.
          • 이렇게 하면, 새언어로의 전환 등의 이유로 롬복이 필요없어질 경우 위에 것만 지우면 되기 때문
          • 이런 것에도 마땅한 근거가 있을 줄은 몰랐다. 역시 갓갓.
        • 개인적으로 내 어노테이션 순서는 저자의 반대에 가깝고, 내 기준은 글자수가 늘어나는 대로 이다.
          • 이를테면 @Entity, @Getter, @NoArgsConstructor 가 된다.
          • 이유는 가독성이 좋아진다고 생각해서.
          • 삐뚤빼뚤한 것 보다 가독성이 좋다고 생각했고, 같은 길이라면 더 중요하다고 생각되는 것(ex. @Entity)을 더 잘 보이는 위쪽에 위치시키는 습관이 있다.
      2. PK 에 Obejct Type Long을 사용한 이유가 있을까?
        • not null 이므로 primitive type long 을 사용하는 것이 더 좋지 않나?

JPA

  • Posts Class
      @Entity
      @Getter
      @NoArgsConstructor
      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;
          }
            
      }
    
  • 의견 충돌 부분
    • Class 이름을 복수형으로 한 이유는 뭘까
    • 특별한 이유가 없다면 안티패턴으로 알고있는데..
  • 특이점
    • Setter 메소드가 없다.
      • getter/setter 를 무작정 생성하는 것을 지양하자.
    • Entity 클래스에서 절대 Setter 메소드를 만들지 않는다.
      • 해당 필드 값 변경이 필요하다면, 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가하자.
      • 이 문장 너무 좋네 세상 똑똑.
    • Builder 장점
      • 생성자는 채워야 하는 필드가 무엇인지 정확히 알 수 없다.
      • 같은 타입의 파라미터가 2개라면, 순서가 변경되어도 Syntax error 가 없으니 디버깅이 매우 어렵다.

          new Example(b, a);
        
      • 반면 빌더는 어느 필드에 어떤 값을 채워야 할 지 명확하다.

          Example.builder()
              .a(a)
              .b(b)
              .build();
        
  • PostsRepository Class

      public interface PostsRepository extends JpaRepository<Posts, Long> {}
    
    
    • like Dao
  • Repository test

      @SpringBootTest
      @RunWith(SpringRunner.class)
      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("choising")
                      .build());
    
              // when
              List<Posts> postsList = postsRepository.findAll();
    
              // then
              Posts posts = postsList.get(0);
              assertThat(posts.getTitle()).isEqualTo(title);
              assertThat(posts.getContent()).isEqualTo(content);
          }
    
      }
    
    • @After
      • 단위 테스트가 끝날 때 마다 수행되는 메소드
    • @SpringBootTest
      • @SpringBootApplication 을 찾아 테스트를 위한 빈을 다 생성한다.
      • webEnvironment 라는 값을 통해 테스트 시 Mock 으로 테스트 할 것인지, 실제 서블릿 컨테이너를 구동해서 테스트 할 것인지를 정할 수 있다.
          @SpringBootTest(webEnvironment = MOCK) // Default 값이 Mock 이긴 하다.
        
      • 애플리케이션 컨텍스트를 올려, 설정을 모두 로드하기 때문에 애플리케이션 규모가 클 수록 느려진다.
      • H2도 자동으로 실행됨.
  • application.properties
    • 설정파일
      • src/main/resources 아래에 위치한다.
      spring.jpa.show_sql=true
    
    • 이 한 줄의 옵션을 추가하면 실제 쿼리 로그를 확인 가능하다.
      • 단 H2 쿼리 문법으로 출력되었다.
      spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
    
    • 이 한줄을 추가함으로, mySql 문법의 쿼리로그를 확인할 수 있다.

API 만들기

  • 통상적으로 api 는 3개의 클래스가 필요하다.
    • Dto / Controller / Service
  • Service Layer
    • Service Layer 에서 비즈니스 로직을 처리하지 않도록 하는 게 좋다.
    • 트랜잭션, 도메인 간 순서 보장 의 역할만!
    • 모든 로직이 서비스 클래스 내부에서 처리되는 것은 서비스 계층이 무의미하며, 객체가 단순히 데이터 덩어리 역할만 하게되는 안티패턴
  • PostsController Class
      @RestController
      @RequiredArgsConstructor
      public class PostsApiController {
    
          private final PostsService postsService;
    
          @PostMapping("/api/v1/posts")
          public Long save(@RequestBody PostsSaveRequestDto requestDto) {
              return postsService.save(requestDto);
          }
      }
    
  • PostsService Class
      @Service
      @RequiredArgsConstructor
      public class PostsService {
    
          private final PostsRepository postsRepository;
    
          @Transactional
          public Long save(PostsSaveRequestDto requestDto) {
              return postsRepository.save(requestDto.toEntity()).getId();
          }
      }
    
  • PostsSaveRequestDto Class
      @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();
          }
      }
    
  • 스프링의 Bean 주입 방식
    • @Autowired
    • setter
    • constructor

    • 이 중 저자는 생성자를 통해 주입하는 방식을 강력히 권한다.
      • Lombok 의 @RequiredArgsConstructor 어노테이션으로 쉽게 구현 가능하다.
      • final 키워드로 선언된 모든 필드를 인자값으로 하는 생성자
  • 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안된다.
    • Entity 클래스는 DB와 맞닿은 핵심 클래스이므로
    • 화면 변경 처럼 사소하고 잦은 변경의 인자로 쓰이는 것은 위험하다.
    • View Layer 와 DB Layer 의 분리를 철저히 하는 것이 좋다.
  • PostsApiControllerTest Class

      @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 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 를 사용하지 않고 @SpringBootTest 를 사용하였다.
      • @WebMvcTest 의 경우 JPA 기능이 동작하지 않기 때문
  • PostsService Class update method
      @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;
          }
    
    • postsRepository.save(posts) 를 하지 않았다.
      • JPA 영속성 컨텍스트 때문
      • JPA 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스의 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태.
        • 이 때 해당 데이터의 값을 변경하게 되면, 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
      • 이 개념은 더티체킹 이라는 개념!
    • 더티체킹
      • Dirty : 상태의 변화가 생김
      • Dirty Checking : 상태변경 검사
      • JPA 는 트랜잭션이 끝나는 시점에 최초 조회 상태를 기준으로 변화가 있는 모든 엔티티 객체를 데이터베이스에 자동으로 반영
        • 기본적으로 전체 필드를 업데이트 친다.
          • 요 문장만 보면 엄청 구릴 것 같지만 그렇지가 않다.
            • 생성되는 쿼리가 동일하므로, 부트 실행시점에 미리 만들어 재사용한다.
            • DB 입장에서는 동일한 쿼리를 받으면 이전에 파싱된 쿼리를 재사용하므로, 재사용 가능하다.
        • 변경 필드만 반영되도록 할 수도 있다.
          • @Entity 객체에 @DynamicUpdate 어노테이션을 추가하여
          • 변경분만 업데이트 치므로 쿼리 자체는 가벼워졌지만
          • 쿼리 재사용이 불가하다는 측면에서, 필드가 몇 개 없는 테이블이라면 오히려 쿼리가 재사용되는 전체 업데이트가 더 효율적일수도 있다.
  • PostsApiControllerTest Class Posts 수정 테스트 케이스
      @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, 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);
      }
    
    • create table, insert, update query 를 log 로 확인도 가능하다.
  • 테스트 코드가 아닌 실제로 확인하기
    • application.properties 에 한 줄 추가
        spring.h2.console.enabled=true
      
    • run application
    • http://localhost:8080/h2-console 로 접속
      • JDBC URL 을 jdbc:h2:mem:testdb 로 변경 -> Connect 버튼 클릭!
    • query 를 날려 볼 수 있는 콘솔로 접속 완료.

JPA Audit

  • BaseTimeEntity Class

      @Getter
      @MappedSuperclass
      @EntityListeners(AuditingEntityListener.class)
      public abstract class BaseTimeEntity {
    
          @CreatedDate
          private LocalDateTime createdDate;
    
          @LastModifiedDate
          private LocalDateTime modifiedDate;
            
      }
    
    • @MappedSuperclass
      • JPA Entity 클래스들이 해당 어노테이션이 붙은 클래스를 상속할 경우 해당 클래스의 field 들을 column 으로 인식
    • @EntityListeners(AuditingEntityListener.class)
      • Auditing 기능을 포함시키는 어노테이션
  • Posts Class

      ...
    
      public class Posts extends BaseTimeEntity {
    
      ...
    
  • Application Class

      ...
    
      @EnableJpaAuditing
    
      ...
    
    • @EnableJpaAuditing
      • JPA Auditing 활성화
  • PostsRepositoryTest class

      @Test
      public void BaseTimeEntity_등록() {
          // given
          LocalDateTime now = LocalDate.now().atStartOfDay();
    
          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);
      }