개발자의 서재

Chapter.03 : 스프링부트에서 JPA 로 데이터베이스를 다뤄보자 본문

SpringBootProject/SpringBoot_ Oauth_AWS

Chapter.03 : 스프링부트에서 JPA 로 데이터베이스를 다뤄보자

ironmask431 2022. 2. 11. 22:59

"스프링부트와 AWS로 혼자 구현하는 웹 서비스" 라는 책을 바탕으로 학습목적의 프로젝트를 진행하고 있습니다. 

 

소스 : https://github.com/ironmask431/springboot_aws_01

 

Chapter 03. 스프링 부트에서 JPA로 데이터베이스를 다뤄보자

3.1 JPA소개

ORACLE, MYSQL 등 관계형데이터베이스 필수요소이고 웹서비스의 중심이 SQL중심이됨.
SQL은 객체지향 프로그래밍과 맞지않는 부분이 있음.
기존 관계형 데이터베이스방식은 객체모델링보다는 테이블모델링에 집중하게되고
객체를 단순히 테이블형식에 맞추어 데이터 전달역할만 하는 기형적인 형태임.
JPA(자바 표준 ORM)는 SQL에 종속적인 개발을 하지않도록 할 수 있게하여, 객체지향 프로그래밍을 추구하게 해줌.
JPA를 사용하기 위해서는 Hibernate 같은 구현체가 필요함.
구현체들을 좀더 쉽게 사용할 수 있는 Spring Data JPA라는 모듈을 사용함.

  • Spring Data JPA 의 장점
구현체 교체의 용이성
저장소 교체의 용이성

새로운 구현체나 새로운 db 로 교체가 용이함.

3.2 프로젝트에 Spring Data Jpa 적용하기

1.build.gradle 에 jpa, h2 라이브러리 등록

dependencies 에 추가

//springboot용 spring data jpa 추상화 라이브러리   
implementation('org.springframework.boot:spring-boot-starter-data-jpa')   
//인메모리 관계형 데이터베이스, 별도의 설치없이 사용가능한 db, 애플리케이션 재시작마다 초기화   
implementation('com.h2database:h2')   

2.domain.post 패키지 생성 / posts 패키지와 Posts.java 클래스 생성

/* Entity 클래스에는 setter 를 만들지않는다. setter가 있는 경우
 * 인스턴스 값들이 언제 어디서 변하는지 코드상으로 명확히 구분이 어려워 복잡해짐
 * 그럼 어떻게 값을 채우는가?
 * 생성자를 통해 최종값을 채운 후 db에 삽입하고,
 * update는 해당이벤트에 맞는 public 메소드를 호출하여 변경
 * 여기서는 생성자 대신에 @Builder 클래스를 통해 값을 채워준다.
 * 생성자보다 빌더패턴을 사용 시 어떤 필드에 어떤값을 채워야 할지 명확하게 인지 할 수 있어서 좋음.
 * */

@Entity //db테이블과 링크될 class임을 표시
@Getter //클래스내의 모든 필드에 getter 메소드 자동생성
@NoArgsConstructor //기본 생성자 자동 추가 (인자값이 없는 생성자)
public class Posts extends BaseTimeEntity {

    @Id //해당 테이블의 pk필드
    @GeneratedValue(strategy = GenerationType.IDENTITY) //pk생성규칙 (auto increment)
    private Long id;

    @Column(length = 500, nullable = false) //테이블의 컬럼
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    @Builder //해당클래스의 빌더패턴 생성. 생성자 대신에 @Builder으로 값을 채워주는게 더 좋음.
    public Posts(String title, String content, String author){
        this.title = title;
        this.content = content;
        this.author = author;
    }
}

3.interface PostsRepository.java 생성

public interface PostsRepository extends JpaRepository<Posts, Long> {
/*보통 mybatis 에서 dao 라고 불리는 db layer 접근자
    * jpa에서는 Repository 라고 부르며 인터페이스로 생성함.
    * 생성 후 JpaRepository<Entity클래스, pk타입> 을 상속하면
    * 기본적인 crud 메소드가 자동으로 생성됨.
    * Entity클래스 와 Repository 는 같은곳에 위치해야함. 밀접한 관계
    * */
}

 

3.3 Spring Data JPA 테스트코드 작성하기

1.PostsRepositoryTest.java 생성, 테스트실행

@RunWith(SpringRunner.class)
@SpringBootTest //SpringBootTest 를 사용할 경우 h2 데이터베이스를 자동실행해줌
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

    @After //junit 에서 단위테스트가 끝날때마다 수행되는 메소드, 여러테스트가 동시
    //수행될때 h2에 데이터가 남아잇으면 다음테스트시 테스트사 실패할 수 있음.
    public void cleanup(){
        postsRepository.deleteAll(); //전체 삭제
    }

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

        //postsRepository.save() : 테이블 posts에 insert/update 실행
        //id값이 있다면 update 없다면 insert 실행됨.
        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("leesk83@sk.com")
                .build());

        //when
        //postsRepository.findAll() : 테이블 posts에 있는 모든 데이터조회
        List<Posts> postsList = postsRepository.findAll();

        //then
        //조회한 데이터의 첫번째 row의 title과 content가 제대로 저장,조회되었는지 확인
        Posts posts = ((List<Posts>) postsList).get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
        //테스트 성공
    }
}

테스트 정상 확인

 

2.테스트시 콘솔에서 실제 쿼리 확인하기

src/main/resources/ 에 파일생성 - application.properties 내용추가

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

테스트 실행시 콘솔에서 실제 create, insert sql문을 볼 수 있다.

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

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

  • Request 데이터를 받을 dto
  • api 요청을 받을 controller
  • 트랜잭션,도메인 기능간의 순서를 보장하는 service

2.Spring WEB 계층

  • Web Layer : 흔히 사용하는 컨트롤러 @Controller 와 jsp등 뷰템플릿영역
  • Service Layer : @Service 영역 Controller 와 Dao의 중간영역 @Transactional 이 사용되어야함.
  • Repository Layer :DB에 접근하는 영역 예전 DAO 영역
  • Dtos :계층간에 데이터전달을 위한 객체
  • Domain model :@Entity 등을 의미 , 비즈니스 처리를 담당해야 하는곳

기존 spring 방식은 모든 로직이 서비스클래스에서만 처리됨. (컨트롤러나 service)
그러다보니 서비스계층을 나눠놓은것이 무의미함.
그러나 이것을 도메인모델에서 처리하면 단순하게 처리 할수있음.

//예시
derivery.cancel();   
order.cancel();   
billing.cancel();

각자가 본인의 취소이벤트를 하며, 서비스메소드는 이들의 순서만 정해줌.

 

3.Controller, Dto, Service 를 만들어보자.

PostsSaveRequestDto.java 생성

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {

    /**
     * Entity 클래스와 유사한형태이지만 Dto 클래스를 따로 만들어줌.
     * 그 이유는 Entity를 Request, Response 용 Dto 클래스로 사용해선 안되기 때문.
     * Entity 클래스는 DB와 맞닿은 핵심 클래스로 Entity 클래스를 기준으로 테이블이 생성되고
     * 스키마가 변경됨. 화면 변경을 위해 Entity 클래스가 수정되면 안됨.
     * View Layer와 DB Layer를 철지하게 분리하기위함.
     * Entity 클래스와 컨트롤러에서 쓸 Dto 클래스는 꼭 분리해서 쓴다.
     */

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

    //dto로 받은 값들을 DB에 저장하기위해 Entity클래스에 데이터를 입력해야하므로
    //toEntity() 를 통해 Dto의 값들을 Entity에 입력한다.
    public Posts toEntity(){
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

PostsService.java 생성 

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

    //postsRepository 를 통해서 insert를 하고, pk id를 리턴받음
    public Long save(PostsSaveRequestDto requestDto){
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

PostsApiController.java 생성

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    /**
     * 기존 스프링에서는 Controller 와 Service에서 @Autowired 를 통해서 bean을 주입받으나 여기선 안씀.
     * 대신 생성자를 통해 주입받는다. 생성자는
     * @RequiredArgsConstructor 롬복 어노테이션을 통해서 자동으로 생성된다.
     * (클래스에서 final 이 선언된 모든 필드를 인자값으로 하는 생성자를 생성해줌.)
     */
    private final PostsService postsService;

    //게시글 저장, id return
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }
}

 

4. PostsApiControllerTest.java  를 만들어 게시글등록 기능을 테스트해보자

//@WebMvcTest 대신 @SpringBootTest 를 사용한 이유
//@WebMvcTest의 경우 jpa기능이 동작하지 않으므로 jpa가 포함된 기능을 테스트시에는
//@SpringBootTest 와 TestRestTemplate 를 사용하면 된다.
//SpringBootTest.WebEnvironment.RANDOM_PORT : 랜덤포트로 테스트할 시 사용
@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();

        //PostsApiController 에 게시글 저장 url을 설정
        String url = "http://localhost:"+port+"/api/v1/posts";

        //when
        //ResponseEntity 를 이용해서 해당url 에 requestDto로 게시글저장 실행
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        //요청응답 status 정상확인
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 
        //저장후 키값이 정상리턴되었는지 확인
        assertThat(responseEntity.getBody()).isGreaterThan(0L); 
        

//게시글전체 조회
        List<Posts> all = postsRepository.findAll(); 
        //게시글 첫번재row의 데이터가 정상인지 확인
        assertThat(all.get(0).getTitle()).isEqualTo(title); 
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

게시글등록 기능 테스트 정상 확인

 

5. 게시글 업데이트, 게시글 보기 소스 추가

PostResponseDto.java 신규생성

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

PostUpdateRequestDto.java 신규생성

@Getter
@NoArgsConstructor
public class PostUpdateRequestDto {

    private String title;
    private String content;

    @Builder
    public PostUpdateRequestDto(String title, String content){
        this.title = title;
        this.content = content;
    }
}

PostsApiController.java > update, findById 메소드 추가

    //게시글 수정
    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostUpdateRequestDto requestDto){
        return postsService.update(id,requestDto);
    }

    //게시글 조회
    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById (@PathVariable Long id){
        return postsService.findById(id);
    }

Posts.java > update 메소드 추가 

    public void update(String title, String content){
        this.title = title;
        this.content = content;
    }

PostsService.java > update, findById 메소드 추가

//update 메소드에 postsRepository를 통해 별도로 쿼리를 날리는 부분이없다.
//이게 가능한 이유는 jpa의 영속성 컨텍스트 때문. 영속성 컨텍스트란 엔티티를 영구저장하는 환경
//jpa의 엔티티매니저가 활성화된 상태로(spring data jpa를 쓴다면 기본옵션)
//트랜잭션(@Transactional)안에서 db에서 데이터(Entity)를 가져오면 이 데이터는 영속성 컨텍스트가 유지된상태
//이 상태에서 해당데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 자동으로 update가 됨.
//Entity 객체의 값만 변경하면 별도로 update 쿼리를 날릴 필요가없음.
//이것을 jpa의 '더티체킹' 이라고함.
@Transactional
public Long update(Long id, PostUpdateRequestDto 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);
}

 

6.  PostsApiControllerTest.java에 게시글 수정 테스트 코드 작성

//게시글 수정 테스트
@Test
public void Posts_수정된다() throws Exception {
    //given

    //테스트용 게시글 db 입력
    Posts savedPosts = postsRepository.save(
            Posts.builder().title("title").content("content").author("author").build()
    );

    Long updateId = savedPosts.getId();
    String newTitle = "title2";
    String newContent = "content2";

    //게시글 수정에 사용할 requestDto 생성
    PostUpdateRequestDto requestDto = PostUpdateRequestDto.builder()
    	.title(newTitle).content(newContent).build();

    //PostsApiController 에 게시글 수정 url을 설정
    String url = "http://localhost:"+port+"/api/v1/posts/"+updateId;

    //게시글 수정 요청을 보낼 requestDto를 담은 requestEntity 생성
    HttpEntity<PostUpdateRequestDto> 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(newTitle);
    assertThat(all.get(0).getContent()).isEqualTo(newContent);
}

-> 게시글 수정 테스트 정상 확인

 

7. 조회기능은 톰캣을 실행해서 브라우저에서 확인 가능함. 

H2데이터베이스 툴을 사용하기 위해서는 웹콘솔을 사용해야한다.

 

application.properties 에 추가 

//h2 db툴을 브라우저에서 사용가능하도록 해줌.
spring.h2.console.enabled=true

추가 후 application.java 구동 

localhost:8080/h2-console 접속

웹콘솔 화면 확인함. > JDBC URL 에 "jdbc:h2:mem:testdb" 입력 후 Connect 클릭

> DB tool이 브라우저에 나타난다. 

> Posts 테이블을 확인 할 수 있다.

> 테스트용 데이터를 insert 후 확인 해본다. 

INSERT INTO POSTS (author, content, title )VALUES ('author','content','title');

select * from posts;

그리고 브라우저에서 아래 url (조회) 접속

http://localhost:8080/api/v1/posts/1

{"id":1,"title":"title","content":"content","author":"author"}

위 와 같이 조회결과가 정상 출력되는 것을 확인 할 수 있다.

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

1.domain 패키지에 BaseTimeEntity.java 생성

/*
BaseTimeEntity 클래스는 모든 Entity의 상위클래스가 되어 Entity들의
CreatedDate, LastModifiedDate를 자동으로 관리해준다.

@MappedSuperclass : jpa Entity 클래스들이 BaseTimeEntity를 상속할 경우
필드들 (createDate, modifiedDate)도 컬럼으로 인식하게 합니다.

@EntityListeners(AuditingEntityListener.class) :
BaseTimeEntity 에 Auditing 기능 부여 (Entity 생성,수정 시간저장기능)

@CreatedDate : Entity 가 생성되어 저장될때 시간이 자동저장됨.
@LastModifiedDate : 조회한 Entity의 값을 변경할 때 시간이 자동저장됨.
 */
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createDate;
    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

2. Posts 클래스가 BaseTimeEntity를 상속받도록함.

public class Posts extends BaseTimeEntity {
	.....
}

3. JPA Auditing 을 사용할 수 있도록 Application 클래스에 어노테이션추가

@EnableJpaAuditing //JPA Auditing 활성화
@SpringBootApplication
public class Application {
    .....
}

4. PostsRepositoryTest.java에 JPA Auditing 테스트코드 작성

@Test
public void BastTimeEntity_등록(){
    //given
    //오늘 날짜 생성
    LocalDateTime now = LocalDateTime.of(2022,2,5,0,0,0);

    //신규 Posts 저장
    postsRepository.save(Posts.builder()
            .title("title")
            .content("content")
            .author("author")
            .build());
    //when
    List<Posts> list = postsRepository.findAll();

    //then
    Posts posts = list.get(0);
    //저장된 date값 확인
    System.out.println(">>>>creatDate = "+posts.getCreateDate()+"/modifiedDate = "+posts.getModifiedDate());

    //post의 date값들이 설정한 날짜보다 미래인지 확인
    assertThat(posts.getCreateDate()).isAfter(now);
    assertThat(posts.getModifiedDate()).isAfter(now);
}

=> 저장한 Posts Entity에 createDate 와 modifiedDate 가 잘 저장된것을 확인 할 수있다.

Comments