데이터 접근기술 - JPA

고동현·2024년 6월 21일
0

DB

목록 보기
9/13

SQL 중심 개발의 문제점

  1. CRUD코드를 반복해서 짜야한다.
    자바객체 -> SQL로 변환하고
    SQL -> 자바객체로 변환하고 반복적인 코드들이 너무 많다.

  2. 객체와 관계형 데이터베이스의 차이점이 있다.

  • 상속

    만약 Album을 저장하려면, 객체를 분해 하여, Item에 해당하는 insert문과, Album에 해당하는insert문을 둘다 날려야하다.
    join할때도 Album과 Item을 조인하여서 가져와야하는 복잡함이 있다.

  • 연관관계

    사실 member 객체에서는 참조로 Team과 연관관계를 맺는게 맞다.
    하지만 RDB에서는 객체를 Table의 칼럼값으로 쓸수는 없다.
    그래서,
    이런식으로 teamId로 저장하는 방식이 대부분이다.
    이렇게 객체와 RDB간의 연관관계 차이가 존재한다.

  • 데이터 타입
    객체와 RDB에 데이터 타입의 차이도 존재한다.
    int price;
    Integer price등..

  • 데이터 식별방법
    객체 모델링은, 자바 컬랙션이 관리를 한다.

    이런 상황에서 객체는 자유롭게 객체 그래프를 탐색할수 있다.
    member.getTeam()//ok
    member.getOrder()//ok

    그러나 RDB에서는 다르다.
    처음 실행하는 SQL에 따라 탐색볌위가 결정된다.
    SELECT M., T.
    FROM MEMBER M
    JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
    member.getTeam()//ok
    member.getOrder()//null

    또한 엔티티 신뢰 문제가 발생한다.
    member로 부터 Delivery를 가져오고 싶으면,
    Member member = memberRepository.getMemberWithOrderWithDelivery(); 를 실행해야하고 해당 Reposiroty에 이 쿼리문을 날리는 메서드가 있어야한다.

    즉, 어떤걸 조회할지 모르니까 이걸 Repository에서 with,,,with,,,with 이런걸로 전부 메서드를 만들어야한다는것이다.(해당 그림에 있는 모든 엔티티의 필드를 가져오는 메서드를 전부 만들어야함)

    만드는것도 복잡하고, 실수로 누락할 가능성이 있어 신뢰 문제가 발생한다.

    이 모든 문제들이 객체를 자바 컬렉션에 저장하듯이 DB에 저장하지 못하기 때문이다.

JPA 적용

JPA에서 중요한것은 객체와 테이블을 매핑하는것이다.


@Data
@Entity
public class Item {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "item_name",length = 10)
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • @Entity: JPA가 사용하는 객체, 이게 있어야 JPA가 인식가능
  • @Id: 테이블의 PK와 해당 필드를 매핑
  • GeneratedValue: PK값을 데이터베이스에서 생성하는 Identity방식 사용, Mysql auto increment
  • Column: 객체의 필드를 테이블 컬럼과 매핑
    필드와 칼럼명이 다를때 name지정가능, camel은 지원해서 사실상 안적었어도 됨

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

    private final EntityManager em;

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

    @Override
    public Item save(Item item) {
        em.persist(item);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = em.find(Item.class,itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setQuantity(updateParam.getQuantity());
        findItem.setPrice(updateParam.getPrice());
    }

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

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String jpql = "select i from Item i";
        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();
    }
}

스프링을 통해 EntityManage를 주입받음, JPA의 모든 동작은 EntityManager를 통해서 수행됨, 엔티티매니저 내부에 데이터소스를 가지고있고, DB접근가능

@Tranasactional: JPA의 모든 데이터변경은 트랜잭션 안에서 수행되야함.
원래는 서비스에서 걸어야하는데, 해당 포스트 참고 복잡한 비즈니스 로직이 서비스단에 없어서 서비스 계층에서 걸지 않고, 리포지토리에 걸었다.

  • em.persist
    JPA에서 객체를 저장할때 엔티티 매니저가 제공하는 persist()메서드를 사용한다.

  • update
    em.update()같은 메서드 호출 x
    JPA는 트랜잭션이 커밋될때, 변경된 엔티티가 있는지 확인
    -> 변경되었을때 update sql실행, 우리는 트랜잭션 롤백하니까 update쿼리를 안날림

    JPA가 어떻게 엔티티 객체를 찾는지는 영속성 컨텍스트를 이해해야함
    -> 나중에 JPA편에서 설명,
    지금은 그냥 커밋시점에 JPA가 변경된 엔티티 객체를 찾아서 UDATE SQL을 실행한다고 이해하기

  • em.find()
    단건 조회시 사용

  • fildAll
    여러 데이터를 복잡하게 사용할때 jpql을 사용한다.
    하지만 동적쿼리 부분도 너무 불편하게 if문으로 조건 검사를 해야하는데 -> 나중에 QueryDsl로 해결한다.

@Configuration
public class JpaConfig {
    private final EntityManager em;

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

    @Bean
    public ItemService itemService(){
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository(){
        return new JpaItemRepositoryV1(em);
    }
}

@Slf4j
@Import(JpaConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

Test 결과

그런데 update부분에서 봐야할 것이 있다.

insert쿼리는 날라갔는데 findById로 찾아올때는 select쿼리가 나오지 않았다.
그이유가 나중에 자세히 설명할것인데,
Item savedItem = itemRepository.save(item); 먼저 저장하면 jpa내부 캐시에 잠깐 저장이 된다.
그다음에 update를 하면 내부 캐시에 있는 data가 바뀌는것이다.
그다음에 find를 하게되면 DB에서 불러오는게아니라 내부 캐시에 있는 data를 가져오기떄문에 select쿼리를 날리지 않는것이다.

@Repository와 예외변환

JPA의 경우 예외가 발생할 경우 JPA예외가 발생한다.
예외처리에 대한 자세한 글은 이 포스트를 참고해주십쇼.

리포지토리에 id가 1인 item이 있고 service에서 id가 1인 item을 저장한다고 치자.
여기서 service에서 예외가 날라오는경우 try catch문을 쓴다고 치자.

pk가 1로 동일하므로 persistenceException이라는 JPA예외를 던질것이고
try{
}catch(persistenceException e){
}
이런식으로 서비스에서 잡는다면 JPA에 종속적이게 된다.

그러나, 실제로 실행을 시켜보면 스프링 예외 추상화 DataAccessException이 터진다.

왜일까?

  • @Repository의 기능
    해당 애노테이션이 붙은 클래스는 컴포넌트 스캔의 대상이 된다.
    해당 애노테이션이 붙은 클래스는 예외 변환 AOP의 적용대상이 된다.
    스프링과 JPA를 같이 쓰는 경우 스프링이 JPA예외변환기 PersistenceExceptionTranslator를 등록한다.

예외변환 AOP프록시가 JPA관련 예외가 발생하면 JPA예외 변환기를 통해 예외를 스프링 데이터 접근 예외로 변환한다.


findAll메서드에서 지금 selezzzct로 오류가 있다.

Test수행시 springCGLIB어쩌구가 뜨는것을 볼 수 있다.
프록시가 만들어 진것을 볼 수 있는데 예외변환을 처리해주는 프록시가 나온것을 확인 해 줄수 있다.

즉, jpa리포지토리 메서드 안에서는 JPA예외가 터지는데 밖으로 서비스 계층까지 예외를 던질때는 프록시가 대신 스프링 예외 변환으로 바꾸어서 던져주는것이다.

스프링 데이터 JPA 적용

public interface SpringDataJpaItemRepository extends JpaRepository<Item,Long> {
    List<Item> findByItemNameLike(String itemName);

    List<Item> findByPriceLessThanEqual(Integer price);

    //쿼리메서드 아래 메서드와 같은 기능
    List<Item> findByItemNameLikeAndPriceLessThanEqual(String itemName, Integer price);

    //쿼리 직접 실행
    @Query("select i from Item  i where i.itemName like :itemName and i.price <= :price")
    List<Item> findItems(@Param("itemName") String itemName, @Param("price") Integer price);
}

스프링 데이터 JPA가 제공하는 JpaRepository 인터페이스를 상속받으면 해당 인터페이스에서 제공하는 메서드를 사용할 수 있다.

그러나, 이름과 가격으로 검색하는 기능은 공통으로 제공을 하지 않으므로, 우리가 @Query를 사용하여 직접 실행 시켜야한다.

조건

  • 이름 조건만 가지고 검색하는경우
  • 가격 조건만 검색하는경우
  • 이름과 가격조건 둘다 가지고 검색하는경우
  • 조건없이 전부 가져오는경우

findItems메서드는 이름과 가격조건 둘다 가지고 검색하는 경우를 만든것이다.
이름,가격만 가지고 검색하는 기능은 jpa가 제공한다.

@Repository
@RequiredArgsConstructor
@Transactional
public class JpaItemRepositoryV2 implements ItemRepository {

    private final SpringDataJpaItemRepository repository;

    @Override
    public Item save(Item item) {
        return repository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = repository.findById(itemId).orElseThrow();
        findItem.setItemName(updateParam.getItemName());
        findItem.setQuantity(updateParam.getQuantity());
        findItem.setPrice(updateParam.getPrice());
    }

    @Override
    public Optional<Item> findById(Long id) {
        return repository.findById(id);
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        if(StringUtils.hasText(itemName) && maxPrice !=null){
            return repository.findItems("%" + itemName + "%",maxPrice);
        }else if(StringUtils.hasText(itemName)){
            return repository.findByItemNameLike("%" + itemName + "%");
        }else if(maxPrice != null){
            return repository.findByPriceLessThanEqual(maxPrice);
        }else{
            return repository.findAll();
        }
    }
}

Jdbc -> Jpa로 바꾸는 경우를 대비해 ItemRepository를 interface로 만들었기 떄문에 구현체를 만들어줘야한다.
[ItemService는 ItemRepository에 의존하기 때문에 ItemService에서 SpringDataJpaItemRepository를 그대로 사용할 수 없다.]

SpringDataJpaItemRepository를 가지고 ItemRepository를 구현한 JpaItemRepositoryV2를 만들었다.

findAll메서드를 보면 itemName,maxPrice의 유무에 따라서 if else문을 통해서 각 조건에 따라 다르게 메서드를 호출하였다.

하지만, 이렇게 모든 조건을 고려하여서 탐색 메서드를 만들고, if else문으로 로직을 만드는 것은 복잡하고, 실수할 가능성이 높다.

그래서 이런 동적 쿼리에 해당하는것은 경우에 따라 모든 메서드를 만드는것이 아닌, QueryDsl을 통해서 가져오게 된다.

@Configuration
@RequiredArgsConstructor
public class SpringDataJpaConfig {
    private final SpringDataJpaItemRepository springDataJpaItemRepository;

    @Bean
    public ItemService itemService(){
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository(){
        return new JpaItemRepositoryV2(springDataJpaItemRepository);
    }
}

@Slf4j
@Import(SpringDataJpaConfig.class) // SpringDataJpaConfig 임포트
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Bean
	@Profile("local")
	public TestDataInit testDataInit(ItemRepository itemRepository) {
		return new TestDataInit(itemRepository);
	}

}

Test결과

QueryDsl

JPA와 QueryDsl에 대한 내용은 추후 다른 편에서 자세히 소개하므로, 아 이렇게 쓰는구나 감을 잡고 가자.

우선 QueryDsl을 사용하는 이유는 동적쿼리를 만들때 개발자가 오류를 낼 수 있기 때문이다.

jpql로 쿼리문을 작성할때 개발자가 잘못 작성하더라도 컴파일타임에 오류를 낼 수가 없다.

즉, 실제 jpql문을 날려서 오류가 나야 런타임에러로 개발자가 알 수 있다.
고로 java처럼 sql을 작성해서 컴파일 타임에 오류를 만들 수 는 없을까? 이게 QueryDsl이다.

gradle에 추가

	//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"

jpaItemRepoitoryV3

@Repository
@Transactional
public class JpaItemRepositoryV3 implements ItemRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public JpaItemRepositoryV3(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public Item save(Item item) {
        em.persist(item);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = findById(itemId).orElseThrow();
        findItem.setItemName(updateParam.getItemName());
        findItem.setQuantity(updateParam.getQuantity());
        findItem.setPrice(updateParam.getPrice());
    }

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

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        List<Item> result = queryFactory
                .select(item)
                .from(item)
                .where(likeItemName(itemName), maxPrice(maxPrice))
                .fetch();
        return result;
    }

    private BooleanExpression likeItemName(String itemName) {
        if(StringUtils.hasText(itemName)){
            return item.itemName.like("%" + itemName + "%");
        }
        return null;
    }

    private BooleanExpression maxPrice(Integer maxPrice){
        if(maxPrice != null){
            return item.price.loe(maxPrice);
        }
        return null;
    }
}

Querydsl을 사용하려면 JpaQueryFactory가 필요하다. JpaQueryFactorys느 JPA쿼리인 JPQL을 만들기 위해 EntityManager가 필요하다.

save,update,findById는 JPA가 제공하는 기본기능을 사용한다.

findAll
QueryDsl에서는 where(A,B)에 조건을 직접 넣을 수 있는데, 이러면 And처리가 된다.
where에 null을 입력하면 해당 조건은 무시하게 된다.
우리는 이름으로 검색, 가격으로 검색 등의 조건이 있으므로, 해당 조건에 맞게 BooleanExpression 형식으로 메서드를 만들고 쿼리문을 만들었다.

QuerydslConfig


@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {
    private final EntityManager em;

    @Bean
    public ItemService itemService(){
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository(){
        return new JpaItemRepositoryV3(em);
    }
}
@Slf4j
@Import(QuerydslConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Bean
	@Profile("local")
	public TestDataInit testDataInit(ItemRepository itemRepository) {
		return new TestDataInit(itemRepository);
	}

}

보안할점

근데 생각해보면, 서비스에서 바로 JPA를 사용하면 되는데 DI를 위해서 ItemRepository에 의존하고있다.

물론, 추상화를 사용하면 나중에 JPA가 아닌 다른 기술을 도입할때 편리하지만, 너무 클래스도 많아지고 불편한 점이 많다.

트레이드 오프라고 하는데, 포기해야할 부분은 포기하고 개발생산성을 늘리는것이다.

추상화도 비용이든다. 유지보수하기 힘들기 때문이다.

자신의 프로젝트에 맞게 추상화를 사용할지 말지 결정하면 되겠다.

실용적인 구조-보안


이제는 ItemService가 ItemRepositoryV2인터페이스에 직접적으로 의존하게 만들겠다.
또한 동적 쿼리를 사용해야하는 부분은 따로 분리를 하겠다.

이렇게 하면, JPA가 기본으로 제공하는것은 ItemService가 JPA에 직접적으로 의존하고,
Query Dsl을 사용해야하는 부분은 따로 빼두어서 분리시켜두었다.

 public interface ItemRepositoryV2 extends JpaRepository<Item, Long> {
 }
@Repository
 public class ItemQueryRepositoryV2 {
 private final JPAQueryFactory query;
 public ItemQueryRepositoryV2(EntityManager em) {
 this.query = new JPAQueryFactory(em);
    }
 public List<Item> findAll(ItemSearchCond cond) {
 return query.select(item)
                .from(item)
                .where(
 maxPrice(cond.getMaxPrice()),
 likeItemName(cond.getItemName()))
                .fetch();
    }
 private BooleanExpression likeItemName(String itemName) {
 if (StringUtils.hasText(itemName)) {
 return item.itemName.like("%" + itemName + "%");
        }
 return null;
    }
 private BooleanExpression maxPrice(Integer maxPrice) {
 if (maxPrice != null) {
 return item.price.loe(maxPrice);
        }
 return null;
    }
 }

Query dsl을 사용해야하는 findAll메서드만 ItemQueryRepositoryV2 클래스를 따로 만들어서 빼두었다.

 @Service
 @RequiredArgsConstructor
 @Transactional
 public class ItemServiceV2 implements ItemService {
 private final ItemRepositoryV2 itemRepositoryV2;
 private final ItemQueryRepositoryV2 itemQueryRepositoryV2;
    @Override
 public Item save(Item item) {
 return itemRepositoryV2.save(item);
    }
    @Override
 public void update(Long itemId, ItemUpdateDto updateParam) {
 Item findItem = findById(itemId).orElseThrow();
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }
    @Override
 public Optional<Item> findById(Long id) {
 return itemRepositoryV2.findById(id);
    }
    @Override
 public List<Item> findItems(ItemSearchCond cond) {
 return itemQueryRepositoryV2.findAll(cond);
    }
 }

이제 서비스는 ItemRepositoryV2라는 JPA에 직접적으로 의존하게 된다. (findItems를 제외한 모든 메서드)
또한, 쿼리 Dsl을 사용하기위해서 ItemQueryRepositoryV2를 사용하게 된다.(findItems메서드)

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글