JPA는 자바 객체와 데이터베이스의 통신 사이에서 SQL을 Mapping 해주는 역할을 하는 ORM(Object Relational Mapping)기술이다. 특히 JPA는 오픈소스 하이버네이트에서 구현되어 있으며, 따로 의존성을 추가할 필요는 없다.
JPA가 작동하는 순서는 아래와 같다.
Entity Manager Factory -> create() -> Entity Manager -> operation()-> Persistence Context(내부에Entity존재)
1. EntityManagerFactory (엔티티 매니저 팩토리)
JPA의 시작점 역할.
데이터베이스와 연결할 수 있는 EntityManager를 생성하는 공장(Factory 구조).
애플리케이션 실행 시 딱 한 번만 생성해서 공유함. (Spring Boot에서는 EntityManagerFactory를 내부적으로 LocalContainerEntityManagerFactoryBean이 대신 관리해줌)
2. EntityManager (엔티티 매니저)
실제로 JPA를 통해 DB 작업을 수행하는 핵심 객체.
CRUD (저장, 수정, 삭제, 조회) 명령을 수행.
한 번 생성된 EntityManager는 Persistence Context(영속성 컨텍스트) 를 내부적으로 관리함. 스프링에서는 @PersistenceContext 또는 @Autowired로 주입받아 사용.
엔티티 매니저에는 다음과 같은 함수가 존재한다.
1. find()메소드: 영속성 컨테스트에서 엔티티를 검색하고, 없을 경우 데이터베이스에서데이터를 찾아
영속성 컨텍스트에 저장한다.
2. persist()메소드: 엔티티를 영속성 컨텍스트에 저장한다.
3. remove()메소드: 엔티티 클래스를 영속성 컨텍스트에서 삭제한다.
4. flush()메소드: 영속성 컨텍스트에 저장된 내용을 데이터베이스에 반영한다.
3. Persistence Context (영속성 컨텍스트 == 애플리케이션 단위의 캐싱기능)
엔티티 객체들을 보관하는 1차 캐시 공간.
DB에서 데이터를 가져오면 Entity 객체를 여기에 저장하고, 관리하게 됨.
중요한 특징:
1. 1차 캐싱: 같은 트랜잭션 내에서 동일한 엔티티를 여러 번 조회해도 DB를 다시 조회하지 않고 캐시된 객체를 반환.
2. 변경 감지(Dirty Checking): 엔티티 객체의 값이 바뀌면 트랜잭션 커밋 시점에 자동으로 UPDATE SQL을 생성해서 반영.
3. 쓰기 지연: persist()를 호출한다고 즉시 INSERT 쿼리가 날아가지 않고, 트랜잭션 커밋 시점에 모아서 실행.
4. Entity (엔티티)
JPA에서 관리하는 객체(클래스)이자 조회결과.
@Entity 어노테이션이 붙은 클래스.
테이블과 매핑되어, JPA가 Persistence Context 안에서 이 엔티티의 상태를 관리함.
매핑이란? 자바의 객체와 데이터 베이스를 서로 연결하기 위해 둘 사이에서 ORM(ex:jpa 등)이 SQL없이 이를 연결하는 것을 의미한다. 아래는 매핑 관련 에노테이션이다. 참고로 JPA를 사용하려면 반드시 Get, Set 함수들이 있어야 하기 때문에, Lombok 라이브러리의 @Data 에노테이션이나 @Get+@Set 에노테이션이 필요하다.
에노테이션 | 설명 |
---|---|
@Entity | 클래스를 엔티티로 선언한다. |
@Table | 엔티티와 매핑할 테이블을 지정한다. |
@ID | PK를 지정한다. |
@GeneratedValue | 키값을 생성하는 전략을 명시한다. |
@Column | 필드와 컬럼을 매핑한다. |
@Lob | BLOP 또는 CLOB 타입을 매핑한다. (대용량 데이터 저장시 사용) |
@CreationTimestamp | insert시 시간을 자동으로 저장한다. |
@UpdateTimestamp | update시 시간을 자동으로 저장한다. |
@Enumerated | enum(상수)타입을 매핑한다. |
@Transient | 해당 필드 데이터베이스 매핑 무시. |
@Temporal | 날짜 타입을 매핑한다. |
@CreateDate | 엔티티가 생성되어 저장될 떄 시간을 자동으로 저장한다. |
@LastModifiedDate | 조회한 엔티티의 값을 변경할 때 시간을 자동으로 저장한다. |
참고로 @Lob의 CLOB은 사이즈가 큰 데이터를 외부파일로 저장하기 위한 데이터 타입이다. 문자형 대용량 파일을 저장하는데 사용한다. BLOB은 바이너리 데이터를 DB 외부에 저장하기 위한 타입으로, 주로 이미지, 사운드, 비디오 같은 데이터를 다룰 때 사용한다.
import jakarta.persistence.*; // JPA 관련
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Entity // 클래스가 엔티티임을 선언
@Table(name = "MEMBER") // DB의 MEMBER 테이블과 매핑
public class Member {
@Id // PK 지정
@GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 증가 전략 사용
private Long id;
@Column(name = "USERNAME", nullable = false, length = 100) // 컬럼과 매핑
private String username;
@Lob // 큰 데이터 저장 (예: 프로필 소개)
private String description;
@CreationTimestamp // INSERT 시 자동 생성 시간 기록
private LocalDateTime createdAt;
@UpdateTimestamp // UPDATE 시 자동 변경 시간 기록
private LocalDateTime updatedAt;
// Getter/Setter --> 이를 만들어주지 않으려면 @Data또는 @Get+@Set 필요
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import java.util.Date;
@Entity
@Table(name = "ORDERS") // 엔티티와 테이블 매핑
@Data // get,set 자동생성
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long orderId;
// enum 타입 매핑
public enum Status {
PENDING, SHIPPED, DELIVERED, CANCELLED
}
@Enumerated(EnumType.STRING) // enum 이름을 문자열로 저장
private Status status;
// 날짜 매핑
@Temporal(TemporalType.TIMESTAMP) // java.util.Date → DB TIMESTAMP
private Date orderDate;
// JPA에 저장되지 않음 (임시 계산용 필드)
@Transient
private int tempCalculationValue;
// 엔티티 생성 시 자동으로 저장 시간 기록
@CreatedDate
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt;
// 엔티티 변경 시 자동으로 수정 시간 기록
@LastModifiedDate
@Temporal(TemporalType.TIMESTAMP)
private Date lastModifiedAt;
}
아래처럼 @Column 에너테이션을 이용하면, 속성의 제약조건을 추가할 수 있다.
@Column(
name = "USERNAME", // 매핑할 컬럼명
nullable = false, // NOT NULL 제약조건
unique = true, // UNIQUE 제약조건
length = 100, // 문자열 컬럼 길이 (VARCHAR(100), 기본 255)
precision = 10, // 숫자의 전체 자릿수 (BigDecimal 전용)
scale = 2 // 소수점 자리수 (BigDecimal 전용)
insertable = true // insert 가능 여부- 기본 = true
updateable = true // update 가능 여부- 기본 = true
columnDefinition // 직접 제약조건 사용
)
@Entity
@Table(name = "PRODUCT")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(
name = "PRODUCT_NAME",
nullable = false, // NOT NULL
unique = true, // UNIQUE
length = 200 // VARCHAR(200)
)
private String name;
@Column(
precision = 10, // 전체 자릿수 10
scale = 2 // 소수점 2자리
)
private BigDecimal price;
}
@Entity
@Table(name = "MEMBER")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// DB 컬럼 타입 직접 지정 (기본값 설정 포함)
@Column(columnDefinition = "VARCHAR(100) DEFAULT 'GUEST'")
private String role;
// 숫자 타입을 DB의 DECIMAL로 강제
@Column(columnDefinition = "DECIMAL(10,2) DEFAULT 0.00")
private Double balance;
// 체크 제약조건 추가
@Column(columnDefinition = "CHAR(1) CHECK (gender IN ('M', 'F'))")
private String gender;
}
@ID를 사용한 컬럼은 PK가 된다.PK는 @GenerateValue 에노테이션을 통해 기본키의 생성전략을
지정할 수 있다.
1. Auto : JPA구현체가 알아서 생성전략 결정
2. IDENTIFY : 기본키 생성을 DB에 위임한다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
3. SEQUENCE : 시퀸스 테이블을 이용한다.
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
4. TABLE : 별도의 키관리 테이블을 이용한다.
참고로 이렇게 만들어진 PK는 순서대로 값이 만들어지지 않고, 1, 2, 51, 52 등 중간에 값을 건너 띄게 되는데, 이는 동시성을 높이는 전략 때문에 그렇다. 따라서, 굳이 순서대로 ID를 만들고 싶다면,
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "item_seq")
@SequenceGenerator(
name = "item_seq",
sequenceName = "item_seq",
allocationSize = 1
)
private Long id;
위처럼 allocationSize를 1로 바꿔야한다.
즉, 선착순 가입 이벤트 등을 할때는 ID를 통해 계산하는 것이 아닌, 가입일시를 조회하는 쿼리로 조회해야하는 것이다.
JPA를 사용할 때는 인터페이스를 만들고, JpaRepository 를 extends 하여, 인터페이스를 통해 원하는 데이터를 찾게된다.
public interface ItemRepository extends JpaRepository<Item, Long> {
List<Item> findByItemNm(String itemNm); // 모든 Item 조회
List<Item> findByItemNmOrItemDetail(String itemNm, String itemDetail);
}
위는 Item(엔터티)타입의 리스트를 만들어 특정한 데이터를 찾을 수 있게 만든 List이다.
여기서, JPA의 CRUD 기능들을 사용하기 위해서는 메소드의 이름을 특정한 규칙대로 만들어야한다.
왜냐하면 메소드 이름 자체가 SQL의 where절의 역할을 하기 때문이다.
find + ...By... + [엔티티 속성 이름] + [조건 키워드]
예를 들어 두번째 쿼리의 경우, itemNm = :itemNm or itemDetail = :itemDetail 조건으로 검색하겠다와 같은 의미인 것이다.
구분 | 키워드 예시 | 설명 | 메소드 예시 |
---|---|---|---|
조회 | find...By | 가장 일반적인 조회 키워드입니다. (read , get , query 도 가능) | findByItemNm(...) |
조건 | And , Or | 여러 조건을 논리적으로 묶습니다. | findByPriceAndItemNm(...) |
비교 | Is , Equals | 같음을 비교합니다. (보통 생략 가능) | findByItemNmIs("상품A") |
Between | 두 값 사이에 있는지 확인합니다. | findByPriceBetween(10000, 20000) | |
LessThan , GreaterThan | 특정 값보다 작거나 큰 데이터를 찾습니다. | findByStockNumberLessThan(10) | |
Like , Containing | 문자열의 일부가 포함된 데이터를 찾습니다. (% 와일드카드) | findByItemNmContaining("테스트") | |
NotNull | NULL 이 아닌 값을 찾습니다. | findByItemDetailIsNotNull() | |
정렬 | OrderBy...Asc , OrderBy...Desc | 결과를 특정 속성 기준으로 오름차순/내림차순 정렬합니다. | findByPriceGreaterThanOrderByPriceDesc(10000) |
만약, 복잡한 SQL쿼리의 경우 메소드명만으로는 검색하는 것이 힘들 수 있다. 따라서, SQL을 통해 직접 데이터를 찾는 방법이 있는데 그것이 바로 @Query 에너테이션이다. @Query를 사용하면, 메소드명을 규칙에 구애받지 않고, 자유로이 지정할 수 있다. 단 JPQL(Java Persistence Query Language)이라고 하는 문법을 사용하며, SQL이 아니다.
public interface ItemRepository extends JpaRepository<Item, Long> {
List<Item> findByItemNm(String itemNm); // 모든 Item 조회
List<Item> findByItemNmOrItemDetail(String itemNm, String itemDetail);
List<Item> findByPriceLessThan(Integer price);
List<Item> findByPriceLessThanOrderByPriceDesc(Integer price);
@Query("select i from Item i where i.itemDetail like%:itemDetail% order by i.price desc")
List<Item> findByItemDetailTest(@Param("itemDetail") String itemDetail);
List<Item> queryDslTest();
}
@Query(value = "select * from item i where i.item_detail like %:itemDetail%
order by i.price desc", nativeQuery = true)
List<Item> findByItemDetailByNative(@Param("itemDetail") String itemDetail);
@Query가 사용된 findByItemDetailTest() 메소드는 JPQL로 직접 조회 중인 예시이다. select절에 Item테이블의 별명인 i를 사용하였는데, 이는 Item테이블 전체를 객체로 받아 DB로부터 반환한다는 의미이다.
여기에 더해 가장 중요한 개념이 @Param 이다. @Param("itemDetail") 을 통해서 String itemDetail의 파라미터값을 @Query 안의 SQL의 :itemDetail(바인드 변수) 부분에 이를 집어 넣는다는 의미다. 참고로 양쪽의 %표시는 양방향 Like 검색함수로 사용한 것이다.
JPA는 ORM으로 특정 DB에 종속되지 않는다고 했었다. 하지만, 특정 DB에서만 지원하는 문법을 사용하고 싶을 때는 findByItemDetailByNative() 메소드 처럼 nativeQuery = true 로 바꿔 줘야 한다. 즉, JPQL이 일반 SQL을 사용함으로써 DB에 종속성이 생기는 단점이 있지만, DB에 맞춘 세밀한 제어가 가능해진다. 또한 이런 단점도 Ansi 쿼리를 사용하면 어느정도 보완 가능하다.
@Entity
@Table(name="item")
@Getter
@Setter
@ToString
public class Item {
@Id //PK로 설정
@Column(name="item_id") // 컬럼명 지정
@GeneratedValue(strategy = GenerationType.AUTO) // PK정합성은 JDK가 관리
private Long id; // 상품 코드
@Column(nullable = false, length = 50) // not null, 길이 설정
private String itemNm; //상품명
@Column(name="price", nullable = false)
private int price; // 가격
@Column(nullable = false)
private int stockNumber; // 재고수량
@Lob // 대용량 데이터(문자+바이너리) 에서 사용한다.
@Column(nullable = false)
private String itemDetail; // 상품 상세설명
@Enumerated(EnumType.STRING)
private ItemSellStatus itemSellStatus; // 상품판매상태
private LocalDateTime regTime; //상품 등록 시간
private LocalDateTime updateTime; // 상품 수정시간
}
@SpringBootTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@Test
@DisplayName("상품 저장 테스트")
public void createItemTest(){
Item item = new Item();
item.setItemNm("테스트 상품");
item.setPrice(10000);
item.setItemDetail("테스트 상품 상세 설명");
item.setItemSellStatus(ItemSellStatus.SELL);
item.setStockNumber(100);
item.setRegTime(LocalDateTime.now());
item.setUpdateTime(LocalDateTime.now());
Item savedItem = itemRepository.save(item);
System.out.println(savedItem.toString());
}
public void createItemList(){
for (int i=1; i<= 10; i++) {
Item item = new Item();
item.setItemNm("테스트 상품" + i);
item.setPrice(10000+i);
item.setItemDetail("테스트 상품 상세 설명" + i);
item.setItemSellStatus(ItemSellStatus.SELL);
item.setStockNumber(100);
item.setRegTime(LocalDateTime.now());
item.setUpdateTime(LocalDateTime.now());
Item savedItem = itemRepository.save(item);
}
}
@Test
@DisplayName("상품명 조회 테스트")
public void findByItemNmTest(){
this.createItemList();
List<Item> itemList = itemRepository.findByItemNm("테스트 상품1");
for (Item item : itemList){
System.out.println(item.toString());
}
}
@Test
@DisplayName("상품명, 상품상세설명 OR 테스트")
public void findByItemNmOrItemDetailTest(){
this.createItemList();
List<Item> itemList = itemRepository.findByItemNmOrItemDetail("테스트 상품1", "테스트 상품 상세 설명5");
for (Item item : itemList){
System.out.println(item.toString());
}
}
@Test
@DisplayName("가격 LessThan 테스트")
public void findByPriceLessThanTest(){
this.createItemList();
List<Item> itemList = itemRepository.findByPriceLessThan(10005);
for(Item item : itemList){
System.out.println(item.toString());
}
}
@Test
@DisplayName("가격 내림차순 조회 테스트")
public void findByPriceLessThanOrderByPriceDesc(){
this.createItemList();
List<Item> itemList = itemRepository.findByPriceLessThanOrderByPriceDesc(10005);
for(Item item : itemList){
System.out.println(item.toString());
}
}
@Test
@DisplayName("@Query를 이용한 상품 조회 테스트")
public void findByItemDetailTest(){
this.createItemList();
List<Item> itemList = itemRepository.findByItemDetail("테스트 상품 상세 설명");
for(Item item : itemList){
System.out.println(item.toString());
}
}
@Test
@DisplayName("nativeQuery를 이용한 상품 조회 테스트")
public void findByItemDetailByNative(){
this.createItemList();
List<Item> itemList = itemRepository.findByItemDetailByNative("테스트 상품 상세 설명");
for(Item item : itemList){
System.out.println(item.toString());
}
}
}
@Test가 여러개 있는 파일의 경우 Test 실행 순서가 일정하지 않다. 따라서 아래의 방법 중 하나의 방법을 선택한다.
import org.junit.jupiter.api.*;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ItemRepositoryTest {
@Test
@Order(1)
void createItemTest() {
System.out.println("첫 번째 실행");
}
@Test
@Order(2)
void findItemTest() {
System.out.println("두 번째 실행");
}
}
@Test
@Disabled
public void someOtherTest() {
// 실행 안 됨
}