본문 바로가기

Studying/스프링부트와 AWS로 혼자 구현하는 웹 서비스

[스프링부트와 AWS로 혼자 구현하는 웹 서비스]_3. (2) JPA 테스트 코드 및 API 작성

앞서 작성한 코드를 간단하게 테스트 코드로 기능을 검증을 해 볼 것임.

 

1. Spring Data JPA 테스트 코드 작성

 

test 디렉터리 안에 domain 패키지 아래 posts 패키지 생성하고, PostsRepositoryTest 클래스 생성.

java / com / jojoldu / book / springboot / domain / posts / PostsRepositoryTest 생성.

PostsRepositoryTest에서는 다음과 같이 save, findAll 기능을 테스트할 것임.

 

별다른 설정 없이 @SpringBootTests를 사용할 경우 H2 데이터베이스를 자동으로 실행해줌.

이 테스트 역시 실행할 경우 H2가 자동으로 실행됨.

테스트 코드 실행해 보기

package com.jojoldu.book.springboot.domain.posts;

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

import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

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

    // 등록기능
    @Test
    public void saveBoard_call() {
        // given
        String title = "테스트게시글";
        String content = "테스트본문";

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

!설명!

 

@After

 - Junit에서 단위 테스트가 끝날 때마다 수행되는 메서드 지정.

 - 보통은 배포 전 전체 테스트를 수행할 때 테스트 간 데이터 침범을 막기 위해 사용.

 - 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트 실패할 수 있음

 

postsRepository.save

 - 테이블 posts에 insert / update 쿼리를 실행

 - id 값이 있다면 update가, 없다면 insert 쿼리가 실행.

 

postsRepository.findAll : 테이블 posts에 있는 모든 데이터를 조회해 오는 메서드.

 

saveBoard_call() 메서드 실행해 보면 성공한 것을 볼 수 있음.

 

JPA를 통해 데이터베이스를 다루었음. 그렇다면 실제로 실행된 쿼리는 어떤 형태인지 알 수 있을까?

이러한 쿼리를 조회할 수 있음.

스프링 부트에서는 application.properties, application.yml 등의 파일로 한 줄의 코드로 설정 가능하도록 지원하고 권장함.

src / main / resources 아래에 applilcation.properties 파일 생성하기. (Resource Bundle로 생성)

 

파일 생성하면 파일 코드 입력하기.

spring.jpa.show_sql=true

 

그리고 다시 PostsRepositoryTest 실행하면 콘솔에서 쿼리 로그를 확인할 수 있음.

한 가지 짚고 넘어가면 create table 쿼리를 보면 id bigint generated by default as identity라는 옵션으로 생성됨.

이는 H2의 쿼리 문법이 적용되었기 때문.

 

H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 때문에 이후 출력되는 쿼리 로그를 MySQL 버전으로 변경하기.

이 옵션 역시 application.properties에서 설정이 가능.

다음 코드 추가하기.

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

 

다시 PostsRepositoryTest 실행하기.

 

하지만 나는 에러가 났다.

 

책을 보면서 공부하고 있는데, 아무래도 책의 내용과 현재 지원하는 버전이 다른 것 같다.

MySQL5InnoDBDialect는 Hibernate 6 이상에서 더 이상 존재하지 않거나 이름이 바뀌었을 수 있어 dialect 경로와 클래스명이 변경됨.(Hibername 6 부터는 MySQLDialect가 InnoDB를 기본적으로 사용하기 때문에 더 이상 MySQL5InnoDBDialect는 존재하지 않고 필요 없음.)

아래와 같은 코드로 작성하니 테스트 성공함.

spring.datasource.url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create

 

2. 등록 / 수정 / 조회 API 만들기

 

JPA와 H2에 대한 기본적인 기능과 설정을 진행했으니, 본격적으로 API 생성하기.

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

 - Request 데이터를 받을 Dto

 - API 요청을 받을 Controller

 - 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

 

우리는 Service에서 비즈니스 로직을 처리해야 한다고 생각하는데, Service는 트랜잭션, 도메인 간 순서 보장의 역할을 함.

 

위의 그림을 간단하게 설명해 봄.

 

● Web Layer

 - 흔히 사용하는 컨트롤러(@Controller)와 JSP / Freemarker 등의 뷰 템플릿 영역.

 - 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역 말함.

 

● Service Layer

 - @Service에 사용되는 서비스 영역.

 - 일반적으로 Controller와 Dao의 중간 영역에서 사용.

 - @Transactional이 사용되어야 하는 영역.

 

● Repository Layer

 - Database와 같이 데이터 저장소에 접근하는 영역.

 - 기존에 Dao(Data Access Object) 영역으로 이해하면 쉬움.

 

● DTOs

 - DTO(Data Transfer Object)는 계층 간의 데이터 교환을 위한 객체를 말하고, DTOs는 이들의 영역을 의미.

 - 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등을 말함.

 

● Domain Model

 - 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해하고, 공유할 수 있도록 단순화시킨 것. 

 - 예를 들면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있음. 

 - @Entity를 사용하면 @Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 됨.

 - 무조건 데이터베이스의 테이블과 관계가 있어야 하는 것이 아님 그 이유는 VO처럼 값 객체들도 이 영역에 해당되기 때문.

 

이 5가지 레이어에서 비즈니스 처리를 담당해야 할 곳은 Domain임.

기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 하는데, 모든 로직이 서비스 클래스 내부에서 처리됨.

그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 함.

반면 도메인 모델에서 처리할 경우 예를 들어 다음과 같은 코드가 될 수 있음.

@Transactional
public Order cancelOrder(int orderId) {
    
    // 1)
    Orders order = ordersRepository.findById(orderId);
    Billing billing = billingRepository.findByOrderId(orderId);
    Delivery delivery = deliveryRepository.findByOrderId(orderId);
    
    // 2-3)
    delivery.cancel();
    
    // 4)
    order.cancel();
    billing.cancel();
    
    return order;
}

!설명!

 

order, billing, delivery가 각자 본인의 취소 이벤트 처리하며,

서비스 메서드는 트랜잭션과 도메인 간의 순서만 보장해 줌.

 

앞으로도 이런 방법으로 코드를 다룰 것임.

 

등록, 수정, 삭제 기능을 만들어 볼 것임.

src / main / java / com / jojoldu / book / springboot 아래에

service 패키지 생성 후 그 패키지에 posts 패키지 생성한 뒤, PostsService 클래스 생성.

web 패키지 아래에는 PostsApiController 클래스 생성.

web / dto 패키지 안에 PostsSaveRequestDto 클래스 생성.

 

먼저 PostsApiController 코드 작성하기.

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.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.jojoldu.book.springboot.service.posts;

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에서 @Autowired가 없음.

스프링에서 Bean을 주입받는 방식들은 @Autowired, setter, 생성자 이 세 가지가 있음.

이 중 권장하는 방식이 생성자로 주입받는 방식임. (@Autowired는 권장하지 않음.)

즉, 생성자로 Bean 객체를 받도록 하면 @Auowired와 동일한 효과를 볼 수 있음.

위에 코드에서 생성자는 롬복의 어노테이션인 @RequiredArgsConstructor에서 해결해 줌. final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해 줌.

생성자를 직접 안 쓰고 롬복 어노테이션을 생성한 이유는 간단함. 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함.

 

이제는 Controller와 Service에서 사용할 Dto 클래스 생성하기.

앞서 작성한 Entity 클래스와 거의 유사한 형태이지만 Dto 클래스를 추가로 생성함.

하지만, 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안됨.

Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이므로, Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경됨.

화면 변경은 사소한 기능 변경인데, 이를 위해 테이블과 연결된 Entity 클래스를 변경하는 너무 큰 변경임.

꼭 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 함.

package com.jojoldu.book.springboot.web.dto;

import com.jojoldu.book.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();
    }
}

 

등록 기능의 코드를 완성했으니, 테스트 코드로 검증하기.

test / java / com / jojoldu / book / springboot / web에 PostsApiControllerTest 클래스 생성.

 

클래스를 생성하면 아래 코드처럼 작성하기.

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.After;
import org.junit.Test;
import org.junit.runner.RunWith;
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.junit4.SpringRunner;

import java.util.List;

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

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

 

실행하면 테스트 통과된 것을 볼 수 있음.

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 실행된 것을 확인할 수 있음.

 

등록 기능 완성했으니, 수정 / 조회 기능도 만들어보기.

PostsApiCotroller, Posts, PostsService에는 코드 추가하고, web / dto에 PostsResponseDto, PostsUpdateRequestDto 클래스 추가.

먼저, PostsApiController에 코드 추가하기

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
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);
    }

    // 수정기능
    @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);
    }
}

 

그리고, 새로 만든 클래스 PostsResponseDto에 코드 작성하기.

package com.jojoldu.book.springboot.web.dto;

import com.jojoldu.book.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의 필드 중 일부만 사용.

(Posts 엔티티에는 여러 필드가 있지만, 그 중 id, title, content, author만 DTO에 담겠다는 뜻.)

그래서 생성자로 Entity를 받아 필드에 값을 넣음.

굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리함.

(PostsResponseDto를 만들 때 모든 필드를 일일이 매개변수로 받아서 생성자를 만드는 대신, 그냥 posts 객체(Entity 전체)를 받아서 필요한 값만 getId(), getTitle() 등으로 꺼내오는 방식이 간편하고 효율적.)

 

PostsUpdateRequestsDto에 코드 작성.

package com.jojoldu.book.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에 코드 추가하기.

package com.jojoldu.book.springboot.domain.posts;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

 

PostsService에 코드 추가하기.

package com.jojoldu.book.springboot.service.posts;

import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
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();
    }
    
    // 수정기능
    @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의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함 여부에 따라 갈림.

JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태.

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분 반영.

즉, Entity 객체의 값만 변경하면 별도로 update 쿼리 날릴 필요 없는데 이러한 개념을 더티 체킹(Dirty Checking)이라고 함.

이해가 잘 되지 않아 찾아보고 정리한 것.

 

①. 업데이트 쿼리를 직접 날리지 않는다는 의미

 우리는 일반적으로 수정 쿼리 작성할 때, UPDATE posts SET title = '수정된 제목' WHERE id = 1; 이렇게 작성함.

 하지만 JPA에서는 update 쿼리를 직접 쓰지 않고, 데이터 변경 가능.

 

② 이게 가능한 이유는 '영속성 컨텍스트' 때문.

 영속성 컨텍스트는 JPA가 내부적으로 데이터를 관리하는 1차 캐시 메모리 저장소 같은 의미.

 우리가 DB에서 데이터 조회 시, 그 결과는 그냥 반환이 아닌 JPA 내부의 영속성 컨텍스트에 저장.

 이 컨텍스트 안에 있는 객체(엔티티)를 "영속상태"라고 함.

 즉, 데이터베이스에서 꺼낸 엔티티 객체가 JPA의 감시 대상이 되는 것.

 

③ 값을 변경하면 트랜잭션이 끝날 때 update 된다는 의미

 PostsService에 작성된 수정기능 코드를 보면, postRepository.findById(id)로 꺼낸 post는 영속상태를 의미.

 JPA는 이 객체를 영속성 컨텍스트에 등록하고, JPA가 감시하는 상태(영속 상태)가 되는 것.

 posts.update(requestDto.getTitle(), requestDto.getContent()); 코드에서 posts 객체의 내부 필드 값이 바뀜.

 별도로 save 하거나, update 호출하지 않았는데 트랜잭션 끝나는 시점에 값이 바뀌었고,

 UPDATE posts SET title = ?, content = ? WHERE id = ? 쿼리를 만들어서 DB에 날림.

 

④ 이러한 현상을 '더티 체킹'이라고 함.

 여기서 더티는 더럽다는 의미가 아닌, 변경된 상태라는 의미로 사용.

 JPA는 영속 상태의 객체가 처음 조회했을 때 상태와 비교해서 값이 바뀌었는지 감시 후, 변경되었으면 dirty라고 판단해서 트랜잭션 끝날 때 자동으로 update 쿼리를 실행하는 것.

 

위에 작성한 코드가 제대로 작동하는지 보기 위해 테스트 코드로 확인하기.

기존 테스트 코드의 PostsApiControllerTest에 코드 추가하기.

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 com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
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.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

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

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

    // 수정기능
    @Test
    public void PostsUpdate() throws Exception {
        // given
        Posts savePosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savePosts.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).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

 

실행해 보면, 테스트를 성공한 것을 볼 수 있음.

 

조회 기능은 실제로 톰캣을 실행해서 확인해 보기.

로컬 환경에선 데이터베이스로 H2를 사용함. 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야 함.

application.properties에 옵션 하나 추가하기.

spring.datasource.url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create

spring.h2.console.enabled=true

 

그리고, Application 클래스의 main 메서드 실행.

정상적으로 실행됐다면 톰캣 8080 포트로 실행된 것임.

웹 브라우저에 http://localhost:8080/h2-console로 접속하면 아래 화면이 나옴.

 

빨간 박스에 jdbc:h2:mem:testdb로 되어 있어야 함.

 

그리고, Connect 버튼 클릭하면 현재 프로젝트의 H2 관리할 수 있는 관리 페이지로 이동함.

다음과 같이 POSTS 테이블이 정상적으로 노출되어 있어야 함.

 

간단한 쿼리 실행하여 DB 조회해 봄.

현재는 등록된 데이터가 없으므로 빈 테이블이 나옴.

 

간단하게 insert 쿼리 실행해 보고 이를 API로 조회해 보자.

 

등록된 데이터 확인 후 API 요청해 보기.

브라우저에 http://localhost:8080/api/v1/posts/1을 입력하고 API 조회 기능 테스트해봄.

 

기본적인 등록, 수정, 조회 기능을 모두 만들고 테스트 해 봄.

특히 등록, 수정은 테스트 코드로 보호해 주고 있으니 이후 변경 사항이 있어도 안전하게 변경 가능.

 

3. JPA Auditing으로 생성시간, 수정시간 자동화하기

 

보통 Entity에는 해당 데이터의 생성 시간과 수정시간을 포함함.

이는 차후 유지보수에 있어 굉장히 중요한 정보이기 때문임. 그렇다 보니 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록, 수정하는 코드가 여기저기 들어가게 됨.

단순하고 반복적인 코드가 매번 모든 테이블과 서비스 메서드에 포함되어야 하는데 이 문제를 해결하는 것이 JPA Auditing임.

 

● LocalDate 사용

 

domain 패키지에 BaseTimeEntity 클래스 생성.

 

코드 작성하기.

package com.jojoldu.book.springboot.domain;

import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

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

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

!설명!

 

@MapperedSuperClass : JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 함.

 

@EntityListeners(AuditingEntityListener.class) : BaseTimeEntity 클래스에 Auditing 기능 포함함.

 

@CreatedDate : Entity가 생성되어 저장될 때 시간이 자동 저장됨.

 

@LastModifiedDate : 조회한 Entity의 값을 변경할 때 시간이 자동 저장.

 

그리고, Posts 클래스가 BaseTimeEntity 상속받도록 변경.

 

JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션 하나 추가.

 

기능이 잘 작동하는지 테스트 코드 작성해 보기.

PostsRepositoryTests 클래스에 테스트 메서드 하나 더 추가하기.

package com.jojoldu.book.springboot.domain.posts;

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

import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

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

    // 등록기능
    @Test
    public void saveBoard_call() {
        // given
        String title = "테스트게시글";
        String content = "테스트본문";

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

    // BaseTimeEntity 테스트
    @Test
    public void BaseTimeEntity_register() {
        // given
        LocalDateTime now = LocalDateTime.of(2019, 6, 4, 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);
    }
}

 

실행해 보면, 실제 시간이 잘 저장된 것을 확인해 볼 수 있음.

 

앞으로 추가될 엔티티들은 더 이상 등록일, 수정일 고민하지 말고 BaseTimeEntity만 상속받으면 자동으로 해결됨.