framework/spring

5. 데이터 접근 기술 - JPA

wooweee 2023. 9. 29. 18:45
728x90

1. JPA 설정

  • spring-boot-starter-data-jpa
    • 라이브러리
    • JPA와 spring data JPA를 springboot와 통합하고, 설정도 간단히 할 수 있다.
    • spring-boot-starter-jdbc를 포함(의존)한다. mybatis-spring-boot-starter도 spring-boot-starter-jdbc를 포함

  • 추가되는 library
    • hibernate-core : JPA 구현체인 하이버네이트 라이브러리
    • jakarta.persistence-api : JPA 인터페이스
    • spring-data-jpa : 스프링 데이터 JPA 라이브러리

 

  • build.gradle
plugins {
    id 'org.springframework.boot' version '2.6.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

// hibernate.version 버그로 인한 version downgrade 방법
ext["hibernate.version"] = "5.6.5.Final"

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    //테스트에서 lombok 사용
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'

    // JdbcTemplate 추가
    // mybatis나 jpa 사용시, 해당 패키지 내부에 jdbcTemplate이 들어가있어서 주석처리
    // implementation 'org.springframework.boot:spring-boot-starter-jdbc'

    // MyBatis 추가 - 김영한 강사님 file을 그대로 사용한 것이여서 2.2.0으로 작성해야한다.
    // boot 3.0 이상 사용시 version을 3.0.1로 변경 해야한다.
	implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'

    // JPA 추가
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // H2 데이터베이스 추가
    runtimeOnly 'com.h2database:h2'

    //Querydsl 추가
    implementation 'com.querydsl:querydsl-jpa'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

tasks.named('test') {
    useJUnitPlatform()
}

// Querydsl 추가, 자동 생성된 Q클래스를 gradle clean으로 제거 
clean {
    delete file('src/main/generated')
}

 

  • java/resources/application.properties
spring.profiles.active=local

#JPA log
logging.level.org.hibernate.SQL=DEBUG # 하이버네이트가 생성하고 실행하는 SQL 확인
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE # SQL에 바인딩 되는 params 확인

# springboot 3.0 이상 시, 변경된 로그
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

 

  • test/resources/application.properties
spring.profiles.active=test

#JPA log
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

 

 

2. JPA 적용1 - 개발

  • JPA 중요 2가지
    1. 객체와 테이블 매핑 (이번 기록 내용)
    2. 영속성 컨텍스트

 

2.1. Item - ORM 매핑

  • @Entity : JPA가 사용하는 객체, 어노테이션 존재시, JPA가 인식, 해당 어노테이션인 붙은 객체를 엔티티라고 한다
  • @Table : class명과 table명 동일할 경우 생략가능
    • name은 대소문자 구분 안함
  • @Id : 테이블의 PK와 해당 필드를 매핑

  • @GeneratedValue(strategy = GenerationType.IDENTITY) : PK 생성 값을 데이터베이스에서 생성하는 IDENTITY 방식을 사용
    ex) autoIncrement 같은 것

  • @Column : 객체 필드를 테이블의 컬럼과 매핑
    • name = "item_name" : 객체는 itemName이지만 테이블 컬럼은 item_name이므로 이렇게 매핑
    • length = 10 : JPA 매핑 정보로 DDL(create table)도 생성할 수 있는데, 그때 컬럼의 길이값을 활용
    • 생략시 필드의 이름을 테이블 컬럼 이름으로 사용 - springboot와 통합 사용시, 카멜 케이스와 언더스코어 자동 변환 해줌
  • 기본 생성자가 필수 : public, protected 접근 제어자 사용, proxy 기술에 사용 

 

package hello.itemservice.domain;

@Data
@Entity // 해당 어노테이션이 존재해야지 jpa 객체로 인정됨 == table이랑 맵핑되서 관리가 되는 객체
//@Table(name = "item") // 대소문자 구분없이 table명과 class 명이 동일하면 생략해도 된다.
public class Item {

    @Id // PK를 지정
    @GeneratedValue(strategy = GenerationType.IDENTITY) // autoIncrement
    private Long id;

    // table의 컬럼명과 객체 필드명이 동일할 시, @Column이 없어도 된다. 또한 카멜 케이스와 언더스코어도 자동으로 인식함
    // 아래 컬럼들에 모두 @Column을 넣지 않아도 되지만 이해를 위해 item_name 필드에만 @Column 넣어둠
    @Column(name = "item_name", length = 10) // length: jpa로 DDL(create table) 생성시, column 길이 값으로 사용 됨
    private String itemName;
    private Integer price;
    private Integer quantity;

    // JPA는 public || protected 기본 생성자가 필수. 스펙이다. 해당 생성자가 있어야 나중에 proxy 기술 사용 시 유용하게 쓰임.
    public Item() {}

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

2.2. JpaItemRepositoryV1

  • private final EntityManger em
    • 생성자를 보면 스프링을 통해 엔티티 매니저라는 것을 주입 받는다.
    • JPA의 모든 동작은 엔티티 매니저를 통해서 이뤄진다.
    • 엔티티 매니저는 내부에 데이터소스를 가지고 있고, 데이터베이스에 접근 가능하다.
    • 참고로 jpa를 설정하려면 EntityManagerFactory, JPA 트랜잭션 매니저, 데이터소스 등등 다양한 설정을 해야하는데 이를 springboot가 자동화해준다.
      - JpaBaseConfiguration를 참고하면 어떻게 설정하는 알 수 있다.
  • @Transactional
    • JPA의 모든 데이터 CRUD는 trransaction 내부에서 이뤄져야한다. 다만 select는 transaction이 없어도 된다.

    • 나머지 crud도 어지간하면 service 계층에서 @Transactional을 걸기때문에 Repository 쪽에서 크게 신경 쓸 경우가 드물다.

    • 해당 code에서 repository 계층에 @Transactional을 건 이유는 복잡한 비지니스 로직이 없어서 서비스 계층에서 트랜잭션을 걸지 않았기 때문에 @Repository에 transaction을 걸었다.

    • 실제 실무에서도 필요한 경우 Repository 계층에 @Transactional을 거는 경우도 존재한다.
      하지만 일반적으로 비지니스 로직을 사용하는 서비스 계층에 transaction을 걸어주는 것이 맞다.

package hello.itemservice.repository.jpa;

@Slf4j
@Repository // spring 예외 변환을 위한 AOP Proxy 적용대상
// jpa의 모든 데이터 변경은 transactional 안에서 이루어지기 때문에 @Transactional 적용 - 조회는 필요 없다.
// 현재 Service에 @Transactional이 없어서 repository에 넣음. 원래는 Service 계층에 @Transactional을 넣는게 맞다.
@Transactional
public class JpaItemRepository implements ItemRepository {

    private final EntityManager em; // 내부에 dataSource, 등등 수행해야하는 과정을 boot가 다 수행해줘서 해당 class를 사용하면 된다.

    // 생성자 주입
    public JpaItemRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    // @Transactional
    public Item save(Item item) {
        em.persist(item); // jpa entity인 Item의 정보를 이용해서 db에 저장 * persist: 영구 저장
        return item;
    }

    @Override
    // @Transactional
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = em.find(Item.class, itemId); // 1. 항상 select 수행
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
        // update 저장을 알아서 해줌 - transaction이 commit 되는 시점에서 update query 만들어서 db에 날림
        /* 추가 공부 내용
         * select 후, setter 사용으로 값 update 시, 값이 변경되지 않은 부분은 dirty checking을 통해서 update query에서 제거
         * null이 들어간 경우는 기존 값과 다른 것이므로 null로 update
         * dirty checking : 변경된 엔티티의 상태를 감지하고 데이터베이스와의 동기화를 관리하는 방법 중 하나
         * */
    }

    @Override
    public Optional<Item> findById(Long id) {
        Item item = em.find(Item.class, id); // find(entity.class, pk)
        return Optional.ofNullable(item);
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        /* jpql
         * sql과 95% 유사한, 객체 쿼리 언어 - 쿼리가 복잡할 경우 사용
         * Item     : 대소문자 주의, Item entity 객체 의미
         * Item i   : i는 alias(별칭)
         * select i : i는 Item entity 자체를 의미 == class type 의미
         * */
        String jpql = "select i from Item i";
        // 동적쿼리 없는 경우 사용 code
        // List<Item> result = em.createQuery(jpql, Item.class).getResultList();

        // 동적쿼리 넣은 경우 - 아주 불편한 단점 가짐
        Integer maxPrice = cond.getMaxPrice();
        String itemName = cond.getItemName();
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            jpql += " where";
        }
        boolean andFlag = false;
        if (StringUtils.hasText(itemName)) {
            jpql += " i.itemName like concat('%',:itemName,'%')";
            andFlag = true;
        }
        if (maxPrice != null) {
            if (andFlag) {
                jpql += " and";
            }
            jpql += " i.price <= :maxPrice";
        }
        log.info("jpql={}", jpql);
        TypedQuery<Item> query = em.createQuery(jpql, Item.class);
        if (StringUtils.hasText(itemName)) {
            query.setParameter("itemName", itemName);
        }
        if (maxPrice != null) {
            query.setParameter("maxPrice", maxPrice);
        }
        return query.getResultList();
    }
}

 

 

3. JPA 적용2 - 리포지토리 분석

 

  • em.persist(item) : JPA에서 객체를 테이블에 저장할 때는 엔티티 매니저가 제공하는 persist() 메서드 사용
public Item save(Item item) {
    em.persist(item); // jpa entity인 Item의 정보를 이용해서 db에 저장 * persist: 영구 저장
    return item;
}

 

  • em.update() 같은 메서드 전혀 호출하지 않는다.
  • tx가 commit되는 시점에, 변경된 엔티티 객체가 있는지 확인 후, 특정 엔티티 객체가 변경된 경우에는 Update sql 실행
  • test의 경우 마지막에 tx가 롤백 되기 때문에 update sql이 실행되지 않으므로 확인하고 싶으면 @Commit 을 붙여 확인
  • 항상 em.find()로 찾은 후, update code 수행
public void update(Long itemId, ItemUpdateDto updateParam) {
    Item findItem = em.find(Item.class, itemId); // 1. 항상 select 수행 - update 수행 여부의 비교의 대상이 된다
    findItem.setItemName(updateParam.getItemName());
    findItem.setPrice(updateParam.getPrice());
    findItem.setQuantity(updateParam.getQuantity());
    // update 저장을 알아서 해줌 - transaction이 commit 되는 시점에서 update query 만들어서 db에 날림
    /* 추가 공부 내용
     * select 후, setter 사용으로 값 update 시, 값이 변경되지 않은 부분은 dirty checking을 통해서 update query에서 제거
     * null이 들어간 경우는 기존 값과 다른 것이므로 null로 update
     * dirty checking : 변경된 엔티티의 상태를 감지하고 데이터베이스와의 동기화를 관리하는 방법 중 하나
     * */

 

  • em.find(Item.class, id) : 조회타입과 pk 값을 준다. 그러면 JPA가 조회 후, 결과를 객체로 변환 후 반환해준다. 
public Optional<Item> findById(Long id) {
    Item item = em.find(Item.class, id); // find(entity.class, pk)
    return Optional.ofNullable(item);
}

 

  • JPQL(java persistence query Language) : 객체지향 쿼리 언어
    • 여러 데이터를 복잡한 조건으로 조회할 때 사용
    • 엔티티 객체와 속성의 대소문자는 구분해야한다.
    • 이렇게 하는거 개불편한 방법이다 - jpql은 동적쿼리에 약함 
  • JPQL 규칙
    • params는 :maxPrice 같이 작성
    • params binding은 query.setParameter("maxPrice", maxPrice) 이렇게 작성 
public List<Item> findAll(ItemSearchCond cond) {
    /* jpql
     * sql과 95% 유사한, 객체 쿼리 언어 - 쿼리가 복잡할 경우 사용
     * Item     : 대소문자 주의, Item entity 객체 의미
     * Item i   : i는 alias(별칭)
     * select i : i는 Item entity 자체를 의미 == 아까 alias로 만든 그 i 인것이다.
     * Item이라는 entity를 대상으로 entity 객체인 i를 select하겠다는 의미
     * */
    String jpql = "select i from Item i";
    // 동적쿼리 없는 경우 사용 code
    // List<Item> result = em.createQuery(jpql, Item.class).getResultList();

    // 동적쿼리 넣은 경우 - 아주 불편한 단점 가짐
    Integer maxPrice = cond.getMaxPrice();
    String itemName = cond.getItemName();
    if (StringUtils.hasText(itemName) || maxPrice != null) {
        jpql += " where";
    }
    boolean andFlag = false;
    if (StringUtils.hasText(itemName)) {
        jpql += " i.itemName like concat('%',:itemName,'%')";
        andFlag = true;
    }
    if (maxPrice != null) {
        if (andFlag) {
            jpql += " and";
        }
        jpql += " i.price <= :maxPrice";
    }
    log.info("jpql={}", jpql);
    
    TypedQuery<Item> query = em.createQuery(jpql, Item.class);
    if (StringUtils.hasText(itemName)) {
        query.setParameter("itemName", itemName);
    }
    if (maxPrice != null) {
        query.setParameter("maxPrice", maxPrice);
    }
    return query.getResultList();
}

 

4. JPA 적용3 - 예외변환

@Slf4j
@Repository
@Transactional
public class JpaItemRepository implements ItemRepository {

    private final EntityManager em;

    public JpaItemRepository(EntityManager em) {
        this.em = em;
    }
	...
}

  • EntityManger는 순수한 JPA 기술이고, 스프링과 관계가 없다. 따라서 엔티티 매너저는 예외가 발생하면 JPA 관련 예외를 발생 시킴
  • JPA는 persistenceException과 하위 예외를 발생시킨다. + IllegalStateException, IllegalArgumentException 발생시킴

  • JPA 예외를 스프링 예외로 추상화(== DataAccessException)으로 변환하는 방법은 @Repository에 존재한다.

 

4.1. @Repository 기능

  1. component scan 대상
  2. 예외 변환 AOP 적용 대상

 

  • 스프링과 JPA를 함께 사용하는 경우 스프링은 JPA 예외 변환기를 등록한다.
  • 예외 변환 AOP 프록시는 JPA 관련 예외가 발생시 JPA 예외 변환기를 통해 발생한 예외를 스프링 데이터 접근 예외로 변환
  • 결과적으로 @Repository 만 존재하면 spring이 예외 변환을 처리하는 AOP를 만들어준다