Group Study (2024-2025)/Spring 입문

[Spring 입문] 2주차 - 스프링부트에서 JPA로 데이터베이스 다뤄보기

알 수 없는 사용자 2024. 10. 7. 23:59

1. JPA

1) 소개

현대의 웹 애플리케이션에서 관계형 데이터베이스는 빠질 수 없는 요소이다. 그렇기에 객체를 관계형 데이터베이스에서 관리하는 것이 무엇보다 중요하다.

하지만 관계형 데이터베이스는 SQL만 인식할 수 있기에, 개발자가 아무리 자바 클래스를 잘 설계하여도, SQL을 통해야만 데이터베이스에 저장하고 조회할 수 있다는 번거로움이 존재한다. 또한, 어떻게 데이터를 저장할지에 초점이 맞춰진 SQL과는 반대로, 객체지향 프로그래밍 언어는  메시지를 기반으로 기능과 속성을 한 곳에서 관리하는 것이 목표이기에, 이 둘 사이에선 패러다임 불일치 문제 발생한다.

이를 해결하기 위해 JPA가 등장한다. JPA는 서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치 시켜주기 위한 기술이다. 개발자는 객체지향적으로 프로그래밍을 하고 JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행해주기에, 개발자는 더는 SQL에 종속적인 개발을 하지 않아도 된다.

2) Spring Data JPA

JPA는 인터페이스로서 Hibernate, Eclipse, Link 등과 같은 구현체가 필요하다. 하지만 Spring에서 JPA를 사용할 때는 구현체를 직접 다루진 않고, Spring Data JPA라는 모듈을 사용한다. 

JPA <- Hibernate (구현체) <- Spring Data JPA (모듈)

Hibernate를 쓰는 것과 Spring Data JPA를 쓰는 것 사이엔 큰 차이가 없지만, Spring Data JPA의 등장 이유는 다음과 같다.

1) 구현체 교체의 용이성 :  Hibernate외에 다른 구현체로 쉽게 교체하기 위함

2) 저장소 교체의 용이성 : 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함

3) 요구사항 분석

추후 실습은 3장 ~ 6장은 [게시판(웹 어플리케이션) 만들기], 7장 ~ 10장은 [서비스 AWS에 무중단 배포하기]로 진행한다.

우선적으로 만들어볼 게시판의 요구사항은 다음과 같다.

  • 게시판 기능
    • 게시글 조회
    • 게시글 등록
    • 게시글 수정
    • 게시글 삭제
  • 회원 기능
    • 구글/네이버 로그인
    • 로그인한 사용자 글 작성 권한
    • 본인 작성 글에 대한 관리

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

1) build.gradle에 의존성 등록하기

// h2 
implementation('com.h2database:h2') 
// jpa 
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
  • h2 : 인메모리 관계형 데이터베이스, JPA의 테스트/로컬환경에서의 구동에서 이용 예정
  • spring-boot-starter-data-jpa : 스프링부트용 Spring Data Jpa 추상화 라이브러리

2) domain 패키지

* 도메인 : 소프트웨어에 대한 요구사항 혹은 문제 영역 (게시글, 댓글, 회원, 정산, 결제 등)

A) posts 패키지 Posts 클래스 만들기

Posts 클래스는 실제 DB 테이블과 매칭될 클래스로, 보통 Entity 클래스라고도 한다. JPA를 사용하면, 실제 쿼리를 날리기 보다는 이 Entity 클래스의 수정을 통해 DB 데이터에 작업을 한다.

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 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 table )
  • @Id : 해당 테이블의 PK필드를 나타냄.
  • @GeneratedValue : PK의 생성규칙을 나타냄. 스프링 부트 2.0에서는 GenerationType.IDENTITY옵션을 추가해야만 auto_increment가 됨.
  • @Column : 테이블의 칼럼을 나타내며 굳이 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 됨. 기본값외에 추가로 변경이 필요한 경우 사용. (ex.사이즈 늘리기, 타입 변경 등 )
  • @Builder : 해당 클래스의 빌더 패턴 클래스를 생성, 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
주목 ! 이 클래스에는 Setter 메소드가 없다

차후 기능 변경시 복잡해지는 것을 막기 위해 Entity클래스에서는 절대 Setter메소드를 만들지 않는다. 대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 한다. (책에선 @Builder를 통해 제공되는 빌더클래스를 사용한다)

그러면 Setter가 업는 상황에서 어떻게 값을 채워 DB에 삽입 할까?  기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이  필요할 시 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다.

* 빌더를 사용할 시 어느 필드에 어떤 값을 채워야할지 명확히 인지할 수 있다.

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

 

B) PostsRepository 인터페이스 생성

Posts 클래스로 Database 접근을 가능하게 해줄 JpaRepository이다. DB Layer접근자로서 JPA에서 Repository라고 부르며 인터페이스로 생성한다. 단순히 인터페이스를 생성한후 JpaRepository<Entity 클래스, PK 타입> 을 상속하면 기본적인 CRUD메소드가 자동으로 생성된다.

@Repository를 추가할 필요도 없다. 여기서 주의해야할 점은 Entity클래스와 기본 Entity Repository는 함께 위치해야 하는 점이다. Entity클래스는 기본 Repository없이는 제대로 역할을 할 수 가 없다.

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

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

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

1) PostsRepositroyTest 클래스

test 디렉토리에 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)
@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("jojoludu@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에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정. 보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범 막기 위해 사용. 
  • postsRepository.save : 테이블 posts에 insert/update쿼리를 실행. id값이 있다면 update가 없다면 insert 쿼리가 실행
  • postsRepostiroy.findAll : 테이블 posts에 있는 모든 데이터를 조회하는 메소드

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

2) 로그로 실행된 쿼리 확인하기

스프링부트에선 실제로 실행된 쿼리의 형태를 보기 위해 application.properties, application.yml 등의 파일 속 한 줄의 코드로 옵션을 설정하도록 지원 및 권장한다.

A) src/main/resources 디렉토리 아래에 application.properties 파일을 생성하여 옵션 추가하기.

spring.jpa.show_sql=true

B) 이때 쿼리는 H2 문법이 적용되어 출력되는데, 책에선 이후 디버깅을 위해 출력되는 쿼리의 로그를 MySQL 버전으로 변경한다.

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

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect 과 관련된 오류가 생길 시 아래와 같이 코드를 바꿔보자.

spring.jpa.show-sql=true
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://localhost/~/testdb;MODE=MYSQL

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

1) 도메인 모델

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

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

 

스프링 웹 계층은 다음과 같은데,

  • Web Layer : 컨트롤러와 JSP/Freemarker 등의 뷰 템플릿 영역. 이외에도 필터, 인터셉터, 컨트롤러 어드바이스 등 외부 요청과 응답에 대한 전반적인 영역을 이야기 한다.
  • Service Layer : @Service에 사용되는 서비스 영역으로 일반적으로 Controller와 Dao의 중간 영역에서 사용. @Transactional이 사용되어야 하는 영역이기도 하다.
  • Repostiroy Layer: Database오 같이 데이터 저장소에 접근하는 여역(Dao영역)
  • Dtos : Dto는 계층 간에 데이터 교환을 위한 객체를 이야기 하며 Dtos는 이들의 영역
  • Domain Model : 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것을 도메인 모델이라고 한다.

이 5가지 레이어 중 도메인비즈니스 처리를 담당하기에, '모든 로직의 처리가 서비스 클래스 내부에서 처리됨으로써 객체가 단순히 데이터 덩어리 역할만 하는 문제'가 발생하지 않는다.

2) 등록, 수정, 삭제 기능 만들기

PostsApiController 생성 및 코드 추가

src/main/java/com/jojoludu/book/springboot/web/PostsApiController

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

PostsService 생성

src/main/java/com/jojoludu/book/springboot/service/PostsService

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

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

    // update 함수 추가
    @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);
    }
}

생성자를 직접 쓰는 대신 롬복 어노테이션인 @RequiredArgsConstructor을 사용했기에, 해당 클래스의 의존성 관계가 변경되어도 생성자 코드를 수정하지 않아도 된다.

PostsSaveRequestDto 생성

src/main/java/com/jojoludu/book/springboot/web/dto/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();
    }
}

Dto클래스는 Entity클래스와 거의 유사한 형태이다. 절대로 Entity클래스를 Request/Response 클래스로 사용해서는 안된다. Entity클래스는 데이터베이스와 맞닿은 핵심 클래스로, 이를 기준으로 테이블이 생성되고, 스키마가 변경된다. Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Respose용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.

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

PostsApiControllerTest 생성 및 코드 추가

src/main/java/com/jojoludu/book/springboot/web/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 testDown() 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);
    }
    // 수정 기능 테스트 코드 추가

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

}

HelloController와 달리 @WebMvcTest를 사용하지 않았다. @webMvcTest의 경우 JPA 기능이 작동하지 않기 때문인데, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA 기능 까지 한번에 테스트 할 때는 @SpringBootTest와 TestRestTemplate을 사용하면 된다.

PostsResponseDto 파일 생성

src/main/java/com/jojoludu/book/springboot/web/dto/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.author = entity.getAuthor();
    }
}

PostsUpdateRequestDto 파일 생성

src/main/java/com/jojoludu/book/springboot/web/dto/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 클래스 코드 추가

    // update 함수 추가
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

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

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문이다. 그렇다 보니 매번 DB에 삽입하기전 갱신하기전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다. 이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다면 귀찮고 코드가 지저분해질 수 있다. 이런 문제를 해결하고자 JAP Auditing을 사용한다.

BaseTimeEntity 클래스 생성

src/main/java/com/jojoludu/book/springboot/domain/BaseTimeEntity

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

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}
  • @MappedSuperclass : JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 한다.
  • @EntityListeners(AuditingEntityListener.class) : BaseTimeEntity클래스에 Auditing기능을 포함시킨다.
  • @CreatedDate : Entity가 생성되어 저장될때 시간이 자동 저장
  • @LastModifiedDate : 조회한 Entity값을 변경할 떄 시간이 자동 저장

이후에 Posts클래스가 BaseTimeEntity를 상속받도록 변경한다.

마지막으로 JPA Auditing 어노테이션들을 모두 활성화 할 수 있도록 Application클래스에 활성화 어노테이션 하나를 추가한다.

@EnableJpaAuditing

 

JPA Auditing 테스트 메소드 추가

 // JPA Auditing 테스트 코드 추가
    @Test
    public void BaseTimeEntity_등록() {
        // 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);


    }
}

테스트 성공 시, 앞으로 추가될 엔티티들은 더이상 등록일/수정이로 고민할 필요가 없다.


다음 장 : 테블릿 엔진을 이용한 화면 만들기