스프링과 JPA는 자바 기업 시장의 주력 기술이다.스프링이 DI 컨테이너를 포함한 애플리케이션 전반의 다양한 기능을 제공한다면, JPA는 ORM 데이터 접근 기술을 제공한다. 대표적으로 JdbcTemplate이나 MyBatis 같은 SQL 매퍼 기술은 SQL을 개발자가 직접 작성해야 하지만, JPA를 사용하면 SQL을 JPA가 대신 작성하고 처리해준다. 실무에서 JPA를 더욱 편리하게 사용하기 위해 스프링 데이터 JPA와 Querydsl이라는 기술을 함께 사용한다.
자바 웹 어플리케이션에서 관계형 데이터 베이스에 데이터를 저장하기 위해 객체를 SQL로 변환하여 저장해야 한다.이 과정에서 SQL에 의존적인 개발을 피하기 어려운데 객체 지향 프로그래밍의 상속, 다형성, 연관 관계(1:N, N:N 등)를 데이터베이스의 테이블 구조로 표현하는 데 어려움이 있다. 예를 들어 객체는 참조를 통해 다른 객체를 가리키지만, 데이터베이스에서는 외래 키를 통해 관계를 표현해야 한다. 이러한 문제를 해결하기 위해 JPA(Java Persistence API)가 등장했다. JPA는 객체와 관계형 데이터베이스 간의 매핑을 지원하고, 개발자가 객체 중심의 설계를 유지하면서도 관계형 데이터베이스를 쉽게 사용할 수 있도록 돕는다.
JPA는 Java Persistence API의 약어로 자바 진영의 ORM 표준이다. ORM이란 Object(객체)-Relational(관계형 DB)-Mapping으로 객체랑 관계형 db를 매핑하는 기술로 애플리케이션과 JDBC 사이에서 동작한다.
위 그림은 객체를 저장하는 예시로 MemberDAO에서 persist을 통해 JPA에 데이터를 넘겨주면 JPA가 해당 객체를 분석한뒤, JDBC API를 통해 DB에 저장한다. 또한 반환된 결과를 새로운 객체에 매핑해준다. 결과적으로 JAVA Collections에 저장하듯 DB에 편리하게 저장할수 있다.
DAO(Data Access Object): 데이터베이스와 상호작용하는 클래스,계층
다음은 JPA를 사용해야 하는 이유이다.
1.생산성
2.유지보수
객체 필드 변경시 SQL에 자동으로 해당 필드가 추가 된다.
3.상속
Item 클래스를 상속받은 album 클래스를 DB에 저장할때 SQL 쿼리에서 부모 클래스인 Item도 함께 insert,join해야 하지만 JPA에서 이를 자동으로 처리해준다. 또한 연관관계가 있는 객체간 저장도 외래키값을 고려하여 JPA가 저장한다. (ex.member.setTeam(team); jpa.persist(member))
4.신뢰할수 있는 엔티티,계층
자유로운 객체 그래프 탐색으로 필요한 데이터를 가져올 수 있다. 다음과 같이 객체간의 관계를 정의하면 참조를 통해 직관적으로 탐색할수 있다.
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> items;
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this); // 양방향 연관 관계 관리
}
}
5.성능 최적화
JPA가 객체와 DB간 계층 사이에 존재한다는것은 캐시와 버퍼링 2가지가 무조건 포함된다는 것이다.
1. 1차 캐시와 동일성 보장
String memberId = "100";
Member m1 = jpa.find(Member.class, memberId); //SQL
Member m2 = jpa.find(Member.class, memberId); //캐시
println(m1 == m2) //true
같은 트랜잭션 안에서 같은 엔티티를 반환하여 조회 성능을 향상시킨다.
2.트랜잭션을 지원하는 쓰기 지연: 트랜잭션을 커밋할 때까지 INSERT SQL을 지연한다. 다음과 같이 지연 로딩,즉시 로딩도 지원한다.
다음은 JPA를 개발하기 위해 미리 설정을 한다.
build.gradle에 다음을 추가한다.
//JPA,스프링 데이터 JPA 추가
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
이후 java,test의 application.properties에 로그 설정을 추가한다.
#JPA log
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
hibernate.SQL=DEBUG는 하이버네이트가 생성하고 실행하는 SQL을 확인할수 있다. hibernate.type.descriptor.sql.BasicBinder는 SQL에 바인딩되는 파라미터를 확인할수 있다.
JPA에서 가장 중요한 부분은 객체와 테이블을 매핑하는 것이다. JPA가 제공하는 애노테이션을 사용해서 와 테이블을 매핑해보자.
JPA에서 데이터를 변경할때 항상 @Transactional이 있어야 하고 EntityManager 의존관계를 주입 받아야한다. 해당 인스턴스에 CRUD를 실행할수 있다.
@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;
}
}
JPA는 @Entity 애노테이션을 통해 객체를 인식하며 기본 생성자가 필수이다. @Id는 PK값,@GeneratedValue는 PK 생성 전략이다. @Column은 필드와 테이블의 컬럼과 매핑하는데 camel_case를 자동으로 적용해주며 생략 가능하다.
@Slf4j
@Repository
@Transactional
public class JpaItemRepository implements ItemRepository {
private final EntityManager em;
}
JPA에서 데이터 변경시 @Transactional이 필수이며 원래 비지니스 로직이 존재하는 service에서 필요하나, 여기선 저장소 계층에 사용하였다.
private final EntityManager em: 생성자를 보면 스프링을 통해 엔티티 매니저(EntityManager)라는 것을 주입받은 것을 확인할 수 있다. JPA의 모든 동작은 엔티티 매니저를 통해서 이루어진다. 엔티티 매니저
는 내부에 데이터소스를 가지고 있고, 데이터베이스에 접근할 수 있다.
1.save
@Override
public Item save(Item item) {
em.persist(item);
return item;
}
persist을 하게 되면 domain의 Item클래스의 매핑정보를 가지고 DB에 insert 쿼리를 실행한다. 또한 id값을 조회해서 객체에 매핑한다.
2.update
@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());
}
초기 객체의 스냅샷을 저장하고, 트랜잭션의 커밋 시점에 변경된 객체가 존재하면 UPDATE SQL을 생성하여 DB에 반영한다. 참고로 TEST에서 @Transaction의 특성상 커밋이 되지 않고 Rollback이 되는데 이럴때 @Commit을 통해 확인할수 있다.
3.findAll(동적 쿼리)
@Override
public List<Item> findAll(ItemSearchCond cond) {
String jpql = "select i from Item i";
Integer maxPrice = cond.getMaxPrice();
String itemName = cond.getItemName();
//이하 생략..
}
JPQL
JPA는 JPQL(Java Persistence Query Language)이라는 객체지향 쿼리 언어를 제공한다. 주로 여러 데이터를 복잡한 조건으로 조회할 때 사용한다. SQL이 테이블을 대상으로 한다면, JPQL은 엔티티 객체를 대상으로 SQL을 실행한다 생각하면 된다. 엔티티 객체를 대상으로 하기 때문에 From 다음에 Item 엔티티 객체가 들어간다. 엔티티 객체와 속성의 대소문자는 구분해야 한다.
동적 쿼리는 여전히 복잡한데, Querydsl을 통해 해결 가능하다.
EntityManager는 순수한 JPA 기술이고, 스프링과는 관계가 없다. 따라서 엔티티 매니저는 예외가 발생하면 JPA 관련 예외를 발생시킨다. JPA는 PersistenceException과 그 하위 예외를 발생시키는데 프레임워크간 통합성을 높히기 위해 스프링 예외로 변환해줘야 한다.
@Repository 애노테이션은 JPA 예외를 동적 프록시 객체 생성으로 스프링 예외인 DataAccessException으로 변환한다.
스프링 데이터 JPA는 JPA를 편리하게 사용할 수 있도록 도와주는 라이브러리이다. JPA의 상위 추상화를 제공하여 코드 작성을 간소화하고, Spring과의 통합을 통해 데이터 접근 계층을 쉽게 개발하도록 지원한다.
특징
주요 기능
1.레포지토리 추상화
Spring Data는 데이터 접근 계층의 추상화를 제공하는 큰 틀의 프레임워크이다. 다양한 DB별 구체화된 인터페이스가 존재하며 개발자가 데이터베이스 작업을 효율적으로 처리할 수 있도록 공통적인 추상화와 DB별 특화 기능을 함께 제공한다.
스프링 데이터 JPA는 이 추상화를 기반으로 JPA에 특화된 기능을 제공하는 하위 구현체이다.
JpaRepository 사용법
public interface ItemRepository extends JpaRepository<Item, Long> {
}
JpaRepository를 상속받고 제네릭에 관리할 <엔티티,엔티티ID>를 주면 된다. 그러면 스프링 데이터 JPA가 프록시 기술을 사용하여 구현 클래스를 만들어 빈으로 등록한다. 따라서 개발자는 구현 클래스없이 인터페이스 상속만으로 CRUD 기능을 사용할수 있다.
2.쿼리 메서드 및 커스텀 쿼리 지원: 메서드 이름만으로 쿼리를 작성할수 있다.(ex.findByName(String name) → 자동으로 SELECT * FROM ... WHERE name = ? 쿼리 생성),@Query를 사용하여 JPQL 또는 네이티브 SQL 작성 가능하다.
4.페이징 및 정렬 지원
5.트랜잭션 통합: 스프링의 @Transactional과 완벽히 통합되어 트랜잭션 관리 가능하다.
SpringDataJpaItemRepository
public interface SpringDataJpaItemRepository extends JpaRepository<Item,Long> {
List<Item> findByItemName(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);
}
이름으로 검색하거나,가격으로 검색하는 기능은 제공되지 않아 쿼리 메서드로 구현했다. 데이터를 조건에 따라 4가지로 분류해서 검색한다.
(1.모든 데이터 조회(기본 내장),2.이름 조회,3.가격 조회,4.이름+가격 조회)
JpaItemRepositoryV2
@Repository
@Transactional
@RequiredArgsConstructor
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);
}

String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
if(StringUtils.hasText(itemName) && maxPrice!=null){
//return repository.findByItemNameLikeAndPriceLessThanEqual("%"+itemName+"%",maxPrice);
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();
}
}
}
ItemService의 필드에서 SpringDataJpaItemRepository를 주입받아 사용가능하지만 구체 클래스에 의존하게 된다. 따라서 중간 다리 역할을 하는 저장소 클래스를 구현한다.
흐름도
기존의 SQL 쿼리 작성 방식은 문자열을 기반으로 작성되기 때문에, 컴파일 타임에는 쿼리의 오류를 확인할 수 없고, 런타임에만 에러가 발생한다. 이는 개발자가 작성한 쿼리가 실행되기 전까지 오류를 발견하기 어려워, 고객 사용 중 문제가 발생할 가능성을 높이며, 오류 발견과 수정 과정의 라이프사이클이 길어지는 단점이 있다.
QueryDSL은 이러한 문제를 해결하기 위해 타입 세이프(Type Safe)를 제공한다. 컴파일 타임에 쿼리 값과 테이블, 컬럼에 대한 타입을 검증하며, 문자열 기반의 방식이 아닌, Java 객체와 메서드 체인을 통해 쿼리를 생성함으로써 잘못된 쿼리 작성으로 인한 실수를 방지하고, 코드의 가독성과 생산성을 높여준다.
QueryDSL은 @Entity가 붙은 Java 클래스들을 기반으로, 애노테이션 프로세서 도구를 통해 Q로 시작하는 동적 프록시 객체를 생성한다. 이러한 Q 객체를 사용하면 아래와 같이 간단하게 쿼리를 작성할 수 있다.
QueryDSL의 원리는 JPQL(Java Persistence Query Language)을 생성하는 빌더 역할을 하고 최종적으로 SQL 쿼리문을 생성한다. 타입 세이프(Type Safe)하며, 코드가 단순하고 직관적이기 때문에 쉽고 생산적으로 쿼리를 작성할 수 있는 장점을 제공한다.
빌드 도구를 Gradle로 사용한다면,Task->build->clean 실행,
other->compileJava를 실행하면 build 파일이 생성되며 도메인 폴더에 Qitem이 생성된것을 확인할수 있다.
intellJ를 빌드 도구로 사용한다면 빌드나 application을 한번 실행하면 된다.
Repository
@Repository
@Transactional
public class JpaItemRepositoryV3 implements ItemRepository {
private final EntityManager em;
private final JPAQueryFactory query;
public JpaItemRepositoryV3(EntityManager em) {
this.em = em;
this.query = new JPAQueryFactory(em);
}
}
Query dsl을 사용하려면 JPQL을 만들기 때문에 JPAQueryFactory가 필요하다. 또한 파라미터로 EntityManager를 주입 해줘야한다.
다음과 같이 깔끔한 동적 쿼리가 가능하다.
public List<Item> findAllOld(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
QItem item = new QItem("i");
BooleanBuilder builder=new BooleanBuilder();
if(StringUtils.hasText(itemName)){
builder.and(item.itemName.like("%"+itemName+"%"));
}
if(maxPrice!=null){
builder.and(item.price.loe(maxPrice));
}
List<Item> result=query.select(item)
.from(item)
.where(builder)
.fetch();
return result;
}
또한 Query dsl 라이브러리의 BooleanExpression 타입을 통해 where절에 대한 함수 생성이 가능하다.
@Override
public List<Item> findAll(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
//QItem item = new QItem("i");
List<Item> result = query
.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;
}
where절 안에 조건들은 구분자,로 AND 처리가 되며 null이 반환되면 해당 조건은 무시된다.