개발자로 후회없는 삶 살기

spring PART.JPA 본문

[백엔드]/[spring | 학습기록]

spring PART.JPA

몽이장쥰 2023. 5. 16. 00:22

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com:443

 

본론

- jpa 소개

스프링과 jpa 자바 엔터프라이즈 시장의 주력 기술입니다. 스프링이 DI 컨테이너를 포함한 어플리케이션 전반의 기능을 제공한다면, JPA는 ORM 데이터 접근 기술을 제공합니다. jpa는 데이터 접근 기술에서 매우 큰 생산성을 향상할 수 있습니다. SQL도 JPA가 대신 작성하고 처리해줍니다. 

 

> jpa, 스프링 데이터 jpa, Querydsl로 이어지는 전체 그림을 보고 이 기술들을 우리 어플리케이션에 적용하며 장점을 알아보겠습니다.

 

- SQL 중심적인 개발의 문제점

그전에 ORM 개념을 먼저 학습해야 합니다. 어플은 객체 지향언어를 사용합니다. DB는 관계형을 씁니다. 어플은 객체로, DB는 관계형으로 하는데 이는 객체를 관계형 DB에 저장하는 시대라고 보면됩니다. 근데 관계형 DB는 SQL을 잘 써야해서 SQL 중심적인 개발이 됩니다.

 

> 맨날 CRUD sql을 해야하고 item 레포만 봐도 객체를 파라미터 바인딩해서 sql로 바꾸고 sql을 rs해서 객체로 바꾸는 코드를 맨날 하고 있고 탬플릿을 사용해서 많은 부분이 해결되지만 그래도 sql을 결국 작성해야 합니다.

 

객체에 필드(TEL)가 하나 추가 되면 모든 쿼리에 필드를 다 추가해야 합니다. insert, select, update 모두 이 새로 추가된 필드를 추가해야 합니다. sql에 의존적인 개발을 피하기 어렵습니다. 이렇게 다 하나하나 고치다가 필드는 추가했는데 update sql에는 빼 먹으면 큰 문제가 발생합니다.

 

또한 이 둘은 패러다임이 다릅니다. 객체 지향은 추상화, 캡슐화 등 다양한 장치를 제공합니다. 이를 DB에 저장하려면 객체를 일단 sql로 바꿔야합니다. 근데 이 객체를 sql로 변환하는 것을 DTO, 파라미터 맵핑 이런 걸로 다 개발자가 변환하고 개발자가 sql 작성해야 합니다. 사실 개발자가 sql 맵퍼입니다.

 

-> 객체와 관계형 db의 차이
1. 상속

객체는 상속이 있습니다. 근데 테이블은 상속이라는 개념이 없습니다. Album을 db에 저장한다고 하는데 Album 객체에 item 참조가 있으면 album을 insert하려면 album은 album 테이블에 인서트하고 item은 item 테이블에 쿼리를 2번으로 쪼개서 insert 해야합니다.

 

album을 조회하고 싶으면 item을 참조하고 있어서 album의 item 필드에도 데이터를 넣어야 합니다. 두 테이블을 join하고 각각 생성하고 album에 set해야하는데 생각만해도 복잡합니다. 이래서 db에 저장할 객체는 상속 관계를 잘 안 씁니다.

 

근데 자바 컬랙션에 album을 저장하면 add 하면 끝입니다. 조회는 get하면 끝입니다. 부모 타입으로 선언해서 다형성 활용도 가능합니다.

 

2. 연관관계

객체는 참조를 사용해서 팀 객체를 가지고 있습니다. 테이블은 팀 테이블에 외래키로 join 해야합니다. 그래서 객체를 테이블에 맞춰서 모델링을 많이 합니다.

 

ex) 예를들어

멤버에 id가 있고 팀 id를 가지고 있습니다. 테이블에 외래키가 있어서 그렇습니다. 객체 연관관계를 보면 원래는 팀 객체를 넣는게 맞는게 db 연관관계를 봤을 때 팀 객체를 넣을 수는 없으니 팀 id를 넣게 됩니다. 이렇게 하면 편하고 객체를 테이블에 맞춰서 모델링하는 것입니다. 

 

하지만 객체는 참조가 들어가는게 더 맞습니다.

 

-> 객체다운 모델링

팀을 참조를 통해 멤버가 가지고 있는게 더 맞습니다. 근데 그러면 팀을 db에 저장하는게 안 됩니다. 그래서 member.getTeam().getId()로 합니다. 이게 매우 번접합니다.

 

조회를 할 때라면 sql에서 join을 해야하고 멤버 정보를 얻고 팀 정보를 얻고 팀을 멤버에 넣어야합니다. 이러면 개발자 입장에서는 매우 번잡합니다.

 

-> 자바 컬랙션 관리

객체 모델링을 컬랙션으로 관리하면 member에 이미 team의 참조가 있으니 add해서 member를 더하면 됩니다. 컬랙션에 넣으면 번잡한 과정이 없어집니다.

 

 

3. 객체그래프 탐색

객체는 자유롭게 그래프를 그릴 수 있어야합니다. 근데 sql을 쓰는 순간 처음 실행하는 sql에 따라서 탐색 범위가 결정이 되어버립니다. 처음에 멤버와 팀 조인을 조회했습니다. 그러면 팀과 멤버만 조회할 수 있고 멤버에 order 객체가 참조가 있어도 order는 볼 수 없습니다. 

 

-> 엔터티 신뢰 문제

이 상황에서 멤버를 엔터티라고 하면 멤버를 조회하면 getorder해도 데이터가 없어서 엔터티를 신뢰할 수 없습니다. 이렇게 되어서 계층 분리가 어렵습니다. 코드를 봤을 때 이 멤버가 현재 팀을 가지고 있는지 현재 order를 가지고 있는지 믿을 수가 없습니다. 실행한 sql에 따라서 member가 가져올 수 있는 필드값이 달라지는 것입니다. 이게 물리적으로는 계층이 분할되어있지만 논리적으로는 분리되지 않은 것입니다.

 

- 최종

객체를 자바 컬랙션에 저장하듯이 DB에 저장할 수는 없을까? 이런 고민이 생겨서 jpa가 나왔습니다. 

 

- JPA 소개

Java Persistense API는 ORM 표준입니다. JPA가 표준 인터페이스이고 하이버네이트가 구현체입니다.

 

- ORM

Object-relational mapping(객체 관계 매핑) 객체랑 관계형 db를 어떻게 맵핑할 것이냐의 내용으로 메인 사상은 객체는 객체대로 설계하고 관계형 db는 관계형 대로 설계를 합니다. 그러면 둘의 차이가 있을 텐데 이 둘을 ORM 프레임워크가 중간에서 맵핑해줘서 우리는 객체처럼 쓸 수 있게 해주는 것입니다.

 

-> 동작

자바 어플리케이션과 jdbc 사이에서 동작해서 jdbc를 직접 사용하는 것이아니라 자바 어플에서 jpa를 쓰고 jpa가 내부에서 jdbc를 써서 sql을 db에 전달하는 것입니다.

 

1. 저장

그림을 보면 DAO를 레포라고 보면 내부에 DB 접근하는 코드가 있을 텐데 Persist라고하면 저장하는 것으로 member 객체를 jpa에 넘기면서 저장합니다. jpa가 이 member entity를 분석해서 insert sql을 만들고 jdbc를 사용해서 db에 넣습니다. 이는 패러다임의 불일치를 해결합니다.

 

2. 조회

jpa는 자바 컬랙션처럼 생겨서  find라고 하면서 id를 넘겨주면 select 쿼리를 만들어서 db에 넘겨서 결과가 오면 jdbc를 써서 rs를 다 쓰고 맴버 객체를 만들어서 반환해줍니다. 마치 우리는 자바 컬랙션에 조회하는 것처럼 조회할 수 있고 복잡한 것은 jpa가 해줍니다.

 

- 왜 jpa를 사용해야 하는가? ✅

1. sql 중심적인 개발을 쓰면 개발자가 맵퍼가 됩니다. sql 중심적인 개발에서 객체 중심 개발로 넘어갈 수 있습니다. 
2. 생산성 : 개발자는 개발자가 할 일, 나머지는 jpa에게 맡길 수 있습니다.
3. 유지보수 편리
4. 패러다임 불일치 해결 등

 

- 생산성

저장은 jpa.persist(member)하면 그냥 jpa가 멤버 객체를 읽어서 insert 쿼리 만들어서 db에 저장합니다. 조회도 jpa.find로 찾을 수 있고 수정이 진짜 놀라운게 객체의 이름을 set으로 바꿔주면 됩니다. 컬랙션을 생각하면 컬랙션에서 멤버를 find해서 set으로 값을 변경하면 레퍼런스이기 때문에 실제 메모리의 값이 변경됩니다. 다시 컬랙션에 집어 넣을 필요가 없습니다. 그것과 동일하게 동작합니다. 생산성이 확실히 증가합니다.

 

 

- 유지보수

만약에 tel 필드를 넣으려면 모든 sql을 다 뒤져서 새로운 필드를 넣어야합니다. jpa를 사용하면 필드만 변경하면 sql에 자동으로 추가가 됩니다.

 

- 패러다임 불일치 해결

jpa가 객체와 관계형 db의 패러다임 불일치를 해결해줍니다.

 

1. 상속
1) 저장

객체는 상속이 있고 테이블은 슈퍼 타입, 서브 타입이 있습니다. 개발자가 앨범을 저장하면 insert를 2번해야하는데 앨범에 저장하고 item에 저장해야 합니다. 앨범을 저장하면 jpa가 쿼리 2번 날려서 저장해줍니다.

 

2) 조회

앨범을 조회하려면 앨범과 상품 테이블을 조인해야 합니다. 앨범 객체를 만드려면 앨범 객체와 그 내부에 참조하는 item 객체도 만들어야하기 때문입니다. 이것을 jpa가 find하면 두 테이블을 join해서 데이터를 가져와서 데이터를 다 채워서 반환해줍니다. 

 

2. 연관관계

멤버를 조회하면 팀도 찾을 수 있습니다. 이렇게 객체 지향적으로 신뢰할 수 있는 엔터티가 보장이 됩니다.

 

4. jpa와 비교하기

jpa는 자바 컬랙션이라고 보면 되고 나머지는 자바 컬랙션이 db와 알아서 해결해준다고 이해하면 됩니다. 회원 1, 2에 id를 100으로 같은 pk를 넣으면 이 둘이 같다고 합니다. 컬랙션도 같은 id를 넣으면 같은 것이라고 봅니다. (자바 컬랙션은 동일한 내용을 가진 여러 객체가 있더라도 하나로 봅니다.)

 

5. 성능 최적화

jpa는 어플과 db 사이에 하나의 계층이 있는 것입니다. 항상 이렇게 뭔가 사이에 있으면 2가지 일을 할 수 있는데 캐시와 버퍼링 write로 성능 최적화가 가능해집니다. jpa가 자동으로 성능 최적화를 해줍니다.

 

1) 1차 캐시와 동일성 보장

같은 트랜잭션 안에서는 같은 엔터티를 반환합니다. 이게 많지는 않지만 약간의 조회 성능을 향상시킵니다. 처음 id 100으로 조회하면 sql이 날라가는데 두번째 id 100으로 조회하면 캐시가 적용이 됩니다. 그래서 둘을 '=='하면 같은 객체로 True가 나옵니다.

 

2) 트랜잭션을 지원하는 쓰기 지연

방금은 캐시였다면 버퍼링 write로 버퍼를 모아서 한 번에 write하는 것입니다. 트랜잭션을 커밋할 때까지 insert sql을 모읍니다. jdbc batch sql을 활용해서 jpa가 커밋 전까지 sql을 모으고 커밋하면 한 번에 db에 보냅니다.

 

3) 지연 로딩과 즉시 로딩

지연로딩 : 객체가 실제 사용될 때 로딩하는 것

아까 객체 그래프를 자유롭게 탐색할 수 있어야 엔터리를 신뢰할 수 있다고 했습니다. 멤버를 id로 조회하면 jpa가 select 만들어서 조회하면 member가 반환됩니다. 그러면 member.get Team하면 select에 쿼리에 team이 join이 안되어있는데 어떻게 team 데이터를 가져오냐면 getName처럼 team을 사용할 때 jpa가 내부에서 select team 쿼리를 실행합니다. 그렇게 해서 팀의 데이터도 가져오는 것입니다.

 

> 지연로딩으로 멤버를 조회하고 어떨 때는 팀을 사용하고 어떨 때는 사용하지 않을 때도 있으니 팀을 사용하기 전까지 팀의 데이터를 가져오는 것을 미뤘다가 실제 쓸 때 가져오는 것입니다. 이런 것을 다 jpa가 자동으로 처리해줍니다.

 

즉시 로딩 : 처음 조회할 때 한번의 join으로 필요한 연관관계를 다 조회

근데 지연로딩하면 쿼리가 두번 나갑니다. 근데 때로는 "거의 멤버를 볼 때 매번 팀을 같이 쓴다면?" 쿼리 한 번으로 가져오는 것이 좋으니 즉시 로딩을 제공해서 멤버를 조회할 때 항상 팀도 같이 가져오도록 합니다. 

 

> 일단 개발을 할 때 지연로딩으로 쭉 개발을 해 놓고 그러다가 이 부분은 최적화가 필요하다면 즉시 로딩으로 바꿔서 분리된 둘을 하나의 조인 쿼리로 바꿔서 처음부터 미리 로딩하도록 할 수 있습니다. jpa가 이렇게 성능 최적화까지 제공합니다.

 

+ ORM은 RDB의 위에 있는 기술로 실무에서 장애의 90%는 DB에서 발생하므로 관계형 DB는 굉장히 깊이 있게 학습해야 합니다.

 

- JPA 설정

spring-boot-starter-data-jpa 라이브러리를 추가합니다. spring-boot-starter-jdbc는 jpa가 다 해결해줘서 없어도 됩니다.

 

이렇게 하면 하이버네이트 구현체가 추가가 되고  

 

persistence-api가 JPA이고

spring data jpa도 들어옵니다. 

 

프로퍼티스에 jpa 로그를 추가합니다. jpa는 sql을 생성한다고 했으니 org.hibernate.sql은 하이버 네이트가 생성하고 실행하는 sql을 확인할 수 있고 BasicBinder를 하면 sql에 바인딩 되는 파라미터 바인딩을 확인할 수 있습니다.

 

- jpa 개발

jpa에서 가장 중요한 부분은 객체와 테이블을 중간에서 맵핑하는 것입니다. jpa가 제공하는 어노를 사용해서 item 객체와 테이블을 맵핑하면 됩니다.

 

-> 맵핑

@Data
@Entity
@Table(name = "item")
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;
    }
}

domain인 item 객체에 테이블과 어떻게 되는지 맵핑 정보를 줘야합니다. @Entity를 넣어줘야 "jpa로 관리하는 것 이구나" 하고 jpa 객체로 인정이 됩니다. 이게 테이블과 맵핑이 되어서 관리가 되는 객체로 jpa에서는 엔터티라고 부릅니다. pk는 @Id로 "아 얘가 pk구나"를 알려줍니다. Generate를 해서 자동 생성을 씁니다. IDENTITY가 db에서 값을 넣어주는 전략입니다.

@Column은 "이 필드는 db의 컬럼과 맵핑이 돼"를 의미합니다. 비워두면 컬러명과 필드명이 같으면 없어도 됩니다. 역시 카멜과 언더 스코어는 자동 변환을 해줘서 생략해도 됩니다. table명도 줄 수 있는데 도메인 클래명과 실제 테이블이 같으면 생략해도 됩니다. jpa는 public 또는 protected로 기본 생성자가 꼭 있어야합니다.

 

- 레포지토리

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

이렇게 하면 맵핑이 끝납니다. 이제 이것을 가지고 crud하는 코드인 jpa 레포를 만듭니다. jpa의 모든 데이터 변경은 트랜잭션 안에서 일어나서 @Transactional을 붙여야합니다. jpa에서 데이터를 변경할 때는 항상 @트랜잭션이 있어야합니다.

 

private final EntityManager em;

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

jpa는 앤터티 매니저 의존 관계 주입을 받아야하고 이게 jpa입니다. 여기에 대고 저장하고 조회를 하는 것이고 지금 스프링과 통합했기에 스프링에서 다 자동으로 만들어 주고 데이터 소스도 넣어주고 빈 등록도 해줍니다. 지금은 config로 직접 등록할 거라서 생성자를 직접 만듭니다. 

 

1) save

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

// 탬플릿
@Override
public Item save(Item item) {
    String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";
    SqlParameterSource parma = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(parma);
    item.setId(key.longValue());
    return item;
}

em.persist로 item을 넣으면 끝납니다. 이렇게 persist를 하면 item 객체의 정보를 가지고 db에 저장을 합니다. 그리고 id에도 xml 때처럼 넣어줘서 그대로 반환하면 끝납니다.(mybatis 때도 이렇게 했습니다.) 진짜 자바 컬랙션에 넣는 것 같습니다. 메모리에서 이렇게 했습니다. 파라미터 바인딩도 jpa가 자동으로 해줍니다.

 

2) update

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

em.find에 타입과 id를 넣어서 item을 찾은 다음에 컬랙션이라고 가정하면 그냥 setItemName할 것입니다. jpa에서도 그렇게 합니다. 거의 메모리 레포를 사용할 때와 똑같습니다. 이렇게 하면 실제 db update 쿼리가 나갑니다. 

이게 set만 했는데 어떻게 update 쿼리가 나갈까요? jpa가 내부에 어떤 데이터가 바뀌는 지 다 알고 있습니다. 그리고 트랜잭션이 커밋되는 시점에 sql을 날려서 그때 update 쿼리를 만들어서 날리고 그 시점에 itemName이 바뀌었다면 "어! 상품명이 변경되었네"하고 update 쿼리를 만들어서 db에 날립니다. 

 

> 트랜잭션은 try catch에 실행할 메서드가 있는데 실행할 메서드를 수행하고 커밋을 날렸었습니다. 커밋할 때 db에 update sql을 날립니다. 마치 컬랙션을 쓰듯이 사용할 수 있고 그래서 메모리 레포를 배웠던 것입니다.

 

3) findBYId

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

find하고 타입과 pk를 넣으면 item을 반환합니다. 지금 optional을 반환하니 Nullable로 반환합니다. 이렇게 하면 끝납니다. 정말 자바 컬랙션에서 find하는 것 같습니다.

 

4) findAll

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String jpql = "select i from Item i";

    List<Item> result = em.createQuery(jpql, Item.class)
            .getResultList();

    return result;
}

얘는 jpql을 쓰는데 pk 식별자를 기반으로 하나를 조회할 때는 findById처럼 하면 되는데 여러 조건으로 복잡하게 쿼리를 짜야하면 jpa는 sql이 아니라 jpql을 제공합니다. 얘는 객체 쿼리 언어로 from Item이 Item 도메인 객체로 i가 Item 객체 자체입니다. 

동적쿼리를 빼고 단순하게 하면 createQuery에 jpql(원래는 sql), 클래스로 반환타입을 주고 getResultList라고 하면 결과가 나옵니다. 이렇게 하면 jpa 메서드를 쓰면 파라미터 맵핑 같은 것을 sql을 만들면서 다 해줍니다.(진짜 밑바닥부터 공부 안했으면 이해도 못했을 것입니다.) jpql 문법은 sql과 비슷한데 테이블을 대상으로 하는게 아니라 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();
}

근데 얘도 이렇게 하면 동적쿼리에 약합니다. 동적쿼리를 추가합니다. 보면 이전 탬플릿 코드와 비슷하고 jpql에 이름 기반으로 파라미터 바인딩을 하여 :itemName하고 query.setParameter하면 이름 기반으로 파라미터 바인딩이 됩니다.

 

- 정리

1) jpa의 모든 동작은 엔터티 매니저를 통해 이뤄지고 매니저는 내부에 데이터 소스를 가지고 있고 db 접근도 하고 jdbc api도 사용합니다.

2) @Transactional : jpa의 모든 데이터 변경(등록, 수정, 삭제)는 트랜잭션 안에서 이뤄져야 합니다. 변경의 경우 일반적으로 서비스 계층에서 트랜잭션을 시작해서 문제가 없는데 이번에는 비즈니스 로직이 없어서 트랜잭션을 안 걸어서 여기 겁니다. jpa에서 데이터 변경 시 트랜잭션은 필수입니다. 따라서 레포에 겁니다. 일반적으로는 서비스에 겁니다.

 

- 실행

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

설정을 변경하고 실행합니다. em이 필요합니다. 실행해보면 내장 DB로 잘 됩니다.

 

save를 보면 insert into를 jpa가 만들어서 냅니다. 개발자는 sql을 만든적이 없는데 jpa가 만들어서 해주고 파라미터 바인딩도 해줍니다.

 

@Test
void save() {
    //given
    Item item = new Item("itemA", 10000, 10);

    //when
    Item savedItem = itemRepository.save(item);

레포.save(item)하면 jpa에서 em.persist한 줄 했는데 jpa가 sql 만들어서 넣어주게 됩니다.

 

findAll에서는 jpql을 읽어서 sql로 바꿔주고 select i라고 한게 sql에서는 필드로 다 들어갑니다. update는 update 쿼리가 안 보입니다. 그 이유는 update test 코드에서 먼저 save를 하는데 save를 하면 캐시에 저장되고 sql로 안 가고 캐시에 가서 그렇습니다. update는 트랜잭션이 커밋되는 시점에 sql을 날린다고 했습니다. 그래서 강제 @commit하면 update sql 로그가 남습니다. app으로 실행해도 잘 됩니다.

 

- 리포지토리 분석

1) 저장

jpa에서 객체를 테이블에 저장할 때는 엔터티 매니저가 제공하는 em.persist()를 사용합니다. 

 

@Override
public Item save(Item item) {
    String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";
    SqlParameterSource parma = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(parma);
    item.setId(key.longValue());
    return item;
}

DB에 값을 저장하려면 insert 쿼리가 필요합니다. 그래서 jpa가 insert 쿼리를 만들어줍니다. insert 쿼리를 보면 id에 값이 null이나 default 또는 빠져있습니다. pk 생성 전략을 identity로 해서 그렇고 쿼리 실행 이후에 item의 id에 pk값이 들어갑니다.


2) 수정

DB에 값을 수정하려면 update 쿼리가 필요해서 jpa가 이런 쿼리를 만들어줍니다. em.update 메서드가 없는데 jpa가 트랜잭션 커밋 시점에 변경된 엔터티 객체가 있는지 확인하고 특정 엔터티 객체가 변경된 경우 update sql을 실행합니다.

 

> 변경된 객체가 있는지 아는 방법은 jpa가 처음에 내부에 원본 객체를 복사해서 스냅샷을 가지고 있고 그거와 지금 바뀐게 있나 트랜잭션 커밋 시점에 체크해서 바뀐 게 있으면 update 쿼리를 만듭니다.

+ 테스트의 경우 @Transactional이 붙어있어서 마지막에 트랜잭션이 롤백되어서 jpa는 롤백하면 update sql을 수행하지 않고 커밋될 때만 수행해서 테스트에서 update sql을 확인하려면 @commit을 붙여야합니다.

 

3) 단건 조회

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

jpa에서 엔터티 객체를 pk 기준으로 조회할 때는 find()를 사용하고 조회 타입과 pk 값을 주면 됩니다.

 

그러면 jpa가 이런 sql을 만들어 바로 객체를 반환해줍니다. 그냥 where을 id로 해서 엔터티 한 행을 가져오는 것입니다. 그리고 rowmapper를 썼을 때처럼 바로 객체로 반환해줍니다. > pk를 가지고 조회하는 것이 아니라 금액이 10000원 이상인 상품을 가지고 조회하려면 jpql을 사용해야 합니다.

 

※ jpql

java persistence query lang라는 객체 지향 쿼리 언어를 제공합니다. 주로 여러 데이터를 복잡한 조건으로 조회할 때 사용합니다. sql이 테이블을 대상으로 한다면, jpql은 엔터티 객체를 대상으로 sql을 실행합니다. 엔터티 객체를 대상으로 해서 from 다음에 Item(대문자) 엔터티 객체 이름이 들어갑니다.

 

결과적으로 jpql을 실행하면 그 안에 포함된 엔터티 객체의 맵핑 정보를 활용하여 sql을(엔터티 정보를 확인해서 컬럼 명을 다 만든 모습) 만듭니다. 위의 jpql을 실행하면 이런 sql이 실행되고 파라미터는 이름 기반의 jpql이 sql에서 ?로 변합니다.

 

파리비터 바인딩은 jpql에는 이름 기반으로 ':'하고 query.setParameter()하면 됩니다. 하지만 동적 쿼리 문제를 해결할 수 없어서 querydsl을 사용합니다.

 

- 예외변환

스프링 제공 예외를 jpa에서 어떻게 하는지 보겠습니다. jpa의 경우 예외가 발생하면 스프링 예외와 전혀 관계없는 jpa 예외를 발생시킵니다.

em은 순수 jpa 기술이고 스프링과는 관계가 없어서 em은 예외가 발생하면 jpa 관련 persistenceEx와 그 하위 예외와 추가로 Illegal 예외도 발생시킵니다.

 

그러면 jpa 예외를 스프링 예외 추상화로 어떻게 변환할까요? jpa는 예외가 터지면 perEx가 터진다고 했습니다. 레포에서 못 잡으면 서비스까지 perEx가 넘어갑니다. 그러면 서비스 계층이 jpa 기술에 종속적이게 됩니다. (이전에는 스프링 제공 예외가 런타임 예외라서 스프링에서 throw를 선언하지 않게 하여 jdbc 기술 종속을 피했습니다.)

 

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String jpql = "selectsss i from Item i";

@Repo를 주석처리하고 selectsss라고 하고 테스트에서 문법 오류를 일으키면 IllegalArgumentException가 발생합니다. jpa의 illegal 예외가 발생한 것입니다.

 

@Repo를 주석 해제하면 InvalidDataAccessApiUsageException으로 바뀝니다. 이게 스프링 예외 계층입니다. 비밀은 @Repo로 em은 스프링 예외 변환에 대해 전혀 모르고 jpa는 orm 표준이라서 jpa 예외를 던지는데 @Repo는 컴포넌트 스캔의 대상 기능도 있지만 추가로 레포가 붙은 클래스는 예외 변환 AOP의 적용 대상이라는 기능이 하나 더 있습니다.

 

> 스프링과 jpa를 함께 사용하는 경우 스프링은 jpa 예외 변환기(PersistenceExceptionTranslator)를 내부적으로 등록하고 jpa 예외 변환기를 통해 발생한 예외를 스프링 예외로 변환합니다.

 

 

-> 그림

em에서 persistenceEx가 터졌고 레포로 전달되는데 레포에서 처리 못한다고 던집니다. 여기서 jpa 레포의 AOP가 '@트랜잭션에서 service의 bizlogic을 try catch에 담고 있는 프록시 객체가 만들어져 대신 등록되고 사용된 것처럼' jpa 레포의 AOP가 만들어집니다. 여기서 예외를 스프링 예외로 바꾸고 레포를 호출한 측에 예외를 던집니다. 결과적으로 @Repo만 있으면 스프링이 JPA 예외를 스프링 예외로 변환해서 런타임 예외로 던지고 추상화해줍니다.

 

로그로 찍어보면

 

CGLIb이 프록시로 만들어지고

 

@Repo가 있으면 지금 등록된 레포가 실제 레포가 아니고 실제 레포 코드에 catch 예외 변환을 해주는 코드가 들어간 레포 프록시가 등록되고 동작하는 것입니다. 트랜잭션도 서비스를 프록시 서비스 클래스를 빈에 등록했었는데 지금 레포에 붙어서 레포도 프록시로 만들어주고 

 

@Override
public List<Item> findAll(ItemSearchCond cond) {
    try {
        String jpql = "select i from Item i";
        em.createQuery(jpql, Item.class);
    } catch (PersistenceException e) {
        throw new SQLExceptionTranslator().translate("select")
    }

@Repo로 예외가 발생해도 프록시 레포가 작동해서 예외를 변환해줍니다. 우리는 이 덕분에 jpa 기술에 의존하지 않기 위한 스프링 예외 변환을 고민하지 않아도 되는 것입니다. jdbc랑 mybatis는 스프링 꺼라서 스프링 예외 변환을 당연히 해주는데 jpa는 완전 다른 orm 표준이라서 이런 작업을 프록시에서 대신 해줍니다.

Comments