[3장] 스프링 부트에서 JPA로 데이터베이스 다뤄보자

미천한 개발중생·2023년 9월 14일
0

3장에서는 JPA에대한 설명과 사용방법에 대해 설명합니다.

JPA

Java Persistence API (JPA)는 Java 애플리케이션에서 관계형 데이터베이스와 상호 작용하는 데 사용되는 자바 ORM (Object-Relational Mapping) 기술입니다.

관계형 데이터베이스의 문제점

웹 어플리케이션 개발에서 관계형 데이터베이스는 빠질 수 없는 요소입니다. 그래서 객체를 데이터베이스에서 관리하는것이 중요하지만 몇가지 문제가 있습니다.

1.비효율적이다

  • 관계형 데이터베이스는 SQL만 인식할 수 있기 때문에, 존재하는 각 테이블마다 CRUD SQL을 생성해주어야 합니다. 프로젝트의 규모가 작으면 상관없겠지만 현업에서는 수십,수 백개의 테이블을 사용하는데 테이블마다 SQL을 생성해주어야 한다면 그것만큼 번거로운 일이 없을 것 같습니다.

2.패러다임 불일치

  • 관계형 데이터베이스는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술입니다. 그에 반해 Java는 객체지향 프로그래밍 언어로, 메세지를 기반으로 기능과 속성을 한 곳에서 관리하는 기술입니다. 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생합니다.

해결책

JPA를 사용합니다. 개발자는 객체지향적으로 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해주니 SQL에 종속적인 개발을 하지 않을 수 있습니다.


Spring Data JPA

JPA는 인터페이스로 사용하기 위해선 구현체가 필요합니다.
대표적으로 Hibernate, Eclipse Link등이 있습니다.
하지만 스프링에서는 Spring Data JPA의 사용을 권장합니다.

장점

1.구현체 교체의 용이성

  • Spring Data JPA 내부에서 구현체 매핑을 지원해주기 때문에 Hibernate 외에 다른 구현체로 쉽게 교체할 수 있다.

2.저장소 교체의 용이성

  • 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체할 수 있습니다. 예를 들어 MongoDB로 교체하기 위해선 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됩니다.

의존성 추가

application.properties에 의존성을 추가합니다.

implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')

spring-boot-starter-data-jpa

  • 스프링 부트용 Spring Data JPA 추상화 라이브러리입니다.
  • 스프링 부트 버전에 맞게 자동으로 라이브러리 버전을 관리해줍니다.

h2

  • 인메모리 관계형 데이터베이스입니다.
  • 프로젝트 의존성만으로 관리할 수 있습니다.
  • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화되어 테스트 용도로 많이 사용됩니다.

Domain 패키지 생성

도메인 패키지는 도메인을 담을 패키지입니다. MyBatis의 dao 패키지와는 조금 결이 다릅니다. MyBatis 사용시 xml에서 쿼리를 실행하고 클래스에 쿼리의 결과를 담던 일들이 JPA에서는 모두 도메인 클래스라고 불리는 곳에서 해결됩니다.

Posts클래스

@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
    @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

  • 테이블과 링크될 클래스임을 나타냅니다.
  • 클래스 카멜케이스 이름 => 테이블 언더스코어 네이밍
    ex) SalesManager.java => sales_manager 테이블

@Id

  • 해당 테이블의 PK 필드를 나타냅니다.

@GeneratedValue

  • PK의 생성 규칙을 나타냅니다.

@Column

  • 테이블의 컬럼을 나타내며 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용합니다.(굳이 선언하지 않더라도 해당 클래스의 필드는 모두 컬럼이 됩니다)

@NoArgsConstructor

  • 기본 생성자를 자동으로 추가해줍니다.

@Builder

  • 해당 클래스의 빌더 패턴 클래스를 생성합니다.
  • 생성자 상단에 선언 시 생성장에 포함된 필드만 빌더에 포함됩니다.

서비스 초기 구축단계에서 테이블 설계가 빈번하게 변경되기 때문에 롬복의 어노테이션을 사용하여 코드 변경량을 최소화 시켜줍니다.


Setter 메서드가 없는 이유

인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수 없기때문에 Entity 클래스에서는 절대 Setter 메서드를 만들지 않습니다. 해당 필드의 값 변경시에는 명확히 그 목적과 의도를 나타낼 수 있는 메서드를 추가하여 변경해줍니다.

Setter없이 DB에 값을 삽입하는 방법

@Builder에서 제공하는 클래스를 사용하면 어느 필드에 어떤 값을 채워야할지 명확하게 인지할 수 있습니다.

Example.builder()
	.a(a)
    .b(b)
    .build()

플러터로 모바일 개발을 해보고싶어서 Dart를 잠깐 공부한적이 있는데 위 예시 코드를 보고 개인적으로 Dart의 Named Constructor와 유사한것 같다는 생각이 들었습니다.


JpaRepository

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

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

DB Layer 접근자로 JPA에서는 Repository라고 부릅니다. 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>를 상속하면 CRUD 메서드가 자동으로 생성됩니다.

주의할 점

Entity 클래스와 기본 Entity repository는 함께 위치해야 합니다. 둘은 밀접한 관계이기 때문에 Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없습니다.

나중에 프로젝트 모가 커져 도메인별로 프로젝트를 분리해야 한다면 도메인 패키지에서 함께 관리합니다.


Spring Data Jpa 테스트 코드 작성

PostsRepositoryTest

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

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

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

        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("duawodud66@naver.com")
                .build()
        );

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

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

    }
}    

@AfterEach

JUnit5에서 @After -> @AfterEach로 변경되었습니다.

  • 단위테스트가 끝날 떄마다 수행되는 메서드를 지정합니다.
  • 보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용합니다.
  • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트가 실패할 수 있습니다.

postsRepository.save

  • 테이블 posts에 insert/update 쿼리를 실행합니다.
  • id 값이 있다면 update, 없다면 insert 쿼리가 실행됩니다.

postsRepository.finaAll

  • 테이블 posts에 있는 모든 데이터를 조회해오는 메서드입니다.

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


실제 실행된 쿼리 확인

쿼리 로그를 on/off 하기 위해서 스프링 부트에서는 application.properties, application.yaml등의 파일에 설정해주면 됩니다.

application.properties 파일을 생성후 다음 옵션을 추가합니다.

spring.jpa.show-sql=true

실행결과

출력 쿼리 MySQL버전으로 변경

기본값이 H2쿼리 문법으로 나옵니다. H2에서는 MySQL도 정상적으로 작동하기 때문에 MySQL을 적용해보겠습니다.

책에 나온 다음코드는 스프링부트가 버전업 되면서 Deprecated 되었다고합니다.

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

현재 제가 실습하고 있는 버전에서는 다음 코드를 사용해야합니다.

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.hikari.username=sa

이와 관련해 검색해보니 Issue에 이미 물어보신 분이 계셨고 이후 이동욱님 블로그
정리가 되어있으니 한번 보시는걸 추천합니다.

결과


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

API를 만들기 위해 3가지 클래스가 필요합니다.

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

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

스프링 웹 계층에서 비즈니스 처리를 담당해야 할 곳은 Domain 계층입니다.

**기존에 Service에서 처리하던 방식을 트랜잭션 스크립트라고 합니다.


클래스 생성

PostsApiController

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }
}   

PostsService

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

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

@Autowired를 사용하지 않는이유

스프링에서 Bean을 주입받는 방식은 다음 세가지입니다.

  • @Autowired
  • setter
  • 생성자

이중 권장하는 방법이 생성자로 주입받는 방식입니다. @RequiredArgsConstructor을 사용할 수 있습니다.

@RequiredArgsConstructor

final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해줍니다.
롬복 어노테이션을 사용함으로써 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 수정하는 번거로움을 해결할 수 있습니다.


PostsSaveRequestDto

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

}

View Layer와 DB Layer을 분리

Entity 클래스와 거의 유사한 형태임에도 Dto클래스를 추가로 생성했습니다. 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안됩니다. Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스입니다. 수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작하며 Entity 클래스를 변경하면 여러 클래스에 영향을 끼칩니다. 하지만 실제로 Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기 어려운 경우가 많습니다. 그래서 꼭 Entity 클래스와 Controller에서 쓸 DTO는 분리해서 사용해야 합니다.


PostsApiControllerTest

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

@SpringBootTest

HelloControllerTest와 달리 @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

@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.content = entity.getAuthor();
    }

}

PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣습니다.

PostsUpdateRequestDto

@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에 추가

...

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

PostsService에 추가

...

@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 객체의 값만 변경하면 됩니다. 이 개념을 더티 체킹이라고 합니다.


PostsApiControllerTest에 추가

...

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

    }

결과


조회 기능 확인

조회기능은 실제로 톰캣을 실행해서 확인해보겠습니다. H2는 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야 합니다.

그러기 위해서 application.properties에 다음 코드를 추가해줍니다.

spring.h2.console.enabled=true

추가 후 Application 클래스의 main 메서드를 실행합니다. 웹 브라우저에서 http://localhost:8080/h2-console 로 접속하면 웹 콘솔 화면이 나타납니다.

JDBC URL이 사진처럼 되있지 않다면 바꿔주시면 됩니다. connect 버튼을 클릭해 H2관리 페이지로 이동합니다.

쿼리 실행

쿼리를 입력하고 run버튼을 클릭하면 사진처럼 나올것입니다.

이번에는 insert문을 실행해 보겠습니다.

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

insert 실행 후 다음 주소로 이동하여 결과를 확인하면 됩니다.

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


JPA Auditing

보통 엔티티에는 차후 유지보수에 있어 중요한 정보이기 때문에 해당 데이터의 생성시간과 수정시간을 포함합니다. 그렇다보니 매번 DB에 삽입하기 전, 갱신 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 됩니다. 이런 번거로움을 해결하기 위해 JPA Auditing을 사용하겠습니다.

LocalDate

Java8 부터 LocalDate와 LocalDateTime이 등장했습니다. Java의 기본 날짜 타입인 Date의 문제점을 고친 타입입니다.

Data/Calendar의 문제점

1.불변객체가 아닙니다.

  • 멀티스레드 환경에서 언제든 문제가 발생할 수 있습니다.

2.Calendar는 월 값 설계가 잘못되었습니다.

  • 10월을 나타내는 숫자 값이 '9'입니다.
  • 이로인해 개발자들이 혼란을 겪었습니다.

BaseTimeEntity

Domain 패키지에 BaseTimeEntity 클래스를 생성합니다.

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

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;

}

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

@MappedSuperclass

JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 컬럼으로 인식하도록 합니다.

@EntityListeners(AuditingEntityListener.class)

BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.

@CreatedDate

Entity가 생성되어 저장될 때 자동 저장됩니다.

@LastModifiedDate

조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.


Posts 클래스에서 BaseTimeEntity 상속

	...

public class Posts extends BaseTimeEntity {
	...
}

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

@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

PostsRepositoryTest - JPA Auditing 테스트코드 작성

...

@Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.now();
        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만 상속받으면 되기 때문에 더 이상 등록일/수정일로 고민할 필요가 없습니다.

profile
공부 목적의 블로그 입니다. 부족한 점이 많으니 잘못된 정보가 있다면 지적부탁드려요!

0개의 댓글