Spring : JPA와 Spring Data JPA

JPA(Java Persistence API)

ORM(Object-relational mapping) 자바 기술 표준으로, ORM 프레임워크를 쉽게 사용하기 위한 자바 인터페이스의 모음이다.

데이터베이스 연동에 사용되는 기술들은 JDBC를 비롯하여 MyBatis, Hibernate 등 다양하다. 이 중에서 Hibernate와 같은 ORM 프레임워크는 SQL까지 프레임워크에서 제공하여 개발자들의 업무가 상당히 감소하였다.

과거 DB 연동을 위해 SQL문을 직접 작성하여 영속 데이터를 가져오는 경우 문제점으로 데이터베이스의 테이블이 변경되더라도 SQL 쿼리는 String 형식으로 되어있어서 컴파일 에러가 나오지 않는다. 또한 쿼리문을 잘못 작성하더라도 컴파일 시 확인할 수 없어 런타임에서 에러를 발생시켜 개발 시 쿼리문 작성에 많은 시간과 노력이 필요로 했다.

하지만 Hibernate의 등장으로 상기 문제점들이 해소되었고 Hibernate와 같은 ORM 프레임워크를 보다 쉽게 사용할 수 있도록 표준화된 JPA를 사용 시 SQL을 직접 작성하지 않고, Entity 클래스를 통해 자바 객체를 데이터베이스 테이블과 매핑하여, 데이터베이스 설계 중심의 패러다임을 객체 설계 중심으로 역전하여 처리할 수 있게되어 생산성과 유지보수가 좋아진다.

그러나 배우기가 어렵고 잘 이해하지 않으면 데이터 손실이 있을 수 있으며, 복잡한 작업에 대해서는 성능 문제가 있을 수 있다. 
💡 JPA(Java Persistence API)란?
"ORM을 표준화하여, ORM 프레임워크를 쉽게 사용하기 위한 자바 인터페이스의 모음이다."
"데이터베이스 설계 중심의 패러다임을 객체 설계 중심으로 역전하여 처리할 수 있게 한다."

JPA 특징과 주요 기능

1. 애플리케이션과 JDBC 사이에서 동작

기존 MyBatis 같은 프레임워크는 SQL을 개발자가 직접 XML 파일에 등록하여 사용했지만, JPA를 사용하면 JPA 내부에서 JDBC API를 통해 SQL을 호출하여 DB와 연동하게 된다. 즉, 개발자가 직접 JDBC API를 사용하지 않는다.

2. EntityManager

JPA를 사용하여 데이터베이스와 상호작용하려면 EntityMangerFactory로부터 생성된 EntityManager 객체가 필요하다.
생성된 EntityManager는 싱글톤 객체이며, Thread-safe이다. persist, merge, remove, find 등의 메소드를 사용하여 CRUD를 처리하고 Thread-safe이기 때문에 Thread 환경에서는 주의가 필요, 각각의 영속성 컨텍스트를 가지고 있으며, 데이터를 변경하기 위해서는 트랜잭션(transaction)이 이루어져야 한다.

3. 영속성 컨텍스트 (Persistence Context)

JPA는 엔티티를 영속성 컨텍스트에서 관리한다. EntityManager는 영속성 컨텍스트에 접근해서 Entity에 대한 데이터베이스 작업을 제공한다. 외부에서 요청이 들어오면 영속성 컨텍스트의 1차 캐시를 먼저 검사하여 데이터가 있다면 영속성 컨텍스트 내에서 해결하고, 만약 데이터가 없다면 DB로 가서 데이터를 가져온다.
 
만약에 천개의 데이터를 DB에 저장한다면 한 개의 데이터를 집어 넣을 때마다 DB에 갔다와야 하는데 천개의 데이터를 집어 넣는다 하면 DB에 천번을 갔다 와야 하므로 서버에 과부하가 걸릴 것이다.
 
이러한 문제를 해결하기 위해 영속성 컨텍스트 내에 쓰기 지연 SQL 저장소가 있다. 영속성 컨텍스트의 쓰기 지연 저장소에 데이터들이 차곡차곡 쌓이고 쌓아놓은 데이터들을 commit만 하면 자동으로 트랜잭션 처리가 이루어진다.

4. 트랜잭션 관리

JPA는 데이터베이스의 트랜잭션을 관리하여 데이터의 일관성을 보장한다. 주로 @Transactional 어노테이션을 사용하여 트랜잭션 범위를 지정하며, 트랜잭션 내에서 여러 데이터 작업을 묶어 처리한다.

5. JPQL (Java Persistence Query Language)

JPA에서 SQL을 추상화한 JPQL이라는 객체 지향 쿼리 언어를 사용할 수 있다.  JPQL은 SQL과 유사하지만 특정 데이터베이스 테이블에 의존하지 않고 엔티티 객체에 기반하여 작성된다. SQL과 문법이 비슷하며, SELECT, FROM, WHERE, GRUOP BY, HAVING, JOIN을 지원한다. JPQL은 기본 문자열로 작성되기 때문에 컴파일 시 에러를 발생시키지 않아 문제가 발생할 수 있으며 동적 쿼리를 작성할 시 효율적이지 못하다는 단점이 있다.

6. 주요 어노테이션

  • @Entity : 해당 클래스가 Entity임을 명시, Entity로 인식되도록하여 데이터베이스 테이블과 매핑된다.
  • @Table : 엔티티와 매핑될 데이터베이스 테이블을 지정, name 속성을 통해 테이블 이름을 지정할 수 있다. 엔티티 클래스와 테이블의 이름이 동일할 시 생략 가능하다.
  • @Id : PK 필드와 매핑
  • @Column : Entity의 필드와 데이터베이스 컬럼을 매핑, name 속성을 통해 컬럼 이름을 지정할 수 있다. 이름이 동일할 시 생략 가능하다.
💡 JPA 핵심
" 영속성 컨텍스트인 EntityManager를 통해 Entity를 관리한다. "
" Entity가 DB와 매핑되어 사용자가 Entity에 대한 CRUD를 실행을 했을 때 Entity와 관련된 테이블에 대한 적절한 SQL 쿼리문을 생성하고 이를 관리하였다가 필요 시 JDBC API를 통해 DB에 날리게 된다. "

Spring Data JPA

JPA를 더 쉽게 사용할 수 있도록 Spring에서 제공하는 모듈이며, JPA의 인터페이스 이다.

Spring Data JPA를 사용하면 JPA의 반복적인 작업과 SQL 쿼리 작성을 간소화하여 생산성을 높일 수 있다. 

Spring Data JPA의 주요 특징과 기능

1. Repository 인터페이스

  • Spring Data JPA는 Entity의 기본적인 CRUD 기능을 처리하기 위한 Repository 인터페이스를 제공한다.
  • 기존 DAO와 동일한 개념 비즈니스 로직층에서는 Repository를 통해 데이터베이스 연동을 처리한다.
  • Spring Data JPA에서 제공하는 인터페이스
    • Repository
    • CrudRepository : 기본적인 CRUD 작업을 위한 메서드 제공
    • QueryByExampleExecutor : 
    • PagingAndSortingRepository : 페이징과 정렬을 지원하는 메서드 제공
    • JpaRepository : 페이징 및 정렬을 포함한 JPA 관련 추가 기능 제공

2. 네이밍 룰에 의한 쿼리 생성

  • 메서드 이름만으로 쿼리를 자동 생성하는 기능을 제공한다.
  • 이를 통해 반복적인 쿼리 작성을 줄이고 유지보수를 쉽게 할 수 있다.
// 예시 : 쿼리 메소드 작성 (네이밍룰) find엔티티명By필드명 / 엔티티명 생략 가능
// findBy필드명, read.., get.. 등 다양하게 작성 가능
List<SangpumEntity> findBySangContaining(String svalue); // like '%검색%'
List<SangpumEntity> findBySangStartingWith(String svalue); // like '검색%'
List<SangpumEntity> findBySangEndingWith(String svalue); // like '%검색'

3. @Query 어노테이션으로 쿼리 작성

  • 더 복잡한 쿼리가 필요한 경우, JPQL이나 네이티브 SQL 쿼리를 @Query 어노테이션으로 작성할 수 있다.
// 예시 : num 자동 증가 처리를 위한 코드 작성
@Query(value = "select max(m.num) from Mem as m")
int findByMaxNum();

4. 페이징 및 정렬 지원

  • Pageable과 Sort 인터페이스를 통해 페이징과 정렬 기능을 간편하게 구현할 수 있다.

5. 트랜잭션 관리

  • Spring Data JPA는 Spring의 트랜잭션 관리 기능을 통해 안전하고 간편한 트랜잭션 관리를 지원한다. @Transactional 어노테이션을 통해 트랜잭션 범위를 지정할 수 있으며, 기본적으로 Repository 메서드는 트랜잭션 내에서 실행된다.

6. 영속성 컨텍스트 관리

  • JPA의 영속성 컨텍스트를 자동으로 관리하며, save, delete 같은 메서드를 호출하면 필요한 시점에 엔티티를 자동으로 영속성 컨텍스트에 추가하거나 제거한다.

7. Specification을 통한 동적 쿼리 생성

  • 복잡한 동적 쿼리가 필요할 때, JPA Criteria API와 함께 Specification을 사용하여 쿼리를 동적으로 생성할 수 있다.

Spring Data JPA을 활용한 CRUD 예제

1. Dependencies 추가

Spring Boot DevTools / SpringWeb / Spirng Data JPA
MariaDB Driver / Lombok / Thymeleaf

2. application.roperties 설정

#mariadb server connect
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=DB 주소
spring.datasource.username=DB 아이디 
spring.datasource.password=DB 패스워드
# jpa
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
logging.level.org.hibernate.type.descriptor.sql=trace
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect

3. Entity

package pack.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "allstar")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PlayerEntity {
	@Id // PK 매핑, import jakarta.persistence.Id;
	@Column(name = "no")
	private int no;
	
	@Column(name = "backnum")
	private int backnum;
	
	@Column(name = "name")
	private String name;
	
	@Column(name = "teamno")
	private int teamno;
	
	private String position;

	private int pay;
	
	private String draft;
}

4. Bean

package pack.controller;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class PlayerBean {
	private int no,backnum,teamno,pay;
	private String name,position,draft;
}

5. Repository

package pack.model;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface PlayerRepository extends JpaRepository<PlayerEntity, Integer> { // <Entity명, PK type> 
	// JpaRepository 인터페이스에서 제공되는 기본 메소드 외 하기 메소드처럼 다양하게 생성할 수 있다.
	// 쿼리 메소드 작성
	List<PlayerEntity> findByNameContaining(String searchName); // like '%searchName%'
	List<PlayerEntity> findByNameStartingWith(String searchName); // like 'searchName%'
	List<PlayerEntity> findByNameEndingWith(String searchName); // like '%searchName'
	
	// @Qurey 어노테이션으로 쿼리 작성
	@Query(value = "select p from PlayerEntity p where p.name like %:searchName%")
	List<PlayerEntity> playerSearch(@Param("searchName")String searchName); // 이름에 의한 매핑
	
	@Query(value = "select p from PlayerEntity p where p.no=?1")
	PlayerEntity findByNo(int no);
}

6. Controller

package pack.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import pack.model.PlayerDao;
import pack.model.PlayerEntity;

@Controller
public class PlayerController {
	@Autowired
	private PlayerDao playerDao;
	
	@GetMapping("/")
	public String index() {
		return "index";
	}
	
	// 전체 데이터 읽기
	@GetMapping("list")
	public String list(Model model) {
		List<PlayerEntity> list = playerDao.getListAll();
		model.addAttribute("lists", list);
		return "list";
	}
	
	// 부분 데이터 읽기
	@GetMapping("search")
	public String searchList(@RequestParam("searchName")String searchName, Model model) {
		List<PlayerEntity> searchList = playerDao.getListPart(searchName);
		model.addAttribute("lists", searchList);
		return "list";
	}
	
	// 추가 페이지로 이동
	@GetMapping("insert")
	public String insertPage() {
		return "insert";
	}
	
	// 데이터 추가
	@PostMapping("insert")
	public String insertProcess(PlayerBean bean) {
		String msg = playerDao.insertProcess1(bean);
		if(msg.equals("추가 성공"))
			return "redirect:/list";
		else
			return "error";
	}
	
	// 수정 페이지로 이동
	@GetMapping("update")
	public String updatePage(@RequestParam("no")int no, Model model) {
		PlayerEntity playerEntity = playerDao.getRecord(no);
		model.addAttribute("list", playerEntity);
		return "update";
	}
	
	// 수정
	@PostMapping("update")
	public String updateProcess(PlayerBean bean) {
		String msg = playerDao.updateProcess(bean);
		if(msg.equals("수정 성공"))
			return "redirect:/list";
		else
			return "error";
	}
	
	// 삭제
	@GetMapping("delete")
	public String deleteProcess(@RequestParam("no")int no) {
		String msg = playerDao.deleteProcess(no);
		if(msg.equals("삭제 성공"))
			return "redirect:/list";
		else
			return "error";
	}
}

7. Create

// 추가 (빌더 사용)
public String insertProcess1(PlayerBean bean) {
	try {
		PlayerEntity playerEntity = PlayerEntity.builder()
				.no(bean.getNo())
				.backnum(bean.getBacknum())
				.name(bean.getName())
				.teamno(bean.getTeamno())
				.position(bean.getPosition())
				.pay(bean.getPay())
				.draft(bean.getDraft())
				.build();
		repository.save(playerEntity);
		return "추가 성공";
	} catch (Exception e) {
		System.out.println("insertProcess err : " + e);
		return "추가 실패";
	}	
}
	
// 추가
public String insertProcess(PlayerBean bean) {
	try {
		PlayerEntity playerEntity = new PlayerEntity(bean.getNo(), bean.getBacknum(), bean.getName(), bean.getTeamno(), bean.getPosition(), bean.getPay(), bean.getDraft());
		repository.save(playerEntity);
		return "추가 성공";
	} catch (Exception e) {
		System.out.println("insertProcess err : " + e);
		return "추가 실패";
	}
}

8. Read

// 전체 데이터 읽기
public List<PlayerEntity> getListAll() {
	List<PlayerEntity> list = repository.findAll(); // 기본 제공 메소드
	return list;
}
	
// 부분 데이터 읽기
public List<PlayerEntity> getListPart(String searchName) {
	List<PlayerEntity> searchList = repository.findByNameContaining(searchName);
	return searchList;
}
   
// 수정/삭제 레코드 읽기
public PlayerEntity getRecord(int no) {
	PlayerEntity playerEntity = repository.findByNo(no);
	return playerEntity;
}

9. Update

// 수정
public String updateProcess(PlayerBean bean) {
	try {
		PlayerEntity playerEntity = new PlayerEntity(bean.getNo(), bean.getBacknum(), bean.getName(), bean.getTeamno(), bean.getPosition(), bean.getPay(), bean.getDraft());
		repository.save(playerEntity);
		return "수정 성공";
	} catch (Exception e) {
		System.out.println("updateProcess err : " + e);
		return "수정 실패";
	}
}

10. Delete

// 삭제
public String deleteProcess(int no) {
	try {
		repository.deleteById(no);
		return "삭제 성공";
	} catch (Exception e) {
		System.out.println("deleteProcess err : " + e);
		return "삭제 실패";
	}
}

Error

😈 list.html 페이지 이동 시 오류 발생
org.thymeleaf.exceptions.TemplateInputException: An error happened during template parsing (template: "class path resource [templates/list.html]") org.springframework.expression.spel.SpelEvaluationException: EL1007E: Property or field 'size' cannot be found on null

😍 해결 : Controller에서 model.addAttribute("lists", list); 키 값이 list.html에서 불일치한 부분 확인 후 수정
model.addAttribute("list", list);
                  ▼
model.addAttribute("lists", list);

Reference

🙏 Rubisco's Programming Note : JPA(Java Persistence API)

🙏 로그의 개발일지 : [Spring JPA]JpaRepository 기본 사용법

🙏 IT is True:티스토리 :  [JPA] JPQL이란? - 객체지향 쿼리 언어 JPQL (1)

🙏 삽질 저장소 : JPA vs JDBC, JPA vs Mybatis, JPA vs Spring Data JPA의 차이점과 Hibernate