[Spring] JPA 이해하기
새롭게 시작한 프로젝트에서 JPA를 사용하기로 했다.
과거 스프링 레거시로 프로젝트를 할 때는 Mybatis를 사용했었다.
레거시 특성 상 일일이 해줘야할 설정들이 많은데 JPA보다는 Mybatis가 설정면에서는
접근성이 좋았기 때문이었다.
새 프로젝트에서는 JPA를 못다뤄본 내가 JPA를 맡아서 개발해보기로 했다.
그럴려면 정확히 이해하고 있어야 할 거 같아 알아보기로 했다.
Mybatis vs JPA
MyBatis는 SQL 중심, DB 중심의 사고방식
JPA는 객체 중심적 사고
JPA는 자바의 객체 모델과 DB의 테이블을 자동으로 매핑해주고, SQL보다는 객체의 상태 변화에 집중할 수 있게 해줌
Member member = new Member("subin");
em.persist(member);
이런 식으로 개발자는 객체의 생성/변경/삭제에 집중하고,
SQL은 JPA가 자동으로 처리해준다.
Mybatis
MyBatis의 경우는 SQL Mapper로 자바 객체와 SQL의 파라미터/결과를 매핑해주는 도구일 뿐이다.
단지 개발자가 SQL를 적어 보낼 때 건내주는 것과 받는 걸 JAVA에 맞게 변환해주는 도구
개발자가 SQL과 매핑까지 신경 써야한다.
SQL를 직접 작성해야하는 만큼 DB에 종속된다.
과거 프로젝트에서도 Mapper를 통해 매핑과 XML을 통해 SQL를 직접 작성했어야 했다.
JPA
JPA는 API 표준이다. 이 표준을 지키는 다양한 구현체가 있고, 그중 Hibernate가 있다.
이 구현체는 DB 벤더 별로 각 SQL을 생성하는 방식을 내부적으로 만들어 놨기에 DB 종속성이 없을 수 있는 것이다.
하지만 복잡한 쿼리에서는 JPA가 한계가 있다.
한계점
- 조인 3~4개 이상에 조건이 여러 개 붙는 복잡한 통계 쿼리
- 윈도우 함수(ROW_NUMBER, RANK 등), 서브쿼리, CASE WHEN 등 고급 SQL 기능
- 성능 튜닝을 위해 특정 DB에 최적화된 쿼리 작성 필요할 때
이럴 때는 JPA도 쿼리를 쓸 수 있게 지원한다.
Native Query와 JPQL이 있다.
Native Query는 SQL문에서 쓸 수 있는 건 다 쓸 수 있지만 개발자가 매핑을 해줘야함
이건 MyBatis랑 거의 동일하다고 봐도 될 듯
JPQL은 더 객체지향적인 SQL이다 추상화 되어있어 DB에 종속적이지 않다.
하지만 JPA가 지원하는 SQL문인 JPQL은 타입 안정성이 없고 불편하다.
그래서 나온게 QueryDSL 이게 불편한 JPQL을 대체할 수 있다.
JPQL
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m WHERE m.age > :age")
List<Member> findByAgeGreaterThan(@Param("age") int age);
}
@RequiredArgsConstructor
@Repository
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final EntityManager em;
public List<Member> findCustom() {
String jpql = "SELECT m FROM Member m WHERE m.age > :age";
return em.createQuery(jpql, Member.class)
.setParameter("age", 18)
.getResultList();
}
}
첫 번째 방식은 선언형으로 repo 인터페이스에 직접 적는 것 구현체는 내부적으로 만들어짐
두 번째는 프로그래밍 방식으로 커스텀 인터페이스 만들고 구현체를 본인이 만드는 방식
QueryDSL
QMember m = QMember.member;
List<Member> result = queryFactory
.selectFrom(m)
.where(m.age.gt(18))
.fetch();
Q객체
여기서 QMember는 아래와 같이 @Entity 어노테이션을 사용한 클래스는 자동 생성해줌
@Entity가 “데이터베이스 테이블과 매핑되는 클래스”를 나타내는 어노테이션이니까
@Entity
public class Member {
@Id
private Long id;
private String name;
private int age;
}
public class QMember extends EntityPathBase<Member> {
public final NumberPath<Long> id = createNumber("id", Long.class);
public final StringPath name = createString("name");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
public static final QMember member = new QMember("member");
// 생성자 등 생략
}
이런 식으로 Q객체가 만들어지는데 만들어지면 각 필드들은 아래와 같은 필드로 바뀌면서 메서드를 활용할 수 있다. 이 메서드를 WHERE 절에 넣어 조건을 넣어 줄 수 있는 것이다.
자바 타입 | Q객체 필드 타입 | 메서드 예시 |
String | StringPath | .eq(), .contains(), .like(), .startsWith() |
int, long 등 숫자 | NumberPath<T> | .eq(), .gt(), .lt(), .between() |
boolean | BooleanPath | .isTrue(), .isFalse() |
LocalDateTime, Date | DateTimePath<T> | .before(), .after(), .between() |
Enum | EnumPath<T> | .eq(), .in() |
상속 받는 EntityPathBase는 Q객체 필드 타입들이 정의되어있고, 각 메서드들도 포함되어있어서 사용할 수 있게한다.
<T> 에 기반이 될 클래스를 넣어서 그에 맞는 Q객체를 만드는것이다.
EntityManager
이걸 통해 모든 DB 작업이 실행됨
메서드 | 설명 |
persist(entity) | 엔티티를 저장 (INSERT 쿼리 발생) |
find(Entity.class, id) | 엔티티를 조회 (SELECT) |
remove(entity) | 엔티티 삭제 (DELETE) |
merge(entity) | 준영속 상태의 엔티티를 다시 붙임 (UPDATE) |
createQuery(...) | JPQL 쿼리 실행 |
createNativeQuery(...) | 네이티브 SQL 실행 |
flush() | 영속성 컨텍스트 → DB로 강제 반영 |
clear() | 1차 캐시 초기화 |
JPA의 핵심 동작 원리와 설계 패턴들
1. 영속성 컨텍스트 (Persistence Context)
- JPA는 DB와 직접 통신하지 않고 1차 캐시에 객체를 먼저 저장함
- em.persist() 하면 즉시 insert되지 않고, 트랜잭션 커밋 시점에 flush 됨
- em = EntityManager
- 변경 감지도 여기서 이뤄짐
2. 엔티티 생명주기와 상태
상태 | 설명 |
비영속 | new로 객체만 생성된 상태 (DB와 전혀 관련 없음) |
영속 | em.persist() 된 상태 (1차 캐시에 올라감) |
준영속 | em.detach() 등으로 영속성 컨텍스트에서 분리됨 |
삭제 | em.remove()로 삭제 예정 상태 |
3. 연관관계 매핑
객체 간 관계를 객체스럽게 매핑할 수 있음
@Entity
public class Member {
@ManyToOne // -> 멤버 여럿이 하나에 팀에 들어가는 관계
private Team team;
}
@Entity
public class Team {
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
JPA로 DB 연결하고 CRUD 구현하는 전체 흐름
1. 환경설정
spring:
datasource:
url: jdbc:mysql://localhost:3306/testdb
username: root
password: 1234
jpa:
hibernate:
ddl-auto: update # 또는 create, none
show-sql: true # 콘솔에 SQL 출력
properties:
hibernate:
format_sql: true
ddl-auto 옵션은 개발 중엔 update, 운영 환경에서는 none 권장
2. Entity 클래스 정의(@Entity)
import jakarta.persistence.*;
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
// 생성자, getter/setter, toString 등
}
- @Entity → 테이블로 매핑
- @Id → 기본 키
- @GeneratedValue → 자동 증가 설정
3. Repository 인터페이스 생성
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// 기본적인 CRUD 메서드 자동 제공
// findByName, existsByName 등도 자동 생성 가능
}
- JpaRepository<Entity, ID타입> 상속
- 기본적으로 아래 메서드 제공:
메서드 설명
save() | 저장 또는 수정 |
findById() | ID로 조회 |
findAll() | 전체 조회 |
deleteById() | 삭제 |
등등 |
4. Service, Controller 작성
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public Member createMember(String name, int age) {
Member member = new Member();
member.setName(name);
member.setAge(age);
return memberRepository.save(member);
}
public List<Member> getAllMembers() {
return memberRepository.findAll();
}
public void delete(Long id) {
memberRepository.deleteById(id);
}
}
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService service;
public MemberController(MemberService service) {
this.service = service;
}
@PostMapping
public Member create(@RequestBody Member member) {
return service.createMember(member.getName(), member.getAge());
}
@GetMapping
public List<Member> list() {
return service.getAllMembers();
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
service.delete(id);
}
}
실행 흐름 정리
- 사용자가 /members API 호출
- Controller → Service → Repository → JPA
- JPA가 Hibernate를 통해 SQL 생성
- DB에 쿼리 실행
- 결과를 다시 객체(Entity)로 매핑해 반환
여기서 Repository는 단지 인터페이스만 만들고 있다.
구현체를 따로 만들지 않는다.
Spring Data JPA가 프록시 객체를 런타임에 생성해서 등록하기 때문이다.
- @EnableJpaRepositories가 Repository 인터페이스를 스캔하고
- 내부적으로 SimpleJpaRepository라는 클래스를 자동으로 상속/구현한 프록시 객체를 만들어서
- @Autowired로 내 코드에 넣어주는 것
아래와 같이 클래스가 내부적으로 구현된다.
public class SimpleJpaRepository<T, ID> implements JpaRepository<T, ID> {
@PersistenceContext
private final EntityManager em;
@Override
public Optional<T> findById(ID id) {
return Optional.ofNullable(em.find(domainClass, id));
}
@Override
public List<T> findAll() {
// SELECT t FROM Entity t
}
@Override
public T save(T entity) {
if (isNew(entity)) {
em.persist(entity);
} else {
em.merge(entity);
}
}
...
}
간단한 CRUD를 구현해준다.
구현할 때 EntityManager를 통해 구현하는 모습
기본으로 제공하는 건
기본키로 다루는 메서드(찾기, 삭제하기) + 저장하는 메서드(id가 null이면 INSERT, 아니면 UPDATE)
여기서 별도로 내가 만들저줬으면 하는 게 있으면 인터페이스에 적기만 하면 만들어줌
쿼리 메서드 자동생성
기본으로 생성된 ID기반 메서드 말고 추가로 원하는 건 인터페이스에 적으면 됨
findBy, countBy, existsBy, deleteBy 등으로 시작하는 메서드 이름을 보고 쿼리를 만들어줌
이런 식으로 By뒤에 필드명 + 조건연산자를 적으면 됨
List<Member> findByName(String name); // name = ?
List<Member> findByAgeGreaterThan(int age); // age > ?
List<Member> findByEmailContaining(String keyword); // email LIKE %keyword%
List<Member> findByNameAndAge(String name, int age); // name = ? AND age = ?
Optional<Member> findByEmail(String email); // 단건 조회
조인연산은 불가능함 이건 단일 테이블에서 사용할 쿼리만
그리고 조건이 더 많아질 경우 가독성이 매우 떨어지게 된다.
그때는 SQL을 사용하는게 나을 수 있다.
그래서 나온게 JPQL 이건 SQL 추상화한 거라서 특정 DB에 종속되지 않는다.
@Query("select i from Item i where i.itemDetail like " +
"%:itemDetail% order by i.price desc")
List<Item> findByItemDetail(@Param("itemDetail") String itemDetail);
@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 안에 SQL문을 적는데 Item는 Item 엔티티
또한 @Param을 주는 이유는 명시적인 방법으로 더욱 명확하게 바인딩하기 위해서
→ 입력파리미터를 :itemDetail 여기에 바인딩을 명확히 하고 싶어서
이렇게 작성하는게 가장 좋음 탈이 없음
아래에는 Native Query로 만든 동일 기능이다. SQL 문법 그대로를 사용하고 있다.
JPA 연관관계 매핑
JPA에서 엔티티 간 연관관계를 표현할 수 있는 어노테이션 4가지 존재
- @ManyToOne
- @OneToMany
- @OneToOne
- @ManyToMany
이건 각 엔티티가 어떤 관계가 있는지 보여주는 것이다.
Member.java
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // N:1 관계
@JoinColumn(name = "team_id") // member 테이블의 외래 키 컬럼명
private Team team;
// getter/setter
}
Team.java
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 1:N 관계 (주인이 아님)
private List<Member> members = new ArrayList<>();
// getter/setter
}
여기서 주인이란 관계의 외례키를 가지는 객체를 말한다.
member.setTeam(team);
이렇게 멤버 쪽에서 외례키를 넣어줘야한다는 것
지연로딩
여기서 중요한 점은 조인연산 대신 지연로딩을 통해 각 연산을 따로 쿼리하게 하는 것이다. 단지 연관관계를 따라가는 조회
Member member = em.find(Member.class, 1L); // member만 SELECT
Team team = member.getTeam(); // 그 다음에 team SELECT
-- join 대신 조회를 2번 한 것
SELECT m.*, t.*
FROM member m
JOIN team t ON m.team_id = t.id
WHERE m.id = 1;
이렇게 하면 장점은
- 성능 최적화: 초기 조회 시 불필요한 JOIN 방지
- 메모리 절약: 연관 관계가 많을 경우, 정말 필요한 시점에만 불러와서 메모리 낭비 줄임
- 설계 유연성: 연관된 객체가 많아도 다 가져올 필요 없음
QuerydslPredicateExecutor<T>
public interface ItemRepository extends JpaRepository<Item, Long>,
QuerydslPredicateExecutor<Item>, ItemRepositoryCustom
여기서 ItemRepository에서 상속 받는 것 중에 QuerydslPredicateExecutor<Item>가 있다.
뭐길래 상속을 받아 사용할까?
일단 QuerydslPredicateExecutor는 QueryDSL의 Predicate 기반 동적 쿼리를 Spring Data JPA의 Repository와 연결시켜주는 역할을 함
여기서 생소한 단어들을 정리하고 가자
1. Predicate란?
Predicate는 QueryDSL에서 조건(Where절)을 표현하는 객체
QItem item = QItem.item;
Predicate predicate = item.price.gt(10000).and(item.itemNm.contains("노트북"));
저 item.price.gt(10000) 같은 조건을 담고 있는 게 바로 Predicate 객체
Where절을 객체로 만든 것
BooleanBuilder
- 여러 개의 Predicate를 조합할 수 있도록 도와주는 빌더 객체
- Predicate를 And/OR 로 엮을 수 있음
- 초기엔 빈 상태에서 and(), or()로 쌓아감
BooleanBuilder booleanBuilder = new BooleanBuilder();
QItem item = QItem.item;
String itemDetail = "테스트 상품 상세 설명";
int price = 10003;
String itemSellStat = "SELL";
booleanBuilder.and(item.itemDetail.like("%" + itemDetail + "%"));
booleanBuilder.and(item.price.gt(price));
System.out.println(ItemSellStatus.SELL);
if(StringUtils.equals(itemSellStat, ItemSellStatus.SELL)){
booleanBuilder.and(item.itemSellStatus.eq(ItemSellStatus.SELL));
}
2. 동적 쿼리란?
조건이 실행 시점에 유동적으로 바뀌는 쿼리
- 검색 조건이 있을 때만 Where절에 붙고
- 정렬 기준이 달라질 수 있고
- 필터링 조건이 사용자의 입력에 따라 달라지는 경우
이와 같이 실행 시점에 조건을 동적으로 조립하는 쿼리를 말함
3. Spring Data JPA Repository와 연결시켜준다?
원래는 EntityManager나 JPAQueryFactory를 써서 조건을 조립하고 .fetch() 해야 했음
저걸 상속 받으면
List<Item> result = itemRepository.findAll(predicate); // ← 조건만 넘기면 알아서 실행
이렇게 Predict를 직접 넣어서 실행할 수 있는 기능을 자동 제공하는 것
조건(동적 쿼리)과 실행(Repository 메서드 호출)이 연결됐다는 의미
List<Item> items = queryFactory
.selectFrom(QItem.item)
.where(QItem.item.price.gt(10000))
.fetch();
원래는 이런식으로 사용했음
제공하는 메서드
Optional<T> findOne(Predicate predicate);
Iterable<T> findAll(Predicate predicate);
Page<T> findAll(Predicate predicate, Pageable pageable);
Iterable<T> findAll(Predicate predicate, Sort sort);
long count(Predicate predicate);
boolean exists(Predicate predicate);
동작 원리
- 내가 QuerydslPredicateExecutor<T>를 ItemRepository 같은 데 상속하면
- Spring Data JPA가 이 인터페이스에 맞는 자동 구현체를 생성함
- 내부적으로는 EntityManager + QueryDSL의 JPQLQuery를 조합해 쿼리 생성
Predicate를 파라미터로 넘기면 QueryDSL 기반 JPQL을 생성하고 실행까지 자동 처리한다는 것
장단점
장점
- 빠른 개발
단점
- 복잡한 처리 불가
- 튜닝 어려움
- 별도 구현 못 넣음
빠르게 개발하고, 단순 조건 쿼리 사용할 때 편리하다.
오늘은 JPA 개념적 이해를 중점으로 배워보았다.
와중에 쇼핑몰 웹사이트를 예시로 JPA를 사용한 코드를 같이 보면서 CRUD를 어떻게 구현했는지 확인했다.
오늘 공부한 것을 바탕으로 이번 프로젝트에서 사용할 JPA를 설계해봐야겠다.