Spring Data JPA는 JPA(Java Persistence API)를 더 쉽게 사용할 수 있도록 도와주는 프레임워크다.
extends JpaRepository를 당연히 해줘야한다. // 기본 CRUD 메서드 사용
public User save(User user) {
return userRepository.save(user);
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
// 페이징 처리 예시
public Page<User> findAll(Pageable pageable) {
return userRepository.findAll(pageable);
}
Spring Data JPA는 메소드 이름을 특정 규칙에 따라 파싱한다.
List<User> findByUsername(String username);
SELECT * FROM users WHERE username = ?
// 메소드 이름으로 만드는 다양한 쿼리
List<User> findByUsernameAndAge(String username, int age);
// SELECT * FROM users WHERE username = ? AND age = ?
List<User> findByUsernameOrEmail(String username, String email);
// SELECT * FROM users WHERE username = ? OR email = ?
List<User> findByAgeLessThan(int age);
// SELECT * FROM users WHERE age < ?
List<User> findByUsernameOrderByAgeDesc(String username);
// SELECT * FROM users WHERE username = ? ORDER BY age DESC
메서드 이름이 규칙에 맞지 않으면 애플리케이션 시작 시점에 다음과 같은 예외가 발생한다.
InvalidDataAccessApiUsageException: No property 'invalidField' found for type 'User'
이런 방식으로 Spring Data JPA는 개발자가 별도의 쿼리를 작성하지 않아도 메소드 이름만으로 원하는 데이터베이스 작업을 수행할 수 있게 해준다.
Spring Data JPA 특징엔티티 간 연관관계 매핑JPA에서는 데이터베이스의 관계를 객체 간의 관계로 매핑한다.
주요 관계 유형:
각 관계는 방향성을 가질 수 있으며(단방향/양방향) cascade 옵션으로 연관된 엔티티의 생명주기를 함께 관리할 수 있다.
외래키가 있는 쪽에서 관계를 관리하는 것이 자연스러우므로 @ManyToOne 단방향을 기본으로 사용하고, 필요한 경우 @OneToMany를 추가하여 양방향을 만들자.
CascadeCascadeType.ALL
모든 상태 변화를 전파한다. (PERSIST + REMOVE + REFRESH + MERGE + DETACH)
부모 엔티티를 통해 자식 엔티티의 생명주기를 완전히 관리할 때 사용
CascadeType.PERSIST
저장 시 연관 엔티티도 함께 저장
부모를 persist할 때 자식도 함께 persist된다.
CascadeType.REMOVE
삭제 시 연관 엔티티도 함께 삭제
부모 엔티티 삭제 시 자식 엔티티도 함께 삭제된다.
CascadeType.MERGE
병합 시 연관 엔티티도 함께 병합
준영속 상태의 엔티티를 영속 상태로 변경할 때 사용한다.
CascadeType.REFRESH
부모 엔티티를 새로고침할 때 자식 엔티티도 함께 새로고침한다.
CascadeType.DETACH
부모 엔티티가 준영속 상태가 될 때 자식 엔티티도 준영속 상태가 된다.
Cascade는 부모-자식 관계에서만 사용해야한다.
여러 엔티티에서 같은 자식을 참조하는 경우 Cascade 사용을 피해야 한다.
Cascade를 사용하지 않고 직접 삭제 로직을 구현하는 것도 괜찮다.
데이터소스 설정과 JPA 설정application.properties/yml에서 데이터베이스 연결 정보 설정
JPA 관련 다양한 설정 가능:
영속성 컨텍스트엔티티를 영구 저장하는 환경을 의미하며, 엔티티 매니저(EntityManager)를 통해 엔티티를 저장하거나 조회하면 영속성 컨텍스트에 엔티티를 보관하고 관리한다.
(논리적인 개념으로 눈에 보이지 않는다.)
반복 조회로 동일 데이터를 여러 번 조회할 때 큰 성능 차이가 발생한다.
영속성 컨텍스트가 없는 경우 매번 DB 조회를 하지만, 있는 경우 최초 한번만 DB에서 조회하고 이후에는 캐시에서 조회한다.
그러나 캐시된 데이터가 많아지면 메모리에 부하를 주게 되고, 너무 오래 유지하면 변경된 데이터를 못보는 경우가 생긴다.
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");
// 객체를 저장한 상태(영속)
em.persist(member);
// 회원 엔티티를 영속성 컨텍스트에서 분리
em.detach(member);
// 객체를 삭제한 상태
em.remove(member);
1) 1차 캐시
// 1차 캐시에 저장됨
em.persist(member);
// 1차 캐시에서 조회
Member findMember = em.find(Member.class, "member1");
2) 동일성 보장
Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");
System.out.println(a == b); // true
3) 변경 감지(Dirty Checking)
Transaction transaction = em.getTransaction();
transaction.begin();
// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");
// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);
// em.update(member) 이런 코드가 필요없음
transaction.commit();
4) 지연 쓰기(Write-Behind)
EntityTransaction transaction = em.getTransaction();
transaction.begin();
em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않음
// 커밋하는 순간 데이터베이스에 INSERT SQL을 보냄
transaction.commit();
N+1 문제란?
연관 관계에서 발생하는 성능 이슈. 1번의 쿼리로 N개의 레코드를 가져왔는데, 이 N개의 레코드와 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 문제.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER) // EAGER로 설정된 경우
private Team team;
}
N+1 문제가 발생하는 상황
1) EAGER 로딩에서의 N+1
List<Member> members = memberRepository.findAll();
-- 1. Member를 조회하는 첫 번째 쿼리
SELECT * FROM member;
-- 2. 각 Member의 Team을 조회하는 N번의 쿼리
SELECT * FROM team WHERE team_id = ?; -- member1의 팀 조회
SELECT * FROM team WHERE team_id = ?; -- member2의 팀 조회
SELECT * FROM team WHERE team_id = ?; -- member3의 팀 조회
...
2) LAZY 로딩에서의 N+1
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("팀 이름 = " + member.getTeam().getName()); // 지연 로딩 시점
}
-- 1. Member를 조회하는 첫 번째 쿼리
SELECT * FROM member;
-- 2. 반복문에서 Team을 사용할 때 발생하는 N번의 쿼리
SELECT * FROM team WHERE team_id = ?; -- member1의 팀 조회
SELECT * FROM team WHERE team_id = ?; -- member2의 팀 조회
SELECT * FROM team WHERE team_id = ?; -- member3의 팀 조회
...
3) 해결 방법
페치 조인 사용
@Query("select m from Member m join fetch m.team")
List<Member> findAllWithTeam();
-> 실행되는 SQL
SELECT m.*, t.*
FROM member m
JOIN team t ON m.team_id = t.id
BatchSize 설정
@Entity
public class Member {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Team team;
}
@Repository
public class MemberRepository {
// 일반적인 조회용
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
// 연관된 데이터가 필요한 경우
public List<Member> findAllWithTeam() {
return em.createQuery(
"select m from Member m join fetch m.team", Member.class)
.getResultList();
}
}
A. 캐싱
자주 사용되는 데이터를 메모리에 저장
데이터베이스 접근 횟수 감소
1차 캐시와 2차 캐시로 구분
@Cacheable 애노테이션으로 설정
B. Auditing
엔티티의 생성/수정 시간, 생성/수정자 정보 자동 관리
@CreatedDate, @LastModifiedDate
@CreatedBy, @LastModifiedBy
보안과 감사 추적에 유용
C. 낙관적 락킹
동시성 제어를 위한 메커니즘
@Version 애노테이션 사용
여러 트랜잭션이 동시에 같은 데이터를 수정하는 것을 방지
충돌 발생 시 예외 발생
성능 최적화를 위해 적절한 fetch 전략 선택
트랜잭션 범위와 격리 수준 적절히 설정
연관관계 매핑 시 양방향/단방향 신중히 선택
캐싱 전략 수립으로 성능 향상