이 글은 배민 기술이사 김영한 이사님의 인프런 강의 "스프링 DB 2편 - 데이터 접근 활용 기술" 을 기반으로 작성되었습니다. 문제 시 삭제 조치하겠습니다.
아래와 같은 순서로 공부를 진행합니다.
- JPA 설정
- JPA 적용 1 - 개발
- JPA 적용 2 - 리포지토리 분석
- JPA 적용 3 - 예외 변환
4. JPA 설정
이전 글에 이어서 JPA 을 프로젝트에 실제로 적용해봅시다. 먼저 설정부터 해야합니다.
spring-boot-starter-data-jpa
라이브러리를 사용하면 JPA와 스프링 데이터 JPA를 스프링 부트와 통합하고, 설정도 아주 간단히 할 수 있습니다. (스프링 데이터 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'
spring-boot-starter-data-jpa
는 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 라이브러리
application.properties
에 다음 설정을 추가합니다.
main, 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에 바인딩 되는 파라미터를 확인할 수 있습니다.
5. JPA 적용 1 - 개발
JPA에서 가장 중요한 부분은 객체와 테이블을 매핑하는 것입니다.
JPA가 제공하는 애노테이션을 사용해서 Item
객체와 테이블을 매핑해줍니다.
Item
- ORM 매핑
import lombok.Data;
import javax.persistence.*;
@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가 인식할 수 있습니다. 이렇게 @Entity
가 붙은 객체를 JPA에서는 엔티티라고 합니다.
@Id
: 테이블의 PK와 해당 필드를 매핑합니다.
@GeneratedValue(strategy = GenerationType.IDENTITY)
: PK 생성 값을 데이터베이스에서 생성하는 IDENTITY
방식을 사용합니다.
예) MySQL auto increment
@Column
: 객체의 필드를 테이블의 컬럼과 매핑합니다.
name = "item_name"
: 객체는itemName
이지만 테이블의 컬럼은item_name
이므로 이렇게 매핑됩니다.length = 10
: JPA의 매핑 정보로 DDL(create table
)도 생성할 수 있는데, 그때 컬럼의 길이 값으로 활용합니다. (varchar 10
)
@Column
을 생략할 경우 필드의 이름을 테이블 컬럼 이름으로 사용합니다. 참고로 지금처럼 스프링 부트와 통합해서 사용하면 필드 이름을 테이블 컬럼 명으로 변경할 때 객체 필드의 카멜 케이스를 테이블 컬럼의 언더스코어로 자동으로 변환합니다.
itemName
→item_name
, 따라서 위 예제의@Column(name = "item_name")
를 생략해도 됩니다.
JPA는 public
또는 protected
의 기본 생성자가 필수입니다.
public Item() {}
JpaItemRepositoryV1
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
@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.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
@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;
List<Object> param = new ArrayList<>();
if (StringUtils.hasText(itemName)) {
jpql += " i.itemName like concat('%',:itemName,'%')";
param.add(itemName);
andFlag = true;
}
if (maxPrice != null) {
if (andFlag) {
jpql += " and";
}
jpql += " i.price <= :maxPrice";
param.add(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();
}
}
private final EntityManager em
: 생성자를 보면 스프링을 통해 엔티티 매니저(EntityManager
) 라는 것을 주입받은 것을 확인할 수 있습니다.
JPA의 모든 동작은 엔티티 매니저를 통해서 이루어집니다. 엔티티 매니저는 내부에 데이터소스를 가지고 있고, 데이터베이스에 접근할 수 있습니다.
@Transactional
: JPA의 모든 데이터 변경(등록, 수정, 삭제)은 트랜잭션 안에서 이루어져야 합니다.
조회는 트랜잭션이 없어도 가능합니다.
변경의 경우 일반적으로 서비스 계층에서 트랜잭션을 시작하기 때문에 문제가 없습니다.
하지만 이번 예제에서는 복잡한 비즈니스 로직이 없어서 서비스 계층에서 트랜잭션을 걸지 않았습니다. JPA에서는 데이터 변경 시 트랜잭션이 필수이므로 리포지토리에 트랜잭션을 걸었습니다.
다시 한 번 강조하지만 일반적으로는 비즈니스 로직을 시작하는 서비스 계층에 트랜잭션을 걸어주는 것이 맞습니다.
참고 - JPA를 설정하려면 EntityManagerFactory, JPA 트랜잭션 매니저(JpaTransactionManager), 데이터소스 등등 다양한 설정을 해야 하지만 스프링 부트는 이 과정을 모두 자동화 해줍니다.
스프링 부트의 자동 설정은JpaBaseConfiguration
를 참고합시다.
JpaConfig
@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);
}
}
ItemServiceApplication
- 변경
//@Import(MyBatisConfig.class)
@Import(JpaConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {}
JpaConfig
를 사용하도록 변경였습니다.
테스트 실행
먼저 ItemRepositoryTest
를 통해서 리포지토리가 정상 동작하는지 확인합시다. 테스트가 모두 성공해야 합니다.
애플리케이션 실행
ItemServiceApplication
를 실행해서 애플리케이션이 정상 동작하는지 확인합니다.
정상적으로 동작하네요!
6. JPA 적용 2 - 리포지토리 분석
save()
- 저장
public Item save(Item item) {
em.persist(item);
return item;
}
em.persist(item)
: JPA에서 객체를 테이블에 저장할 때는 엔티티 매니저가 제공하는 persist()
메서드를 사용합니다.
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=?
em.update()
같은 메서드를 전혀 호출하지 않았는데 UPDATE SQL이 실행됩니다.
JPA는 트랜잭션이 커밋되는 시점에, 변경된 엔티티 객체가 있는지 확인을 하고 특정 엔티티 객체가 변경된 경우에는 UPDATE SQL을 실행해줍니다.
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은 별칭이 조금 복잡합니다. 조인이 발생하거나 복잡한 조건에서도 문제 없도록 기계적으로 만들다 보니 이런 결과가 나온 것일겁니다.
findAll
- 목록 조회
public List<Item> findAll(ItemSearchCond cond) {
String jpql = "select i from Item i";
//동적 쿼리 생략
TypedQuery<Item> query = em.createQuery(jpql, Item.class);
return query.getResultList();
}
JPQL
JPA는 JPQL(Java Persistence Query Language)이라는 객체지향 쿼리 언어를 제공합니다. 주로 여러 데이터를 복잡한 조건으로 조회할 때 사용합니다.
SQL이 테이블을 대상으로 한다면, JPQL은 엔티티 객체를 대상으로 SQL을 실행한다 생각하면 됩니다.
엔티티 객체를 대상으로 하기 때문에 from
다음에 Item
엔티티 객체 이름이 들어갑니다. 엔티티 객체와 속성의 대소문자는 구분해야 합니다.
JPQL은 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)
where price <= :maxPrice
query.setParameter("maxPrice", maxPrice)
동적 쿼리 문제
실무에서는 동적 쿼리 문제 때문에, JPA 사용할 때 Querydsl 도 함께 선택합니다.
7. JPA 적용 3 - 예외 변환
JPA의 경우 예외가 발생하면 JPA 예외가 발생합니다.
@Repository
@Transactional
public class JpaItemRepositoryV1 implements ItemRepository {
private final EntityManager em;
@Override
public Item save(Item item) {
em.persist(item);
return item;
}
}
EntityManager
는 순수한 JPA 기술이고, 스프링과는 관계가 없습니다. 따라서 엔티티 매니저는 예외가 발생하면 JPA 관련 예외를 발생시킵니다.
JPA는 PersistenceException
과 그 하위 예외를 발생시킵니다.
- 추가로 JPA는
IllegalStateException
,IllegalArgumentException
을 발생시킬 수 있습니다.
예외 변환 전
@Repository의 기능
@Repository
가 붙은 클래스는 컴포넌트 스캔의 대상입니다. 그리고 예외 변환 AOP 의 적용 대상이기도 합니다.
스프링과 JPA를 함께 사용하는 경우 스프링은 JPA 예외 변환기 (PersistenceExceptionTranslator
)를 등록합니다.
예외 변환 AOP 프록시는 JPA 관련 예외가 발생하면 JPA 예외 변환기를 통해 발생한 예외를 스프링 데이터 접근 예외로 변환합니다.
예외 변환 후
결과적으로 리포지토리에 @Repository
애노테이션만 있으면 스프링이 예외 변환을 처리하는 AOP를 생성합니다.
참고 - 스프링 부트는 PersistenceExceptionTranslationPostProcessor를 자동으로 등록하는데, 여기에서 @Repository를 AOP 프록시로 만드는 어드바이저가 등록됩니다.
복잡한 과정을 거쳐서 실제 예외를 변환하는데, 실제 JPA 예외를 변환하는 코드는 아래와 같습니다.
EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible()
'spring > 스프링 DB 2편' 카테고리의 다른 글
5. 스프링 데이터 JPA(2) - 적용하기 (0) | 2023.02.22 |
---|---|
5. 스프링 데이터 JPA(1) 소개와 기능 (0) | 2023.02.22 |
4. 데이터 접근 기술 - JPA (1) JPA 와 JPA 을 사용해야 하는 이유 (2) | 2023.02.21 |
3. 데이터 접근 기술 - MyBatis (2) | 2023.02.21 |
2. 데이터 접근 기술 - 데이터베이스 테스트하는 방법들 (0) | 2023.02.20 |