스프링 부트에서 JPA로 데이터베이스 다루기


http://www.yes24.com/Product/Goods/83849117

JPA

자바 표준 ORM(Object Relational Mapping) 기술
MyBatis, iBatis는 ORM이 아니라 SQL Mapper
ORM은 객체를 매핑하는 것이고, SQL Mapper는 쿼리를 매핑함

SI 환경에서는 Spring & MyBatis 를 많이 사용함
쿠팡, 우아한형제들, NHN등 자사 서비스를 개발하는 곳에서는 SpringBoot & JPA를 전사 표준으로 사용함

JPA 사용 이유

현대의 웹 애플리케이션에서 관계형 데이터베이스(RDB = Relational Database)는 빠질 수 없는 요소
Oracle, MySQL, MSSQL등을 쓰지 않는 웹 애플리케이션은 거의 없음
그래서 객체를 관계형 데이터베이스에서 관리하는 것이 엄청 중요함
현업 프로젝트 대부분이 애플리케이션 코드보다 SQL로 가득차게됨
이는 관계형 데이터베이스가 SQL만 인식할 수 있기 때문
그래서 각 테이블마다 기본적인 CRUD(create, read, update, delete)SQL을 매번 생성해야함

ex) User 객체를 테이블로 관리시

insert into User (id, name, ...) values (...);
select * from user where ...;
update user set ... where ...;
delete from user where ...;

현업에서는 수십 수백 개의 테이블이 있고 이 테이블의 몇배의 SQL을 만들고 유지 보수해야함 -> 단순 반복 작업을 수백번 해야함

관계형 데이터베이스는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술이고 객체지향 프로그래밍 언어는 기능과 속성을 한 곳에서 관리하는 기술 -> 패러다임 불일치

JPA는 서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술

개발자는 객체 중심으로 개발을 하게 되니 생산성 향상, 유지 보수 쉬워짐

Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서
인터페이스인 JPA를 사용하기 위해서는 Hibernate, Eclipse Link 등 구현체가 필요함
Spring에서 JPA를 사용할 때는 이 구현체들을 직접 다루지 않고 이를 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈 사용

Hibernate를 쓰는 것과 Spring Data JPA를 쓰는 것 사이에는 큰 차이가 없음
Spring Data JPA는 Hibernate 이후 다른 JPA 구현체가 히트일때 구현체 교체가 용이함
또 JPA로 감당하기 힘든 트래픽 증가로 MongoDB로 교체가 필요할 때 Spring Data MongoDB로 의존성만 교체할 수 있음 -> 저장소 교체의 용이성
이는 Spring Data 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문

웹 애플리케이션 요구사항 분석

게시판 기능

게시글 조회
게시글 등록
게시글 수정
게시글 삭제

회원 기능

구글 / 네이버 로그인
로그인한 사용자 글 작성 권한
본인 작성 글에 대한 권한 권리

프로젝트에 Spring Data JPA 적용하기

build.gradle 에 compile('org.springframework.boot:spring-boot-starter-data-jpa'), compile('com.h2database:h2')의존성 등록

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    annotationProcessor('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

spring-boot-starter-data-jpa

스프링 부트용 Spring Data Jpa 추상화 라이브러리
스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해줌

h2

인메모리 관계형 데이터베이스
별도의 설치가 필요 없이 프로젝트 의존성만으로 관리할 수 있음
메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화되기 때문에 테스트 용도로 많이 사용됨
JPA의 테스트, 로컬 환경에서의 구동에서 사용할 예정

domain 패키지 생성

도메인(게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역)을 담을 패키지
이전에 xml에 쿼리를 담고, 클래스는 쿼리의 결과만 담던 일이 모두 도메인 클래스에서 해결

posts 패키지, Posts 클래스 생성

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

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

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

@Entity

테이블과 링크될 클래스임을 나타냄
기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍으로 테이블 이름을 매칭함

@Id

해당 테이블의 PK 필드를 나타냄

@GeneratedValue

PK의 생성 규칙을 나타냄
.IDENTITY 옵션으로 auto_increment 설정

@Column

테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 됨
기본값 외에 추가로 변경이 필요한 옵션이 있을때 사용함
문자열의 경우 VARCHAR(255)가 기본값인데 사이즈를 늘리고 싶거나 타입을 TEXT로 변경하고 싶을때 사용

@NoArgsConstructor

기본 생성자 자동 추가
public Posts(){} 와 같은 효과

@Getter

클래스 내 모든 필드의 Getter 메소드를 자동 생성

@Builder

해당 클래스의 빌더 패턴 클래스를 생성
생성자 상단에 선언 시 생성자에 포함된 빌더에 포함

Entity의 PK는 Long 타입의 Autu_increment가 좋음

주민등록번호와 같이 비즈니스상 유니크 키나, 여러 키를 조합한 복합키로 PK를 잡을 경우 난감한 상황 종종 발생

FK를 맺을 때 다른 테이블에서 복합키를 전부 갖고 있거나, 중간 테이블은 하나 더 둬야하는 상황이 발생할 수 있음
인덱스에 좋은 영향을 끼치지 못함
유니크한 조건이 변경될 경우 PK 전체를 수정해야하는 일 발생

주민등록번호, 복합키 등은 유니크 키로 별도로 추가하는 것이 좋음

서비스 초기 구축 단계에선 테이블 설계가 빈번하게 변경되는데, 이때 롬복의 어노테이션들은 코드 변경량을 최소화시켜주기 때문에 사용하면 좋음

Posts 클래스에 Setter 메소드가 없음

@Setter 대신 @Builder 를 사용하여어느 필드에 어떤 값을 채워야할지 명확하게 인지 가능함

JpaRepository 생성

PostsRepository interface 생성

import org.springframework.data.jpa.repository.JpaRepository;

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

Posts클래스로 Database를 접근하게 해줌
MyBatis에서 Dao라고 불리는 DB Layer 접근자
JPA에선 Repository 라고 부르면 인터페이스로 생성함
단순히 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>를 상속하면 기본적인 CRUD 메소드가 자동으로 생성됨

Entity 클래스와 Entity Repository는 함께 위치해야함

Spring Data JPA 테스트 코드 작성

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;

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

@RunWith(SpringRunner.class)
@SpringBootTest
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("[email protected]")
                .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에 있는 모든 데이터를 조회해오는 메소드

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

실제로 실행된 쿼리 확인하기

src/main/resources 아래에 application.properties 파일 생성

spring.jpa.show_sql=true

테스트 코드 재실행


실제로 실행된 쿼리 확인

출력되는 쿼리 로그를 MySQL 버전으로 변경

application.properties 에 코드 추가

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

테스트 코드 재실행

등록 수정 조회 API 만들기

API를 만들기 위해 필요한 클래스 3가지

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

Service에서 비즈니스 로직을 처리해야하는 것은 아님
Service는 트랜잭션, 도메인 간 순서 보장의 역할만 함

Spring 웹 계층

Web Layer

흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역
이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice)등 외부 요청과 응답에 대한 전반적인 영역을 이야기함

Servicec Layer

@Service에 사용되는 서비스 영역
일반적으로 Controller와 Dao의 중간 영역에서 사용됨
@Transaction이 사용되어야 하는 영역

Repository Layer

Database와 같이 데이터 저장소에 접근하는 영역
Dao(Data Access Object)영역으로 이해하면 쉬움

Dtos

Dto(Data Tranfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기함
Dtos는 이들의 영역
뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기함

Domain Model

도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것
ex) 택시 앱이라면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있음
@Entity가 사용된 영역 역시 도메인 모델
무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아님
VO처럼 값 객체들도 이 영역에 해당하기 때문임

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

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

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

주문 취소 로직을 도메인 모델에서 처리할 경우 객체들이 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해줌

PostsApiController 클래스 생성

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class PostsApiController {
    
    private final PostsService postsService;
    
    @PutMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }
}

PostService 클래스 생성

import com.jojoldu.book.springboot.domain.posts.PostsRepository;
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
생성자

이중 가장 권장하는 방식이 생성자로 주입받는 방식임
생성자로 Bean 객체를 받도록하면 @Autowired와 동일한 효과를 볼 수 있음

생성자는 @RequiredArgsConstructor에서 해결해줌
final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해줌

PostsSaveRequestDto 생성

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

Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성했음

절대로 Entity클래스를 Request/Response 클래스로 사용해서는 안됨

수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작함
Entity 클래스가 변경되면 여러 클래스에 영향을 끼침
Request와 Response용 Dto는 View를 위한 클래스라 원래 자주 변경함

View Layer와 DB Layer의 역할 분리를 철저하게 하는 것이 좋음
실제로 Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하여 Entity 클래스만으로 표현하기가 어려운 경우가 많음

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

테스트 코드 작성

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;

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

@RunWith(SpringRunner.class)
@SpringBootTest
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("[email protected]")
                .build());

        //when
        List<Posts> postsList = postsRepository.findAll();

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

Api Controller를 테스트 하는데 HelloController와 달리 @WebMvcTest를 사용하지 않음

@WebMvcTest의 경우 JPA 기능이 작동하지 않기 때문

Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화 되니 JPA 기능까지 한번에 테스트 할때는 @SpringBootTest와 TestRestTemplate를 사용하면됨

테스트 결과

해결 방법

해결 완료

수정 조회 기능 추가

PostsApiController

@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

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의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣음
굳이 모든 필드를 가진 생성자가 필요하지 않으므로 Dto는 Entity를 받아 처리함

PostsService

@Transactional
    public Long update(Long id, PostdUpdateRequestDto 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의 엔티티 매니저가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본 옵션)트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태

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

Posts 클래스 update 함수 추가

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

PostsUpdateRequestDto 클래스 생성

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

테스트 코드 추가

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

Update 쿼리 수행됨

톰캣을 실행하여 조회 기능 확인하기

application.properties에 옵션 추가

spring.h2.console.enabled=true

Application 클래스의 main 메소드 실행

http://localhost:8080/h2-console 에 접속

JDBC URL 설정

쿼리 실행

insert 쿼리 실행

insert into posts (author, content, title) values ('author', 'content', 'title');

API 조회 기능 테스트

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

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

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

LocalDate 사용

Java8 부터 LocalDate와 LocalDateTime이 등장했음
Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입

기본 날짜 타입 Date의 문제점

변경이 가능한 객체임 -> 멀티스레드 환경에서 문제 발생할 수 있음
Calendar는 월(Month) 값 설계가 잘못되었음
Calendar.OCTOBER의 숫자 값은 9

domain 패키지에 BaseTimeEntity 클래스 생성

import lombok.Getter;
import org.apache.tomcat.jni.Local;
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 createdDate;
    
    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

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

@MappedSuperclass

JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록함

@EntityListeners(AuditingEntityListener.class)

BaseTimeEntity 클래스에 Auditing 기능을 포함시킴

@CreatedDate

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

@LastModifiedDate

조회한 Entity의 값을 변경할 때 시간이 자동 저장됨

Posts 클래스가 BaseTimeEntity를 상속 받도록 변경

public class Posts extends BaseTimeEntity{...}

Application 클래스에 활성화 어노테이션 추가

JPA Auditing 어노테이션들을 모두 활성화 하기 위함

@EnableJpaAuditing
@SpringBootApplication
public class Application {
    public static void main(String[] args){
        SpringApplication.run(Application.class, args);
    }
}

테스트 코드 작성

PostsRepositoryTest 클래스에 테스트 메소드 추가

@Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2021,7,19,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(">>>>>>>>>> createdDate="+posts.getCreatedDate()+", modifiedDate="+posts.getModifiedDate());

        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);
    }

실제 시간 저장됨

앞으로 추가될 엔티티들은 BaseTimeEntity 상속으로 등록일 수정일 해결

좋은 웹페이지 즐겨찾기