[SpringDB] #5. JPA

bien·2024년 1월 8일
0

springDB2

목록 보기
1/2

SQL 중심적인 개발의 문제점

  • 애플리케이션: 객체지향 언어 (Java, Scala)
  • 데이터베이스: 관계형 DB (Oracle, MySQL)
    • 지금 시대는 객체를 관계형 DB에 관리하는 시대
      • 근데 관계형 DB는 SQL만 알아들을 수 있음. 이것 때문에 SQL 중심적인(의존적인) 개발이 발생한다는 점이 문제
  • CRUD의 무한반복
    • INSERT INTO
    • UPDATE
    • SELECT
    • DELETE
    • 자바 객체를 SQL로...
    • SQL을 자바 객체로...

  • 이처럼 테이블에 컬럼 하나만 늘어도 쿼리를 다 수정해야 함..
    • 오류의 발생 가능성 大

패러다임의 불일치: 객체 VS 관계형 DB

객체지향 프로그래밍은 추상화, 캡슐화, 정보은닉, 상속, 다형성 등 시스템의 복잡성을 제어할 수 있는 다양한 장치들을 제공한다.

객체 중심형 사고방식은 객체를 어딘가에 저장해야 한다.
현실적 대안은 주로 관계형 DB에 저장하는 것. 이 방식에는, 필연적으로 SQL 변환의 과정이 요구된다.

  • 이 과정을 누가할까? 개발자가 한다.
    • 개발자 = 유사 SQL 매퍼

객체와 관계형 DB의 차이

  1. 상속
  2. 연관관계
  3. 데이터 타입
  4. 데이터 식별 방법

1. 상속

객체의 상속 개념이 관계형 DB에는 없음. 그나마 슈퍼타입, 서브타입 관계가 유사하지만 같은 개념은 아니다.

만약 Album을 db에 저장하려고 한다면, 다음과 같은 절차를 거쳐야 한다.
1. Album 객체 분해 (쪼갠 자료를 두개의 테이블에 두개의 쿼리로 저장)
2. INSERT INTO ITEM ...
3. INSERT INTO ALBUM ...

만약 Album을 조회하려고 한다면,
1. 각각의 테이블에 따른 조인 SQL 작성 ...
2. 각각의 객체 생성...
3. 상상만 해도 복잡
4. 더 이상의 설명은 생략한다.
5. 그래서 DB에 저장할 객체에는 상속 관계 안쓴다.

만약 자바 컬렉션에 저장한다면, 코드 한줄로 조회, 저장이 모두 끝난다.

list.add(album);
Album album = list.get(albumId);

// 부모 타입으로 조회 후 다형성 활용
Item item = list.get(albumId);

중간에 SQL로 바꾸는 역할을 수행하느라 로직이 번잡해지게 되는 것.

2. 연관관계

  • 객체는 참조를 활용: member.getTeam()
  • 테이블은 외래 키를 사용: JOIN ON M.TEAM_ID = T.TEAM_ID

이 문제를 해결하기 위해, 객체를 테이블에 맞춰 모델링하는 경우가 많다.

class Member {
	String id;
    Long teamId; // TEAM_ID FK 컬럼에 사용되는 내용
    String username;
}    

class Team {
	Long id;
    String name;
}

// SQL문
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES ...

이런식으로 객체를 맞춰두면, SQL INSERT문에 사용하기 편함. 하지만 객체의 연간관계 자체는 참조가 들어가는 (Team 객체를 내부적으로 가지고 있는) 상태가 더 적절하다.

만약 객체 모델링을 조회하려고 한다면,

SELECT M.*, T.*
	FROM member M
    JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

public Member find(String memberId) {
	// SQL 실행..
    Member member = new Member();
    // 데이터베이스에서 조회한 회원 관련 정보를 모두 입력
    Team team = new Team();
    // 데이터베이스에서 조회한 팀 관련 정보를 모두 입력
    
    // 회원과 팀 관계 설정
    member.setTeam(team);
    return member;
}

그러나 이와 달리, 객체 모델링을 자바 컬렉션에서 관리하는 모습은 훨씬 간단하게 이루어진다.

list.add(member);

Member member = list.get(memberId);
Team team = member.getTeam();

객체 그래프 탐색

객체는 자유롭게 객체 그래프를 탐색할 수 있어야 한다.
그러나 SQL을 사용하는 순간, 처음 실행하는 SQL에 따라 탐색 범위가 결정된다.

SELECT M.*, T.*
	FROM member M
    JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

member.getTeam(); // OK
memger.getOrder(); // null

SQL로 Member랑 Team만 조회한 상황이면, Order는 객체에서 조회할 수 없다. (sql에서 조회한 적 없으므로) 이는 곧 엔티티에 대한 신뢰의 문제와 연결된다.

class MemberService {
	...
    public void process() {
    	Member member = memberDAO.find(memberId);
        member.getTeam(); //???
        member.getOrder().getDelivery(); //???
    }
}

물리적으로는 MemberServiceMemberRepository가 분리되어있다. (파일 두개가 분리되어 있다.) 물리적으로는 계층 구조를 짰으나, 우리는 이 Service의 process를 신뢰할 수가 없다. SQL에서 team정보와 order정보를 함께 불러오지 않았다면, 두 정보는 null이다. 즉, 서비스 로직을 작성하려면 해당 DAO코드를 다 확인하여 어떤 SQL이 실행되어 어떤 데이터를 담았는지 눈으로 확인해야만 코드를 작성할 수 있다. 즉, 물리적으로는 계층이 분리되어 있으나 논리적으로는 계층이 분리되어있지 않은 상황이다.

모든 객체를 미리 로딩할 수는 없다.

그럼 그냥 특정 객체를 조회할때마다 연관관계가 있는 모든 객체를 다 가져오면 되는게 아닐까? 그럴 수 없다. 엄청나게 많은 조인이 발생할 것이고, 한 번 데이터를 조회할때마다 메모리가 폭주할 것이다. 따라서, 상황에 따라 동일한 회원 조회 메서드를 여러벌 생성해야 한다.

memberDAO.getMember(); // Member만 조회
memberDAO.getMemberWithTeam(); // Member와 Team 조회
memberDAO.getMemberWithOrderWithDelivery(); // Member, Order, Delivery

즉, 진정한 의미의 계층 분할이 어렵다.

비교하기

String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);

member1 == member2; // 다르다.

class MemberDAO {
	public Member getMember(String memberId) {
    	String sql ="SELECT * FROM MEBER WHERE MEMBER_ID = ?";
        ...
        // JDBC API, SQL 실행
        return new Member(...);
	}
}

두개의 인스턴스가 다른 인스턴스이므로, member1과 member2는 다른 인스턴스이다.
그러나 자바 컬렉션에서 같은 key를 가진 두 객체를 조회하면, 두 객체는 같다.

String memberId = "100";
Member member1 = list.get(memberId);
Member member2 = list.get(memberId);

member1 == member2; //같다.

결론

  • SQL 처리와 Java객체 안에서 (Java Collection안에서) 순수하게 다루는 것과 결과가 다르다.
  • 객체답게 모델링 할 수록 매핑 작업만 늘어난다.
  • 객체를 자바 컬렉션에 저장하듯이 DB에 저장할 수는 없을까?
    • => JPA를 이용하면 그게 가능해진다!

JPA 소개

JPA 기본 개념

JPA란?

  • Java Persistence API
  • 자바 진영의 ORM 기술 표준
    • 정확히는 인터페이스, 구현체가 따로 있다.

ORM이란?

  • Object-relational mapping(객체-관계 매핑)
    • 객체와 관계를 어떻게 매핑할 것인가에 관한 이야기
  • 객체는 객체대로 설계
  • 관계형 DB는 관계형 DB대로 설계
  • ORM프레임워크가 중간에서 매핑
    • 객체는 객체대로 설계하고, 관계형 DB는 관계형 DB대로 설계한다. 그리고 그 둘 사이의 차이를 ORM 프레임워크가 중간에서 맵핑해준다. 따라서 우리는 객체처럼 사용할 수 있다.
  • 대중적인 언어는 대부분 ORM 기술이 존재.

JPA는 애플리케이션과 JDBC 사이에서 동작한다.

JPA 동작 - 저장

JPA 동작 - 조회

MemberDAO에서 객체를 보내주면 JPA에서 사진의 작업을 진행한다. 중간의 SQL와 객체 변환 작업을 모두 JPA에서 수행하므로, 우리는 마치 자바 컬렉션을 사용하는 것 처럼 사용 가능하다. 무엇보다도, 패러다임 불일치의 문제를 해결해준다!

JPA의 역사

예전에 Java 진영에 EJB 엔티티 빈이라는 ORM 기술이 있었다. 그러나 이 기술이 너무 복잡해 개빈 킹이라는 개발자가 직접 하이버네이트라는 오픈 소스를 개발했다. (이것이 EJB 엔티티빈 보다 훨신 좋았다.) 그래서 자바 진영에서 하이버네이트를 기반으로 JPA 표준을 만들었다.

JPA는 표준 명세

  • JPA는 인터페이스의 모음
  • JPA 2.1 표준 명세를 구현한 3가지 구현체: 하이버네이트, EclipseLink, DataNucleus
    • 일반적으로 거의 다 하이버네이트 사용 함.
      • 오픈 소스에서 출발해 실무의 실용성에 맞다.
      • 표준이기에 많은 이들의 의견을 받아 정제되어 있다.

JPA 버전

  • JPA 1.0(JSR 220) 2006년: 초기 버전. 복합 키와 연관관계 기능이 부족
  • JPA 2.0(JSR 317) 2009년: 대부분의 ORM 기능을 포함, JPA Criteria 추가
  • JPA 2.1(JSR 338) 2013년: 스토어드 프로시저 접근, 컨버터(Converter), 엔티티 그래프 기능이 추가
  • 이미 20년가까이 성숙되어온 기술이기에 왠만한 기능은 다 구현되어 있다.

왜 JPA를 사용해야 하는가?

  • SQL 중심적인 개발에서 객체 중심으로 개발
  • 생산성: 개발자는 개발자가 할 일만.(나머지는 JPA가)
  • 유지보수 (편리)
  • 패러다임의 불일치 해결
  • 성능
  • 데이터 접근 추싱화와 벤더 독립성
  • 표준

생산성: JPA와 CRUD

저장: jpa.persist(member)
조회: Member member = jpa.find(memberId)
수정: member.setName("변경할 이름")
삭제: jpa.remove(member)

유지보수

필드만 추가하면 JPA가 처리.

JPA와 패러다임의 불일치 해결

1. JPA와 상속

저장

  • 개발자가 할 일 jpa.persist(album);
  • 나머진 JPA가 처리
INSERT INTO TEAM...
INSERT INTO ALBUM...

조회

  • 개발자가 할 일: Album album = jpa.find(Album.class, albumId);
  • 나머진 JPA가 처리
SELECT I.*, A.*
	FROM ITEM I
    JOIN ALBUM A ON I.ITEM_ID = A.ITEM_ID

2. JPA와 연관관계

  • 연관관계 저장
member.setTeam(team);
jpa.persist(member);

member랑 team을 입력하면, jpa가 알아서 연관관계 고래해 인서트 쿼리를 만들어준다. (member 테이블에 알아서 team의 외래키 값이 들어가있다.)

3. JPA와 객체 그래프 탐색

Member memeber = jpa.find(Member.class, memberId);
Team team = member.getTeam();

Member만 조회해도 Team의 값이 같이 나온다.

class MemberService {
	...
    public void process() {
    	Member member = memberDAO.find(memberId);
        member.getTeam(); // 자유로운 객체 그래프 탐색
        member.getOrder().getDelivery();
    }
}

자유롭게 객체를 검색할 수 있고, 해당 엔티티 역시 (SQL문을 확인하지 않고도) 신뢰할 수 있다. 계층 분리의 문제도 해결된다!

4. JPA와 비교하기

String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);

member1 == member2; // 같다.
  • 동일한 트랜잭션에서 조회한 엔티티는 같음을 보장.

JPA의 기본 컨셉이 Java Collection. 조회한 PK가 같은 경우, JPA는 같은 객체 인스턴스를 반환한다. (단, Java Collection에서는 모든 경우에 두 인스턴스가 동일하지만, JPA는 동일한 트랜젝션에서만 조회한 엔티티의 동일성을 보장한다.)

JPA의 성능 최적화 기능

JPA는 애플리케이션과 DB사이에 하나의 계층이 존재하는 것. 항상 둘 사이에 계층이 존재하면 2가지 기능을 할 수 있다: 캐시, 버퍼링 라이트. 이를 통해 성능 최적화가 가능한데, JPA 역시 이를 통해 성능 최적화가 가능하다.

1. 1차 캐시와 동일성(identity) 보장

  1. 같은 트랜잭션 안에서는 같은 엔티티를 반환 - 약간의 조회 성능 향상
  2. DB Isolation Level이 Read Commit이어도 애플리케이션에서 Repeatable Read 보장 (약간 어려워서 생략)
String member Id = "100";
Member m1 = jpa.find(Member.class, memberId); //SQL
Member m2 = jpa.find(Member.class, memberId); //캐시

println(m1 == m2) //true

이런 경우, SQL이 1번만 실행한다.
첫 번째 요청에서는 SQL이 날아가고, 두 번째 요청에서 같은 키를 찾는 경우 캐시가 적용된다.

2. 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)

Insert

  1. 트래잭션을 커밋할 때까지 INSERT SQL을 모음
  2. JDBC BATCH SQL 기능을 사용해서 한번에 SQL 전송
transaction.begin(); // [트랜잭션] 시작

em.persist(memberA);
em.persist(memberB);
em.persist(memberC);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.

//커밋하는 순간 데이터베이스에 INSERT SQL을 모아서 보낸다.
transaction.commit(); // [트랜잭션] 커밋

버퍼링 라이트 버퍼로 SQL을 모아 한번에 전송한다. 트랜잭션을 커밋할 때까지 JPA는 기본적으로 INSERT SQL을 모아둔다. 이는 JDBC 배치 SQL이란 기능을 활용하는 것인데, JPA에 A,B,C 멤버를 모아두었다가 커밋 발생 시 네트워크 통신 한 번으로 모아둔 데이터를 전송한다. 이를 통해 성능 최적화를 이룰 수 있다. 옵션을 켜서 적용 가능하다.

언급했듯 과거 JDBC 배치 SQL기능을 활용하는 것인데, 이 로직이 매우 복잡하다. 이를 중간에서 다 자동으로 해주는 것.

Update

  1. UPDATE, DELETE로 인한 로우(ROW)락 시간 최소화
  2. 트랜잭션 커밋 시 UPDATE, DELETE SQL 실행하고, 바로 커밋
transaction.begin(); // [트랜잭션] 시작

changeMember(memberA);
deleteMenber(memberB);
비즈니스_로직_수행(); // 비즈니스 로직 수행 동안 DB 로우 락이 걸리지 않는다.

// 커밋하는 순간 데이터베이스에 UPDATE, DELETE SQL을 보낸다.
transaction.commit(); // [트랜잭션] 커밋

3. 지연 로딩 (Lazy Loading)

  • 지연 로딩: 객체가 실제 사용될 때 로딩
  • 즉시 로딩: JOIN SQL로 한번에 연관된 객체까지 미리 조회

지연 로딩

먼저 MEMBER정보를 가져오는데, 이 당시에는 Team 객체에 관한 정보가 없다.
실제로 Team 정보가 호출될 때, Team을 조회하는 SQL문을 사용해 Team정보를 가져온다. (Team 정보를 사용하지 않는 경우, 불필요하게 Team 정보를 가져오지 않을 수 있다.) 단, 쿼리를 두번 실행해야 한다.

즉시 로딩


코드 상에서 Team 정보 또한 요구된다는 것을 알 수 있다. 이런 경우 즉시 로딩 설정이 걸려있으면, Member정보를 가져오는 경우 항상 Team정보를 불러온다. (원래는 이런 작업을 모두 개발자가 수행해야 한다.)

ORM은 객체지향, 관계형 DB를 모두 잘 알아야 제대로 사용할 수 있다. 따라서 관계형 DB, SQL에 대해서도 깊이있게 학습해야 한다.


JPA 설정

spring-boot-starter-data-jpa 라이브러리를 사용해 간단히 설정 가능하다.

build.gradle에 의존 관계 추가

//JPA, 스프링 데이터 JPA 추가
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

build.gradle에 의존 관계 제거

//JdbcTemplate 추가
//implementation 'org.springframework.boot:spring-boot-starter-jdbc'

추가하는 의존에서 제거되는 외존도 포함하고 있어서 제거해도 괜찮다.

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    
    //JdbcTemplate 추가
    //implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    
    //MyBatis 추가
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
    
    //JPA, 스프링 데이터 JPA 추가
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    //H2 데이터베이스 추가
    runtimeOnly 'com.h2database:h2'
    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'
}

다음과 같은 라이브러리가 추가된다.

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

(main) application.properties에 추가

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

(test) application.properties에 추가

#JPA log
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
  • org.hibernate.SQL=DEBUG : 하이버네이트가 생성하고 실행하는 SQL 확인 가능
  • org.hibernate.type.descriptor.sql.BasicBinder=TRACE: SQL에 바인딩 되는 파라미터 확인 가능
  • spring.jpa.show-sql=true: 이 설정은 콘솔 System.out으로 SQL이 출력된다. 따라서 사용 권장하지 않음.

스프링 부트 3.0

스프링부트 3.0 이상 사용시 하이버네이트 6 버전이 사용되는데, 로그 설정 방식이 다르다.

#JPA log
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

JPA 적용1 - 개발

Item 객체 - 테이블 매핑

Item: ORM 매핑

package hello.itemservice.domain;

import lombok.Data;

import javax.persistence.*;

@Data
@Entity
//@Table(name = "item")
// 테이블명. 객체와 테이블명이 같은 경우 생략 가능.
public class Item {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    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가 인식할 수 있다. 해당 애노테이션이 붙은 객체를 JPA에서는 엔티티라 한다.
  • @Id : 테이블의 PK와 해당 필드를 매핑한다.
  • @GeneratedValue(strategy = GenerationType.IDENTITY) : PK 생성 값을 데이터베이스에서 생성하는 IDENTITY 방식을 사용한다. 예) MySQL auto increment
  • @Column : 객체의 필드를 테이블의 컬럼과 매핑한다.
    • name = "item_name": 객체는 itemName이지만 테이블의 컬럼은 item_name이므로 이렇게 매핑했다.
    • length = 10: JPA의 매핑 정보로 DDL(create taable)도 생성할 수 있는데, 그때 컬럼의 길이 값으로 활용된다.(varchar 10)
    • @Column을 생략할 경우 필드의 이름을 테이블 컬럼 이름으로 사용한다. 참고로 지금처럼 스프링 부트와 통합해서 사용하면 필드 이름을 테이블 컬럼 명으로 변경할 때 객체 필드의 카멜 케이스를 테이블 컬럼의 언더스코어로 자동으로 변환해준다.
      • itemName -> item_name, 따라서 위 예제의 @Column(name = "item_name")를 생략해도 된다.

기본 생성자

JPA는 public 또는 protected의 기본 생성자가 필수이다. 기본 생성자를 꼭 넣어주자. 이 생성자가 들어가야만 프록시 기술을 사용하기 편하다.

public item() {}

ItemRepository

JpaItemRepsotiroyV1

package hello.itemservice.repository.jpa;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import javax.transaction.Transactional;
import java.util.List;
import java.util.Optional;

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

	private final EntityManager em;
    
    pulic JpaItemRepository(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.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }
    
    @Override
    public Optional<Item> findById(Long id) {
    	Item item = em.find(Item.class, id);
        return Optoinal.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);
        }

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

        return result;
	}
}
  • private final EntityManager em;
    • 생성자를 보면 스프링을 통해 엔티티 매니저(EntityManager)라는 것을 주입받은 것을 확인할 수 있다. JPA의 모든 동작은 엔티티 매니저를 통해서 이루어진다. 엔티티 매니저는 내부에 데이터소스를 가지고 있고, 데이터베이스에 접근할 수 있다.
      • (실제 JDBC에서 개발자가 번거롭게 수행하던 데이터 주입 작업을 JPA가 대신 수행해준다.)
  • @Transactional: JPA의 모든 데이터 변경(등록, 수정, 삭제)은 트랜잭션 안에서 이루어져야 한다. 조회는 트랜잭션이 없어도 가능하다. 변경의 경우 일반적으로 서비스 계층에서 트랜잭션을 시작하기 때문에 문제가 없다. 하지만 이번 예제에서는 복잡한 비즈니스 로직이 없어서 서비스 계층에서 트랜잭션을 걸지 않았다. JPA에서는 데이터 변경 시 트랜잭션이 필수다. 따라서 리포지토리에 트랜잭션을 걸어주었다. 다시한번 강조하지만 일반적으로는 비지니스 로직을 시작하는 서비스 계층에 트랜잭션을 걸어주는 것이 맞다.

참고

JPA를 설정하려면 EntityManagerFactory, JPA 트랜잭션 매니저(JpaTransactionManager), 데이터 소스 등등 다양한 설정을 해야한다. 스프링 부트는 이 과정을 모두 자동화해준다. main() 메서드 부터 시작해서 JPA를 처음부터 어떻게 설정하는지는 JPA 기본편을 참고하자. 그리고 스프링 부트의 자동 설정은 JpaBaseConfiguration을 참고하자.

JpaConfig

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jpa.JpaItemRepository;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;

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

ItemServiceApplication - 변경

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

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

save(): 저장

public Item save(Item item) {
	em.persist(item);
    return item;
}
  • em.persist(item): JPA에서 객체를 테이블에 저장할 때에는 엔티티 매니저가 제공하는 persist() 메서드를 사용하면 된다.
    • 매핑 정보를 기반으로 INSERT query를 만들어 저장하며, 자동 생성된 Id 값 까지도 조회해서 객체에 넣어준다.

JPA가 만들어서 실행한 SQL

insert into item (id, item_name, price, quantity) values (null, ?, ?, ?)
또는
insert into item (id, item_name, price, quantity) values (default, ?, ?, ?)
또는
insert into item (item_name, price, quantity) values (?, ?, ?)
  • JPA가 만들어서 실행한 SQL을 보면 id에 값이 빠져있는 것을 확인할 수 있다. PK 키 생성 전략을 IDENTITY로 사용했기 때문에 JPA가 이런 쿼리를 만들어서 실행한 것이다. 물론 쿼리 실행 이후에 Item 객체의 id 필드에 데이터베이스가 생성한 PK값이 들어가게 된다.(JPA가 INSERT SQL 실행 이후에 생성된 ID 결과를 받아서 넣어준다.)

PK 매핑 참고

@Entity
public class Item {

	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long 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가 만들어서 실행한 SQL

update item set item_name=?, price=?, quantity=?, where id=?

🤔 메서드에서 commit과 같은 전송 단계를 호출하지 않고 어떻게 UPDATE SQL이 실행되는 걸까?

  • em.update()같은 메서드를 전혀 호출하지 않았다. 그런데 어떻게 UPDATE SQL이 실행되는 것일까?
  • JPA는 트랜잭션이 커밋되는 시점에, 변경된 엔티티 객체가 있는지 확인한다. 특정 엔티티 객체가 변경된 경우에는 UPDATE SQL을 실행한다.
  • JPA가 어떻게 변경된 엔티티 객체를 찾는지 명확하게 이해하려면 영속성 컨텍스트라는 JPA 내부 원리를 이해해야 한다. 이 부분은 JPA 기본편에서 자세히 다룬다. 지금은 트랜잭션 커밋 시점에 JPA가 변경된 엔티티 객체를 찾아서 UPDATE SQL을 수행한다고 이해하면 된다.
  • 테스트의 경우 마지막 트랜잭션이 롤백되기 때문에 JPA는 UPDATE SQL을 실행하지 않는다. 테스트에서 UPDATE SQL을 확인하려면 @Commit을 붙이면 확인할 수 있다.

findById(): 단건 조회

    public Optional<Item> findById(Long id) {
        Item item = em.find(Item.class, id);
        return Optional.ofNullable(item);
    }
  • JPA에서 엔티티 객체를 PK를 기준으로 조회할 때는 find()를 사용하고 조회 타입과, PK 값을 주면 된다. 그러면 JPA가 다음과 같은 조회 SQL을 만들어서 실행하고, 결과를 객체로 바로 반환해준다.

JPA가 만들어서 실행한 SQL

select
 item0_.id as id1_0_0_,
 item0_.item_name as item_nam2_0_0_,
 item0_.price as price3_0_0_,
 item0_.quantity as quantity4_0_0_
from item item0_
where item0_.id=?
  • JPA(하이버네이트)가 만들어서 실행한 SQL은 별칭이 조금 복잡하다. 조인이 발생하거나 복잡한 조건에서도 문제 없도록 기계적으로 만들다 보니 이런 결과가 나온 듯 하다.

JPA에서 단순히 PK를 기준으로 조회하는 것이 아닌, 여러 데이터를 복잡한 조건으로 데이터를 조회하려면 어떻게 하면 될까?

findAll: 목록조회

식별자를 기반으로 하나의 데이터를 조회하는 것이 아니라, 여러 조건으로 복잡하게 쿼리를 써야 하는 경우를 위해 JPA는 SQL과 거의 유사한 JPQL이라는 것을 제공하고 있다. (객체 쿼리 언어)

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

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

        return result;
	}
  • 주어진 ItemSearchCond 객체를 기반으로 JPQL을 동적으로 생성해 Item을 찾는다.
    1. ItemSearchcond는 maxPrice, itemName 필드를 가진다.
    2. maxPrice와 itemName이 비어있지 않으면 동적으로 JPQL 쿼리를 조합해 검색한다.

JPQL

  • JPQL(Java Persistence Query Language): JPA가 제공하는 객체지향 쿼리 언어
    • 주로 여러 데이터를 복잡한 조건으로 조회할 때 사용한다.
    • SQL이 테이블을 대상으로 한다면, JPQL은 엔티티 객체를 대상으로 SQL을 실행한다.
    • 엔티티 객체를 대상으로 하기 때문에 from 다음에 Item 엔티티 객체 이름이 들어간다. 엔티티 객체와 속성의 대소문자는 구분해야 한다.
      • "select i from Item i"
    • SQL과 문법이 비슷하여 쉽게 적응 가능하다.

결과적으로 JPQL을 실행하면 그 안에 포함된 엔티티 객체의 매핑 정보를 활용하여 SQL을 만들게 된다.

실행된 JPQL

select i from Item i
where i.itemName like concat('%',:itemName,'%')
 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<=?

파라미터

  • JPQL에서 파라미터는 (:)를 통해 입력한다.
    • where price <= :maxPrice
  • 파라미터 바인딩은 다음과 같이 사용한다.
    • query.setParameter("maxPrice", maxPrice)

동적 쿼리 문제
JPA를 사용해도 동적 쿼리 문제가 남아있다. 동적 쿼리는 뒤에서 설명하는 Querydsl이라는 기술을 활용하면 매우 깔끔하게 사용할 수 있다. 실무에서는 동적 쿼리 문제 때문에, JPA 사용할 때 Querydsl도 함께 선택하게 된다.


JPA 적용3 - 예외 변환

@Repository
@Transactional
public class JpaItemRepository implements ItemRepository {

    private final EntityManager em;

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

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

}
  • EntityManager순수한 JPA 기술이고, 스프링과는 관계가 없다. 따라서 엔티티 매니저는 예외가 발생하면 JPA 관련 예외를 발생시킨다.
  • JPA는 PersistenceException과 그 하위 예외를 발생시킨다.
    • 추가로 JPA는 IllegalStateException, IllegalArgumentException을 발생시킬 수 있다.
  • 그렇다면 JPA 예외를 스프링 예외 추상화(DataAccessException)로 어떻게 변환할 수 있을까?
  • 비밀은 바로 @Repository에 있다.

실제로 @Repository, @Transactional 애노테이션에 주석을 달면 예외에 AOP가 적용되는 것을 확인할 수 있다.

@Repository의 기능

  • @Repository가 붙은 클래스는 컴포넌트 스캔의 대상이 된다.
  • @Repository가 붙은 클래스는 예외 변환 AOP의 적용 대상이 된다.
  • 스프링과 JPA를 함께 사용하는 경우 스프링은 JPA 예외 변환기 (PersistenceExceptionTranslator)를 등록한다.
  • 예외변환 AOP 프록시는 JPA 관련 예외가 발생하면 JPA 예외 변환기를 통해 발생한 예외를 스프링 데이터 접근 예외로 변환한다.

결과적으로 리포지토리에 @Repository 애노테이션만 있으면 스프링이 예외 변환을 처리하는 AOP를 만들어준다.

참고

스프링부트는 PersistenceExceptionTranslationPostProcessor를 자동으로 등록하는데, 여기에서 @Repsoitory를 AOP 프록시로 만드는 어드바이저가 등록된다.

참고

복잡한 과정을 거쳐서 실제 예외를 변환하는데, 실제 JPA 예외를 변환하는 코드는 EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible()이다.


References

profile
Good Luck!

0개의 댓글