Spring DB : JPA

    들어가기 전

    이 글은 인프런 영한님의 스프링 DB 2편 - 데이터 접근 활용 기술을 공부하며 작성한 글입니다. 

     

     

    JPA 시작

    • JPA는 ORM 데이터 접근 기술을 제공한다. 
    • MyBatis, JdbcTemplate은 SQL을 개발자가 직접 작성해야한다. 반면 JPA는 SQL를 개발자 대신 작성해준다.
    • JPA는 일반적으로 QueryDSL이라는 기술과 함께 사용된다. 

    JPA 필요성

    • SQL 의존적인 개발에서 벗어날 수 없다.
      • SQL에 사용되는 많은 개발 쿼리들을 무한히 반복해서 작성해야 함. 
      • 객체에 필드가 하나 추가되는 등의 변경점이 발생한다면, SQL을 모두 고쳐야 함. 
    • 객체 vs 관계형 데이터베이스의 패러다임이 불일치함. 
      • 객체와 관계형 데이터베이스의 차이가 발생함. (상속, 연관관계, 데이터 타입, 데이터 식별 방법 등)
      • 패러다임이 불일치 하기 때문에 개발자가 SQL Mapper 역할을 함. 
      • 현재는 객체를 테이블에 넣기 편하도록 모델링 한다. 객체관점에서는 올바르지 않다.
        • 연관관계가 있는 경우 테이블에는 FK가 들어간다. 예를 들어 Team, Member 객체가 있고 Member가 Team에 속해있다고 가정해보자. 그렇다면 Member Table은 TeamID를 가지고 있을 것이다. 객체를 테이블에 넣기 좋게 만들기 위해서 자바 Member 객체에서도 TeamID를 가지고 있어야한다. 그렇지만 객체 관점에서는 올바르지 않다.
        • 객체 관점에서는 Member 객체가 내부적으로 Team 객체를 포함하고 있는 관계가 더 올바르다. 
      • 처음 실행하는 SQL에 따라 탐색 범위가 결정됨.
        • 어떤 테이블까지 Join 하느냐에 따라 탐색 범위가 한정된다. 탐색 범위가 한정되어있기 때문에 개발자는 DB에서 찾아온 객체가 어디까지 탐색 가능한지를 알고 서비스 로직을 작성해야한다. 
        • 반면 JPA를 사용하면, 처음 Join 쿼리를 사용한 것 이외에도 탐색 할 수 있게 된다. 
        • Service 계층에서 Repository 계층에서 불러온 객체를 바탕으로 작업을 해야하지만, SQL 중심으로 작성한 경우 그렇게 작동할 수 없다. 왜냐하면 실제 나간 쿼리가 어느 필드까지 포함되는지 알아야 되기 때문이다. 따라서 SQL을 중심인 경우 물리적으로는 계층이 나누어져 있지만, 논리적으로는 계층이 나누어져 있지 않다고 볼 수 있다. 

     


    JPA 설정

    build.gradle에 라이브러리 의존성을 추가하고 필요한 경우 application.properties에 로그 설정값을 추가하면 된다.

    build.gradle 설정

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    위 코드를 build.gradle에 추가하면 된다. 이 의존성이 추가되면 다음 코드들이 추가된다.

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

    Spring Data JPA의 의존성을 추가해주면  JdbcTemplate 관련 라이브러리들이 알아서 추가된다. 따라서 JdbcTemplate을 위해서 build.gradle에 의존성을 추가해두었다면 그 부분을 삭제해도 무방하다. 

     

    로깅 정보

    # JPA Log
    logging.level.org.hibernate.SQL=DEBUG
    logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
    
    # JPA Log
    spring.jpa.show-sql=true

    위 코드를 application.properties에 추가해서 JPA 로그를 남길 수 있다. 각 명령어는 다음과 같이 동작한다.

    • org.hibernate.SQL : 하이버네이트가 생성하고 실행하는 SQL 확인 가능. Logger를 통해 출력됨. 
    • org.hibernate.type.descriptor.sql.BasicBinder : SQL에 바인딩 되는 파라미터를 확인할 수 있음. Logger를 통해 출력됨.
    • spring.jpa.show-sql : 하이버네이트가 생성하고 실행하는 SQL 확인 가능. System.out을 통해 출력됨. (비추천)

     


    JPA 적용

    라이브러리 설정을 완료했으면 이제 도메인과 리포지토리 영역에 JPA 사용을 위한 설정을 진행해야한다. 

    JPA 도메인 설정 

    JPA를 사용하기 위해서는 기본적으로 사용하고자 하는 도메인(Item 도메인) JPA를 위한 어노테이션을 추가해야한다. JPA에서 사용하는 많은 어노테이션이 있으며 아래는 그 중에 극소수의 어노테이션이다. 

    • @Entity : JPA가 사용하는 객체라는 뜻이다. 이 어노테이션이 있어야 JPA가 인식함. 
    • @Id : 테이블의 PK와 객체의 필드를 맵핑한다.
    • @GeneratedValue(strategy = GenerationType.IDENTITY) : PK 생성 전략을 설정할 수 있음. Identity는 DB에서 생성한 PK 값을 사용함. 
    • @Column : 테이블의 컬럼명과 객체의 필드명을 맵핑함. 
      • name : 테이블의 컬럼명을 표시.
      • length : JPA가 DDL을 수행할 때 컬럼의 길이값으로 사용됨. length = 10 → varchar 10
      • @Column을 생략하면 필드명으로 컬럼에 매칭한다. 
      • @Column은 카멜 케이스를 언더스코어로 자동으로 변환해준다. (itemName → item_name) 

     

    @Data
    @Entity // JPA가 사용하는 객체라는 뜻. 이게 있어야 JPA가 인식함.
    //@Table(name = "item") // 테이블명 = 객체명이면 생략해도 가능함.
    public class Item {
    
        // DB에서 ID 값을 넣어줌.
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @Column(name = "item_name", length = 10)
        private String itemName;
    
        @Column(name = "price")
        private Integer price;
    
        // 컬럼명 == 필드명이면 @Column 생략 가능함.
        private Integer quantity;
    
        // JPA는 public으로 default 생성자가 필요함. (프록시를 위해서)
        public Item() {
        }
    
        public Item(String itemName, Integer price, Integer quantity) {
            this.itemName = itemName;
            this.price = price;
            this.quantity = quantity;
        }
    }

     

    JPA는 기본 생성자가 반드시 필요함.

    public Item(){}

    JPA는 프록시를 이용해서 구현되어있다. 프록시를 원활하게 사용하기 위해서는 기본 생성자가 필요한데, JPA는 기술 스펙으로 반드시 기본 생성자를 요구한다. 따라서 위와 같이 사용 도메인에 기본 생성자를 추가한다. 

     

    JPA Repository 설정

    가장 크게 추가되는 설정값은 다음 두 가지다.

    • EntityManager 
      • JPA의 모든 동작은 EnitytManager 객체를 이용해서 실행된다. 따라서 Repository에는 EntityManager를 제공해줘야한다.
      • EntityManager는 EntityManagerFactory, JpaTransactionManager, DataSource 등 다양한 설정을 해야한다. 스프링부트는 이 과정을 모두 자동화해서 EntityManager를 스프링 빈으로 등록해준다. 
    • @Transactional 
      • JPA는 트랜잭션 기반으로 동작한다. 단순 조회는 트랜잭션 없이도 읽기 전용 쿼리로 조회가 가능하긴 하다. 
      • 일반적으로는 Service 계층에서 트랜잭션을 시작한다. 

    아래에 JPA를 이용할 때의 Repository 코드를 확인할 수 있다. 

    @Slf4j
    @Repository
    @Transactional // JPA는 트랜잭션을 이용해서 동작한다.
    @RequiredArgsConstructor
    public class JpaItemRepository implements ItemRepository {
    
    
        private final EntityManager em;
    
        @Override
        public Item save(Item item) {
            em.persist(item);
            return item;
        }
    
        // 더티 체크를 이용한 업데이트.
        @Override
        public void update(Long itemId, ItemUpdateDto updateParam) {
    
            Item item = em.find(Item.class, itemId);
    
            item.setItemName(updateParam.getItemName());
            item.setPrice(updateParam.getPrice());
            item.setQuantity(updateParam.getQuantity());
        }
    
        @Override
        public Optional<Item> findById(Long id) {
            return Optional.ofNullable(em.find(Item.class, id));
        }
    
        @Override
        public List<Item> findAll(ItemSearchCond cond) {
    
            String itemName = cond.getItemName();
            Integer maxPrice = cond.getMaxPrice();
    
    
            String jpql = "select i from Item i";
            boolean firstFlag = false;
    
            if (StringUtils.hasText(itemName) ) {
                jpql = jpql + " where i.itemName like concat('%', :itemName, '%')";
                firstFlag = true;
            }
    
            if (maxPrice != null) {
    
                if (firstFlag) {
                    jpql = jpql + " and i.price <= :maxPrice";
                }else{
                    jpql = jpql + " where 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();
        }
    }

     

    Save 쿼리

    insert into item (id, item_name, price, quantity) values (default, ?, ?, ?)
    • 위는 JPA가 생성한 쿼리다. 쿼리에서 확인해볼 부분은 id에 default 값이 들어간다는 점이다.
    • PK 키 생성 전략을 Identity로 사용한다면 JPA는 위와 같은 쿼리를 생성한다. 비록 JPA는 쿼리를 보낼 때, Item 객체의 Id 필드에 default 값이 들어가서 DB가 생성한 PK 값이 들어가게 된다. 그렇지만 Insert 쿼리 실행 이후에 생성된 ID 결과를 받아서 넣어준다. 

     

    Update 쿼리

    public void update(Long itemId, ItemUpdateDto updateParam) {
     Item findItem = em.find(Item.class, itemId);
     findItem.setItemName(updateParam.getItemName());
     findItem.setPrice(updateParam.getPrice());
     findItem.setQuantity(updateParam.getQuantity());
    }
    • JPA를 사용할 때는 Update 쿼리를 직접 작성하지 않는다. 대신에 영속성 컨텍스트의 더티체크를 이용해서 실행된다.
    • JPA는 트랜잭션 커밋 전, flush()전, jpql 실행 전에 영속성 컨텍스트를 flush한다. flush하는 시점에 더티체크를 이용해서 변경점이 있는 엔티티에는 UPDATE SQL을 실행한다. 
    • 테스트 코드에서는 @Transactional 어노테이션 안에서 commit이 실행되지 않는다. 따라서 아래 두 가지 방법을 이용해서 수동 Commit 한 후에, Update 쿼리를 확인해 볼 수 있다.
      • @commit 어노테이션 추가.
      • em.flush() 

     

     

    JPQL

    • JPA는 JPQL(Java Persistence Query Language)라는 객체지향 쿼리 언어를 제공한다. 
    • SQL이 테이블을 대상으로 한다면, JPQL은 엔티티 객체를 대상으로 한다. 따라서 JPQL을 사용할 때, from 절 뒤에 엔티티 객체의 이름이 들어간다. 그리고 모든 필드는 엔티티 객체의 별칭 + 컬럼명으로 접근한다. 아래에서 예시를 확인할 수 있다. 
    // JPQL
    SELECT i from Item i
    WHERE i.itemName like concat('%',:itemNamem'%')
    	and i.price <= :maxPrice
        
    // JPQL을 통해 실행된 SQL    
    select
     item0_.id as id1_0_,
     item0_.item_name as item_nam2_0_,
     item0_.price as price3_0_,
     item0_.quantity as quantity4_0_
    from item item0_
    where (item0_.item_name like ('%'||?||'%'))
     and item0_.price<=

     

    순수 JPA 문제점

    순수 JPA 문제점 중 하나는 동적쿼리를 작성하기가 어렵다는 것이다. 예를 들어 검색 조건에 따라서 동적쿼리를 생성하는 경우, 그 때 작성된 쿼리는 아래와 같고, 문제점도 살펴볼 수 있다. 

    • 로직이 복잡해지면 많은 오류가 수반된다.
    • 쿼리가 문자열로 작성된다.
    • 동적 쿼리에서 사용하는 조건문들이 재반복되지 않는다. 즉, 코드가 길어진다. 

    순수 JPA에서 동적 쿼리를 작성할 때는 위 같은 문제점이 있기 때문에 QueryDSL과 함께 사용된다.

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
    
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();
    
    
        String jpql = "select i from Item i";
        boolean firstFlag = false;
    
        if (StringUtils.hasText(itemName) ) {
            jpql = jpql + " where i.itemName like concat('%', :itemName, '%')";
            firstFlag = true;
        }
    
        if (maxPrice != null) {
    
            if (firstFlag) {
                jpql = jpql + " and i.price <= :maxPrice";
            }else{
                jpql = jpql + " where 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();
    }

     

    JPA와 예외변환

    JPA는 EntityManager를 이용해서 DB와 Repository 계층 사이에서 인터페이스한다. 만약 JPA가 동작하다가 문제가 발생하면 EntityManager에서 예외가 발생되게 될 것이다. 이 때, JPA는 스프링과 관련이 없기 때문에 JPA에서 사용하는 예외를 발생시킨다. 이 때 발생되는 예외는 PersistenceException, IllegalSTateException, IllegalArgumentException들이다. 

    만약 JPA, 정확하게는 EntityManager에서 Exception이 발생하게 된다면 여기서 발생하는 Exception이 서비스 계층까지 바로 다이렉트로 전달되게 될 것이다. 그리고 서비스 계층에서 바라보는 예외는 PersistenceException이고, 이 예외는 JPA와 관련된 예외다. 서비스 계층에서는 이 예외를 처리하기 위해서 PersistenceException을 캐치하는 로직이 들어가야한다. 이렇게 구성되게 된다면 Service 계층은 JPA 기술에 종속되게 된다. 계층별로 기술 종속성이 분리가 되지 않았다는 것이다. 

     

    JPA와 Service 계층의 분리 → @Repository

    @Repository를 Repository 계층에 설정해주면 위 문제가 해결된다. @Repository는 아래 두 가지 기능을 가진다.

    • Component Scan의 대상이 된다.
    • 어노테이션이 달린 클래스를 예외변환 AOP의 적용 대상으로 만든다. 
      • 예외변환 AOP는 JPA 관련 예외가 발생하면, JPA 예외 변환기를 이용해 발생한 예외를 스프링 데이터 접근 예외로 변환한다. 
      • 예외변환 AOP는 스프링부트가 자동으로 등록해준다.

    @Repository를 추가하게 되면 아래 그림과 같은 방식으로 동작하게 된다. 엔티티 매니저에서 발생한 Persistence Exception은 Repository 계층까지 던져지고, Repository 계층도 이 값을 Service 계층으로 던진다. Service 계층으로 데이터가 넘어가기 전에 예외변환 AOP Proxy를 통해서 PersistenceException은 DataAccessException으로 변경되어 Service 계층으로 던져진다. 

    PersistenceExceptionTranslationInterceptor라는 AOP가 생성되는데, 이 객체에서 Persistence Exception(RuntimeException)을 잡아준다. 잡은 예외는 persistenceExceptionTranslator를 통해서 스프링의 DataAccessException으로 변경된다. 

    // PersistenceExceptionTranslationInterceptor.java
    @Override
    @Nullable
    public Object invoke(MethodInvocation mi) throws Throwable {
       try {
          return mi.proceed();
       }
       catch (RuntimeException ex) {
          // Let it throw raw if the type of the exception is on the throws clause of the method.
          if (!this.alwaysTranslate && ReflectionUtils.declaresException(mi.getMethod(), ex.getClass())) {
             throw ex;
          }
          else {
             PersistenceExceptionTranslator translator = this.persistenceExceptionTranslator;
             if (translator == null) {
                Assert.state(this.beanFactory != null,
                      "Cannot use PersistenceExceptionTranslator autodetection without ListableBeanFactory");
                translator = detectPersistenceExceptionTranslators(this.beanFactory);
                this.persistenceExceptionTranslator = translator;
             }
             throw DataAccessUtils.translateIfNecessary(ex, translator);
          }
       }
    }

    참고

    • 스프링 부트는 PersistenceExceptionTranslationPostProcessor를 자동으로 등록한다. 여기서 @Repository를 AOP 프록시로 만드는 어드바이저가 등록됨.
    • 실제 JPA 예외를 변환하는 코드는 EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible()이다. 

     

    'Spring > Spring DB' 카테고리의 다른 글

    Spring DB : 스프링 트랜잭션의 이해  (0) 2023.01.29
    스프링 DB : 스프링 데이터 JPA 관련  (0) 2023.01.23
    Spring DB : MyBatis  (0) 2022.07.08
    Spring DB : DB 테스트  (0) 2022.07.04
    Spring DB : JdbcTemplate  (0) 2022.06.11

    댓글

    Designed by JB FACTORY