어떻게 프로젝트를 시작하게 되었고, 진행하면서 느낀 개발자와 디자이너의 생생한 스토리를 직접 확인해보세요!
Development
스프링 부트에서 JPA로 데이터베이스 다뤄보기
#Back-End
IlGoo Yeo
2023. 9. 18.
스프링 부트에서 JPA로 데이터베이스 다뤄보기
스터디 1주차(챕터3)
JPA의 필요성
웹 애플리케이션에서 RDB는 빠질 수 없는 요소
하지만, RDB로 인해 2가지 문제점이 있음
SQL 사용에 따른 단순 반복 작업이 늘어남
현업에서 테이블이 너무 많기 때문에, 대부분의 코드가 SQL인 경우가 빈번
패러다임의 불일치
RDB(어떻게 데이터를 저장할 것인가) VS OOP(기능과 속성을 한 곳에서 관리)
예시 코드(User와 Group이 부모-자식 관계임을 보여줌)
User user = findUser();Group group = user.getGroup();
DB를 추가(User와 Group의 관계와 상관없이 따로 조회)
User user = userDao.findUser();Group group = groupDao.findGroup(user.getGroupId());
JPA는 OOP와 RDB를 중간에서 패러다임 일치를 시켜주기 위한 기술
JPA를 통해 개발자는 객체지향적인 프로그래밍을 하면 JPA가 RDB에 맞게 SQL을 대신 생성하여 실행해줘 SQL에 종속적인 개발을 하지 않아도 됨
보다 자세한 건 ORM을 찾아보자
Spring Data JPA
인터페이스인 JPA를 사용하기 위해서는 구현체가 필요
Spring에서 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 모듈이 Spring Data JPA
JPA <- Hibernate <- Spring Data JPA
Hibernate를 안 쓰고 Spring Data JPA를 쓰는 이유는 크게 2가지
구현체 교체의 용이성
Hibernate 외에 다른 구현체로 쉽게 교체 가능
저장소 교체의 용이성
RDB 외에 다른 저장소로 쉽게 교체 가능
e.g. 트래픽이 증가하여 RDB를 MongoDB로 교체할 경우 Spring Data MongoDB로 의존성만 교체하면 됨
Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문에 가능
예제 요구사항
게시판 기능
게시글 조회
게시글 등록
게시글 수정
게시글 삭제
회원 기능
구글/네이버 로그인
로그인한 사용자 글 작성 권한
본인 작성 글에 대한 권리
프로젝트에 Spring Data JPA 적용
build.gradle
implementation('org.springframework.boot:spring-boot-starter-data-jpa') // Spring-boot용 Spring Data JPA 추상화 라이브러리
// 인메모리 관계형 DB로 별도의 설치 없이 프로젝트 의존성만으로 관리 가능
// 메모리에서 실행돼 앱을 재시작할 때마다 초기화되고, 이를 이용해 테스트 용도로 많이 사용
implementation('com.h2database:h2')
Post.java
package com.jojoldu.book.springboot.domain.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;// 주요 어노테이션을 클래스에 가깝게// Kotlin 등 새 언어 전환 시 불필요한 어노테이션(e.g. lombok의 어노테이션) 삭제에 용이@Getter // 클래스내 모든 필드에 Getter 자동 생성@NoArgsConstructor // 기본 생성자 추가@Entity // 테이블과 링크될 클래스임을 나타냄public class Posts{ @Id // 해당 테이블의 PK 필드 @GeneratedValue(strategy = GenerationType.IDENTITY) // PK의 생성 규칙 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 클래스는 절대 Setter 메소드를 만들지 않음
클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확한 구분이 어려워지기 때문
잘못된 예시
public class Order{ public void setStatus(boolean status) { this.status = status; }}public void 주문서비스의_취소이벤트(){ order.setStatus(false);}
올바른 예시
public class Order{ public void cancelOrder() { this.status = false; }}public void 주문서비스의_취소이벤트(){ order.cancelOrder();}
기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것
값 변경은 해당 이벤트에 맞는 public 메소드를 호출
여기서는 @Builder를 통해 제공되는 빌더 클래스를 사용
지금 채워야 할 필드를 명확히 지정 가능
생성자 예시
// 아래의 경우 new Example(b, a)처럼 a와 b의 위치를 변경해도 코드를 실행하기 전에는 문제를 찾을 수 없음public Example(String a, String b){ this.a = a; this.b = b;}
빌더 패턴 예시
Example.builder() .a(a) .b(b) .build();
PostsRepository.java
// Entity 클래스와 기본 Entity Repository는 항상 함께 위치package com.jojoldu.book.springboot.domain.posts;import org.springframework.data.jpa.repository.JpaRepository;public interface PostsRepository extends JpaRepository<Posts, Long>{}
Spring Data JPA 테스트 코드 작성
PostsRepositoryTest.java
package com.jojoldu.book.springboot.domain.posts;import org.junit.jupiter.api.AfterEach;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.context.junit.jupiter.SpringExtension;import java.util.List;import static org.assertj.core.api.Assertions.assertThat;@ExtendWith(SpringExtension.class)@SpringBootTestpublic class PostsRepositoryTest{ @Autowired PostsRepository postsRepository; // 단위 테스트가 끝날 때마다 수행되는 메소드를 지정 // 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용 @AfterEach public void cleanup() { postsRepository.deleteAll(); } @Test public void savePost() { // given String title = "테스트 게시글"; String content = "테스트 본문"; postsRepository.save(Posts.builder() // 테이블 posts에 insert/update 쿼리 실행(id 값이 있으면 update, 없으면 insert) .title(title) .content(content) .author("jojoldu@gmail.com") .build()); // when List<Posts> postsList = postsRepository.findAll(); // 테이블 posts에 있는 모든 데이터 조회 // then Posts posts = postsList.get(0); assertThat(posts.getTitle()).isEqualTo(title); assertThat(posts.getContent()).isEqualTo(content); }}
// 기존 importimport com.jojoldu.book.springboot.web.dto.PostsResponseDto;@RequiredArgsConstructor@RestControllerpublic class 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 PostsReponseDto findById(@PathVariable Long id) { return postsService.findById(id); }}
@ExtendWith(SpringExtension.class)@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)public class PostsApiControllerTest{ // 기존 코드 @Test public void PostsUpdate() 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); }}
웹 콘솔에서 DB에 접근해보기
application.properties
// 다음과 같이 수정spring.jpa.show-sql=truespring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialectspring.jpa.properties.hibernate.dialect.storage_engine=innodbspring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQLspring.h2.console.enabled=true
이후 http://localhost:8080/h2-console로 접속
SQL로 데이터 조회, 추가
http://localhost:8080/api/v1/posts/1으로 접속해 API 조회 기능 테스트
JPA Auditing으로 생성시간/수정시간 자동화
보통 엔티티에는 데이터의 생성시간과 수정시간을 포함함
이 두 정보는 차후 유지보수에서 중요한 정보이기 때문
그렇다 보니 매번 DB에 삽입/갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 사용됨
// 생성일 추가 코드 예시public void savePosts(){ // ... posts.setCreateDate(new LocalDate()); postsRepository.save(posts);}
위의 과정을 JPA Auditing으로 자동화 가능
Java8부터 등장한 LocalDate와 LocalDateTime 사용
LocalDate 사용
BaseTimeEntity.java
package com.jojoldu.book.springboot.domain;import lombok.Getter;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 // JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우, 필드들도 칼럼으로 인식되게 함@EntityListeners(AuditingEntityListener.class) // BaseTimeEntity 클래스에 Auditing 기능 포함public class BaseTimeEntity{ @CreatedDate // Entity가 생성되어 저장될 때 시간이 자동으로 저장 private LocalDateTime createdDate; @LastModifiedDate // 조회한 Entity의 값을 변경할 때 시간이 자동 저장 private LocalDateTime modifiedDate;}
Posts.java
public class Posts extends BaseTimeEntity {}
Application.java
@EnableJpaAuditing // 어노테이션 추가하여 JPA Auditing 어노테이션들을 모두 활성화@SpringBootApplicationpublic class Application{ public static void main(String[] args) { SpringApplication.run(Application.class, args); }}
JPA Auditing 테스트 코드 작성하기
PostsRepositoryTest.java
public class PostsRepositoryTest{ // 기존 코드 @Test public void registerBaseTimeEntity() { // given LocalDateTime now = LocalDateTime.of(2021, 11, 16, 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(">>>>>>>>> create Date="+posts.getCreatedDate()+", modifiedDate="+posts.getModifiedDate()); assertThat(posts.getCreatedDate()).isAfter(now); assertThat(posts.getModifiedDate()).isAfter(now); }}