Spring Data JPA

szlee·2025년 1월 12일

JPA

목록 보기
1/1

Spring Data JPA는 JPA(Java Persistence API)를 더 쉽게 사용할 수 있도록 도와주는 프레임워크다.

  • Repository 인터페이스만 작성하면 기본적인 CRUD 작업(save, findById, findAll 등)을 위한 구현체를 자동으로 생성해준다.
    • 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);
    }
  • 메서드 이름만으로 복잡한 쿼리를 생성할 수 있다.
  • 페이징과 정렬 기능을 쉽게 구현할 수 있다.
  • @Query 어노테이션으로 직접 쿼리 작성이 가능하다.

메서드 이름 파싱

Spring Data JPA는 메소드 이름을 특정 규칙에 따라 파싱한다.

  • find : 조회 명령을 나타내는 prefix
  • By : 조건절의 시작을 나타내는 구분자
  • ex)Username : 실제 엔티티의 필드명
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 특징

1. 엔티티 간 연관관계 매핑

JPA에서는 데이터베이스의 관계를 객체 간의 관계로 매핑한다.
주요 관계 유형:

  • @OneToMany / @ManyToOne: 일대다/다대일 관계 (예: 회원과 주문)
  • @OneToOne: 일대일 관계 (예: 회원과 회원상세정보)
  • @ManyToMany: 다대다 관계 (예: 학생과 수업)

각 관계는 방향성을 가질 수 있으며(단방향/양방향) cascade 옵션으로 연관된 엔티티의 생명주기를 함께 관리할 수 있다.

외래키가 있는 쪽에서 관계를 관리하는 것이 자연스러우므로 @ManyToOne 단방향을 기본으로 사용하고, 필요한 경우 @OneToMany를 추가하여 양방향을 만들자.

Cascade

  • CascadeType.ALL
    모든 상태 변화를 전파한다. (PERSIST + REMOVE + REFRESH + MERGE + DETACH)
    부모 엔티티를 통해 자식 엔티티의 생명주기를 완전히 관리할 때 사용

  • CascadeType.PERSIST
    저장 시 연관 엔티티도 함께 저장
    부모를 persist할 때 자식도 함께 persist된다.

  • CascadeType.REMOVE
    삭제 시 연관 엔티티도 함께 삭제
    부모 엔티티 삭제 시 자식 엔티티도 함께 삭제된다.

  • CascadeType.MERGE
    병합 시 연관 엔티티도 함께 병합
    준영속 상태의 엔티티를 영속 상태로 변경할 때 사용한다.

  • CascadeType.REFRESH
    부모 엔티티를 새로고침할 때 자식 엔티티도 함께 새로고침한다.

  • CascadeType.DETACH
    부모 엔티티가 준영속 상태가 될 때 자식 엔티티도 준영속 상태가 된다.

Cascade는 부모-자식 관계에서만 사용해야한다.
여러 엔티티에서 같은 자식을 참조하는 경우 Cascade 사용을 피해야 한다.
Cascade를 사용하지 않고 직접 삭제 로직을 구현하는 것도 괜찮다.

2. 데이터소스 설정과 JPA 설정

application.properties/yml에서 데이터베이스 연결 정보 설정
JPA 관련 다양한 설정 가능:

  • ddl-auto: 데이터베이스 스키마 자동 생성 전략
  • show-sql: SQL 로그 출력 여부
  • dialect: 특정 데이터베이스 방언 설정

3. 영속성 컨텍스트

엔티티를 영구 저장하는 환경을 의미하며, 엔티티 매니저(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();
  • JPA는 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해둠 (스냅샷)
  • 트랜잭션이 커밋되는 시점에 스냅샷과 엔티티를 비교해서 변경된 엔티티를 찾음
  • 변경된 엔티티가 있으면 UPDATE SQL을 생성해서 데이터베이스에 보냄

4) 지연 쓰기(Write-Behind)

EntityTransaction transaction = em.getTransaction();
transaction.begin();

em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않음

// 커밋하는 순간 데이터베이스에 INSERT SQL을 보냄
transaction.commit();
  • 엔티티 매니저는 트랜잭션을 커밋하기 직전까지 데이터베이스에 엔티티를 저장하지 않음
  • 내부 쿼리 저장소에 SQL을 모아두었다가 트랜잭션을 커밋할 때 모아둔 쿼리를 데이터베이스에 보냄

4. 지연 로딩과 즉시 로딩

  • 지연 로딩(LAZY)
    연관된 엔티티를 실제 사용할 때 로딩
    불필요한 데이터 로딩 방지
    N+1 문제 발생 가능
  • 즉시 로딩(EAGER)
    엔티티를 조회할 때 연관된 엔티티도 함께 로딩
    한 번에 많은 데이터를 가져와야 할 때 유용
    불필요한 조인으로 성능 저하 가능

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();
  • 실행되는 SQL
-- 1. Member를 조회하는 첫 번째 쿼리
SELECT * FROM member;

-- 2.MemberTeam을 조회하는 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()); // 지연 로딩 시점
}
  • 실행되는 SQL
-- 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();
    }
}

5. 추가 중요 특성

A. 캐싱

자주 사용되는 데이터를 메모리에 저장
데이터베이스 접근 횟수 감소
1차 캐시와 2차 캐시로 구분
@Cacheable 애노테이션으로 설정

B. Auditing

엔티티의 생성/수정 시간, 생성/수정자 정보 자동 관리
@CreatedDate, @LastModifiedDate
@CreatedBy, @LastModifiedBy
보안과 감사 추적에 유용

C. 낙관적 락킹

동시성 제어를 위한 메커니즘
@Version 애노테이션 사용
여러 트랜잭션이 동시에 같은 데이터를 수정하는 것을 방지
충돌 발생 시 예외 발생

성능 최적화를 위해 적절한 fetch 전략 선택
트랜잭션 범위와 격리 수준 적절히 설정
연관관계 매핑 시 양방향/단방향 신중히 선택
캐싱 전략 수립으로 성능 향상

profile
🌱

0개의 댓글