스프링부트와 AWS로 혼자 구현하는 웹 서비스 4 - JPA

5 minute read

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

어떻게 관계형 데이터베이스를 이용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있을까?

반복적인 SQL 작성 문제

관계형 데이터 베이스가 웹 서비스의 중심이 되면서 현업 프로젝트 대부분이 애플리케이션 코드보다 SQL로 가득해졌다. 관계형 데이터 베이스가 SQL만 인식이 가능하니 테이블마다 CRUD를 매번 생성해야한다.

패러다임 불일치 문제

관계형 데이터 베이스는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술이다. 추상화, 캡슐화, 정보은닉, 다형성 등 객체 지향적 요소들을 관계형 데이터베이스로 표현하기는 어렵다. 객체 모델링보다는 테이블 모델링에만 집중하고 객체를 단순히 테이블에 맞추어 데이터 전달 역할만 하는 개발을 하게된다.

💜JPA

  • 자바 표준 ORM(Object Relational Mapping)
  • 객체지향 패러다임과 관계형 데이터베이스의 중간에서 패러다임 일치를 시켜준다.
  • 개발자는 객체지향적 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성하고 실행한다.

장점

  • 생산성 향상
    • CRUD 코드를 직접 작성할 필요가 없다.

단점

  • 높은 러닝 커브
    • OOP와 RDB를 둘 다 이해하고 있어야한다.

속도이슈는 없을까?

  • JPA에서 제시하는 여러 성능 이슈 해결책들을 잘 활용하면 충분히 네이티브 쿼리만큼의 퍼포먼스를 낼 수 있다.

💜Spring Data JPA

JPA는 인터페이스이다. 즉, Implement된 구현체가 필요하다. 대표적인 구현체는 Hibernate, Eclipse Link 등이 있다. Spring에서는 이 구현체들을 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 제공한다.

  • JPA <- Hibernate <- Spring Data JPA

Hibernate 대신 Spring Data JPA를 사용하는 이유?

  • 구현체 교체 용이
    • 언젠가 필요에 따라 Hibernate 말고 다른 구현체로 쉽게 교체할 수 있다.
  • 저장소 교체 용이
    • 최초에 관계형 데이터베이스로 서비스를 출시했더라도, 의존성만 수정하면 MongoDB 등 다른 데이터베이스로 쉽게 교체할 수 있다.

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

프로젝트 내 build.gradle 파일에 다음 의존성 추가

Gradle 6.9.1 버전 기준


dependencies {
    ...

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

🥕 h2 Database?

  • 자바 SQL 데이터베이스
  • in-memory 관계형 데이터베이스
  • 빠른 속도, 오픈소스, JDBC API
  • 2.5MB 정도 사이즈의 작은 jar 설치 파일
    • 별도의 설치없이 프로그램 의존성으로도 관리가능
  • 브라우저 기반의 콘솔 애플리케이션
    • 기본적으로 /h2-console에서 콘솔을 사용할 수 있다. spring.h2.console.path속성 을 변경하여 콘솔 경로를 사용자정의할 수 있다.
  • 메모리에서 실행되어 프로그램 재시작 시 초기화
    • JPA 테스트 및 로컬 테스트 용도

💜Entity Class

Entity Class란 실제 DB와 매칭될 클래스이다. 데이터 수정이 필요할 경우, DB에 실제 쿼리를 날리지 않고, Entity Class의 수정을 통해 작업할 수 있다.

JPA에서 제공하는 @Entity 어노테이션을 이용해서 Entity Class를 선언해줄 수 있다.

package hyep.study.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.Id;

/**
 * 어노테이션 순서?
 *  주요 어노테이션인 @Entity를 클래스 가까이둔다.
 *  코틀린 등 새 언어로 프로젝트를 전환해야할 경우 필요없어진 Lombok을 제거하기가 더 쉽다.
 **/
@Getter 
@NoArgsConstructor // == public Posts() {}
@Entity 
public class Posts {

    @Id
    @GeneratedValue
    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 Class는 절대 Setter 메소드를 만들지 않는다. (Builder 패턴을 사용하는 이유)

위 코드를 보면, @Setter 를 사용하지 않았다.

자바빈 규약에 따라 Getter/Setter를 무작정 생성하는 경우가 있다. 그러면, 해당 클래스의 인스턴스 값들이 언제 어디서 어떻게 변해야 하는지 코드상으로 명확히 구분할 수가 없어 차후 기능 변경이 어려워진다.

대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야한다.

👉 예시 - 주문 취소 이벤트

Order Entity에 주문의 상태를 나타내는 status 필드를 Setter를 통해서 변경하도록 작성하는 경우이다.

주문 취소 이벤트가 발생하면 setStatus를 통해서 false값을 넘기는데, 이것이 무엇을 의미하는지 코드상에 명확히 드러나있지 않다.

//잘못된 Setter 사용 예
public class Order {
  public void setStatus(boolean status) { //Setter
    this.status = status;
  }
}

public void 주문서비스의_취소이벤트() {
  order.setStatus(false);
}

아래와 같이 cancelOrder 메소드를 이용해서 status 필드 값 변경의 목적과 의도가 명확히 드러나도록 하는 것이 바람직하다.

//목적과 의도를 나타낼 수 있는 메소드
public class Order {
  public void cancelOrder() {
    this.status = false;
  }
}

public void 주문서비스의_취소이벤트() {
  order.cancelOrder();
}


🥕 Setter가 없는데, 어떻게 값을 채워 DB에 insert할 수 있을까?

1. Constructor(생성자)
2. Builder

두 방법 모두 생성 시점에 값을 채우는 역할은 같다.
하지만 Builder를 사용하는 방법이 더 명확하고 안전하다.

예를들어, Order Entity에 주문 정보 데이터를 세팅하는 생성자를 다음과 같이 만들 수 있다.

public Order(int id, int itemCode, int payCode, boolean status) {
  this.id = id;
  this.itemCode = itemCode;
  this.payCode = payCode;
  this.status = status;
}

이 방법은 채워야할 필드가 무엇인지 명확하지 않아 디버깅이 어렵다는 단점이 있다.

파라미터로 넘겨주는 id, itemCode, payCode, status순서를 잘 알고있는 것이 아니라면, 코드상에 아래와 같이 Order 클래스의 인스턴스가 생성되어 있어도 문제를 발견하기 어려울 것이다.

new Order(id, payCode, itemCode, status);

반면에, Builder를 사용하면 어느 필드에 어떤 값을 채워야할지 명확하게 인지할 수 있다. 롬복에서 제공하는 @Builder 어노테이션을 생성자에 작성해주면 쉽게 Builder를 사용할 수 있다.

Order.builder()
     .id(id)
     .itemCode(itemCode)
     .payCode(payCode)
     .status(status)
     .build();

💜JpaRepository

SQLMapper(ex. iBatis, MyBatis 등)에서 Dao라고 불리는 DB Layer 접근자를 JPA에서는 Repository라고 부르며, Interface로 생성한다.

JpaRepository<Entity Class, PK type>를 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다. @Repositoy를 추가할 필요도 없다.

package hyep.study.springboot.domain.posts;

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

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

주의할 점은 Entity Class와 해당 Entity를 위한 JpaRepository Interface는 같은 경로에 위치해야한다. 그래서 도메인 패키지에서 함께 관리한다.

211017181808.png

JpaRepository는 대표적으로 CrudRepository 를 상속한 Interface이다. 그래서 기본적으로 다음과 같은 CRUD 메소드들을 사용할 수 있다.

public interface CrudRepository<T, ID> extends Repository<T, ID> {

  //Saves the given entity.
  <S extends T> S save(S entity);      

  //Returns the entity identified by the given ID.
  Optional<T> findById(ID primaryKey); 

  //Returns all entities.
  Iterable<T> findAll();               
  
  //Returns the number of entities.
  long count();                        

  //Deletes the given entity.
  void delete(T entity);               

  //Indicates whether an entity with the given ID exists.
  boolean existsById(ID primaryKey);   

  // … more functionality omitted.
}

💜Test

만든 PostsRepository의 CRUD 기능 중 insert/select/delete 기능이 잘 동작하는지 테스트해보기 위해 src/test/java/<package directory>/PostsRepositoryTest 파일을 만들고 아래 코드를 작성한다.

package hyep.study.springboot.domain.posts;

import (생략);

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

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

    @Test
    public void test_saveAndFind() {
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        postsRepository.save(Posts.builder() //posts 테이블에 id값 여부에 따라 데이터 삽입 또는 업데이트 (insert/update)
                .title(title)
                .content(content)
                .author("shb0328@gmail.com")
                .build()
        );

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

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

}

🥕 실제로 실행된 쿼리는 어떤 형태일까?

src/main/resource/application.properties 파일을 만들고 아래 설정을 작성한다.

spring.jpa.show_sql=true

테스트 로그를 확인해보면, 다음과 같이 JPA 메소드가 자동으로 쿼리를 생성하여 실행되는 것을 볼 수 있다.

✔️save()

postsRepository.save(Posts.builder() 
                .title(title)
                .content(content)
                .author("shb0328@gmail.com")
                .build()
        );
Hibernate: insert into posts (author, content, title, id) values (?, ?, ?, ?)

✔️findAll()

postsRepository.findAll();
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_

✔️deleteAll()

postsRepository.deleteAll();
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: delete from posts where id=?

💜JPA Annotations

  • @Entity
  • @Id
  • @GeneratedValue
  • @Column
    • 굳이 사용하지 않아도 해당 클래스의 필드는 모두 컬럼이 된다.
    • 기본값(default value) 외에 추가로 변경이 필요한 옵션이 있을 때 사용한다.
    • default value 확인하기

💜Lombok Annotations

  • @NoArgsConstructor
  • @Builder
  • @Getter

💜JUnit Annotations

  • @Test
  • @RunWith
  • @After
    • 주로 배포 전 전체 통합테스트 진행 시 테스트간 데이터 침범을 막기 위해 테스트가 끝날 때마다 수행되는 메소드를 정의한다.


Reference