[Advanced-1] Spring Data Jpa

2026. 1. 6. 17:21·Spring/JPA

1. 예제 도메인 모델

1.1. 엔티티 설계

Member 엔티티

package study.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    // 생성자
    public Member(String username) {
        this(username, 0);
    }

    public Member(String username, int age) {
        this(username, age, null);
    }

    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }

    // 연관관계 편의 메서드
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

Team 엔티티

package study.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Team {

    @Id @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Team(String name) {
        this.name = name;
    }
}

1.2. 엔티티 설계 특징

롬복 애노테이션

  • @Setter: 실무에서는 가급적 사용 지양
  • @NoArgsConstructor(access = AccessLevel.PROTECTED): JPA 명세상 필요
  • @ToString: 내부 필드만 포함 (연관관계 필드 제외)

연관관계 설정

  • 양방향 연관관계: Member ↔ Team
  • 연관관계 주인: Member.team
  • Team.members: 읽기 전용

연관관계 편의 메서드

  • changeTeam(): 양방향 연관관계 일관성 유지

1.3. 엔티티 테스트

@SpringBootTest
@Transactional
public class MemberTest {

    @PersistenceContext
    EntityManager em;

    @Test
    @Rollback(false)
    public void testEntity() {
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);
        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        // 초기화
        em.flush();
        em.clear();

        // 확인
        List<Member> members = em.createQuery(
            "select m from Member m", Member.class)
            .getResultList();

        for (Member member : members) {
            System.out.println("member = " + member);
            System.out.println("-> member.team = " + member.getTeam());
        }
    }
}

테스트 포인트

  1. 엔티티 매핑 검증
  2. 연관관계 매핑 검증
  3. 지연 로딩 동작 확인

2. 공통 인터페이스 기능

2.1. 순수 JPA 기반 리포지토리

MemberJpaRepository

package study.datajpa.repository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import study.datajpa.entity.Member;

import java.util.List;
import java.util.Optional;

@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

    // 저장
    public Member save(Member member) {
        em.persist(member);
        return member;
    }

    // 삭제
    public void delete(Member member) {
        em.remove(member);
    }

    // 전체 조회
    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }

    // 단건 조회 (Optional)
    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
    }

    // 카운트
    public long count() {
        return em.createQuery("select count(m) from Member m", Long.class)
                .getSingleResult();
    }

    // 단건 조회
    public Member find(Long id) {
        return em.find(Member.class, id);
    }
}

TeamJpaRepository

package study.datajpa.repository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import study.datajpa.entity.Team;

import java.util.List;
import java.util.Optional;

@Repository
public class TeamJpaRepository {

    @PersistenceContext
    private EntityManager em;

    public Team save(Team team) {
        em.persist(team);
        return team;
    }

    public void delete(Team team) {
        em.remove(team);
    }

    public List<Team> findAll() {
        return em.createQuery("select t from Team t", Team.class)
                .getResultList();
    }

    public Optional<Team> findById(Long id) {
        Team team = em.find(Team.class, id);
        return Optional.ofNullable(team);
    }

    public long count() {
        return em.createQuery("select count(t) from Team t", Long.class)
                .getSingleResult();
    }
}

JPA 변경 감지 (Dirty Checking)

  • 수정은 별도의 메서드가 필요 없음
  • 트랜잭션 내에서 엔티티 조회 후 데이터 변경
  • 트랜잭션 커밋 시 자동으로 UPDATE SQL 실행

2.2. 순수 JPA 테스트

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired
    MemberJpaRepository memberJpaRepository;

    @Test
    void testMember() {
        Member member = new Member("memberA");
        Member savedMember = memberJpaRepository.save(member);
        Member findMember = memberJpaRepository.find(savedMember.getId());

        assertThat(findMember.getId()).isEqualTo(member.getId());
        assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
        assertThat(findMember).isEqualTo(member); // 동일성 보장
    }

    @Test
    void basicCRUD() {
        Member member1 = new Member("member1");
        Member member2 = new Member("member2");
        memberJpaRepository.save(member1);
        memberJpaRepository.save(member2);

        // 단건 조회 검증
        Member findMember1 = memberJpaRepository.findById(member1.getId()).get();
        Member findMember2 = memberJpaRepository.findById(member2.getId()).get();
        assertThat(findMember1).isEqualTo(member1);
        assertThat(findMember2).isEqualTo(member2);

        // 리스트 조회 검증
        List<Member> all = memberJpaRepository.findAll();
        assertThat(all.size()).isEqualTo(2);

        // 카운트 검증
        long count = memberJpaRepository.count();
        assertThat(count).isEqualTo(2);

        // 삭제 검증
        memberJpaRepository.delete(member1);
        memberJpaRepository.delete(member2);
        long deletedCount = memberJpaRepository.count();
        assertThat(deletedCount).isEqualTo(0);
    }
}

2.3. Spring Data JPA 설정

JavaConfig 설정 (스프링 부트 생략 가능)

@Configuration
@EnableJpaRepositories(basePackages = "study.datajpa.repository")
public class AppConfig {
}

스프링 부트 자동 설정

  • @SpringBootApplication 위치 기준 패키지 스캔
  • @Repository 생략 가능 (컴포넌트 스캔 자동 처리)
  • JPA 예외 → 스프링 예외 자동 변환

2.4. 공통 인터페이스 적용

MemberRepository 인터페이스

package study.datajpa.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.datajpa.entity.Member;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

TeamRepository 인터페이스

package study.datajpa.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.datajpa.entity.Team;

public interface TeamRepository extends JpaRepository<Team, Long> {
}

2.5. Spring Data JPA 테스트

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void testMember() {
        Member member = new Member("memberA");
        Member savedMember = memberRepository.save(member);
        Member findMember = memberRepository.findById(savedMember.getId()).get();

        assertThat(findMember.getId()).isEqualTo(member.getId());
        assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
        assertThat(findMember).isEqualTo(member);
    }

    @Test
    void basicCRUD() {
        Member member1 = new Member("member1");
        Member member2 = new Member("member2");
        memberRepository.save(member1);
        memberRepository.save(member2);

        // 단건 조회
        Member findMember1 = memberRepository.findById(member1.getId()).get();
        Member findMember2 = memberRepository.findById(member2.getId()).get();
        assertThat(findMember1).isEqualTo(member1);
        assertThat(findMember2).isEqualTo(member2);

        // 리스트 조회
        List<Member> all = memberRepository.findAll();
        assertThat(all.size()).isEqualTo(2);

        // 카운트
        long count = memberRepository.count();
        assertThat(count).isEqualTo(2);

        // 삭제
        memberRepository.delete(member1);
        memberRepository.delete(member2);
        long deletedCount = memberRepository.count();
        assertThat(deletedCount).isEqualTo(0);
    }
}

2.6. 공통 인터페이스 분석

인터페이스 계층 구조

Repository
├── CrudRepository
├── PagingAndSortingRepository
└── JpaRepository

JpaRepository 주요 메서드

public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID> {

    // 저장/수정
    <S extends T> S save(S entity);
    <S extends T> List<S> saveAll(Iterable<S> entities);

    // 조회
    Optional<T> findById(ID id);
    List<T> findAll();
    List<T> findAllById(Iterable<ID> ids);

    // 존재 여부
    boolean existsById(ID id);

    // 카운트
    long count();

    // 삭제
    void delete(T entity);
    void deleteAllById(Iterable<? extends ID> ids);
    void deleteAll(Iterable<? extends T> entities);
    void deleteAll();

    // 플러시
    void flush();
    <S extends T> S saveAndFlush(S entity);
    <S extends T> List<S> saveAllAndFlush(Iterable<S> entities);

    // 프록시 조회 (Deprecated → getReferenceById 사용)
    T getOne(ID id);  // Deprecated
    T getReferenceById(ID id);  // 신규

    // 모든 데이터 삭제 (in batch)
    void deleteAllInBatch(Iterable<T> entities);
    void deleteAllByIdInBatch(Iterable<ID> ids);
    void deleteAllInBatch();
}

메서드 변경사항

  • findOne(ID) → findById(ID) (Optional 반환)
  • exists(ID) → existsById(ID)
  • getOne(ID) → getReferenceById(ID) (Deprecated 처리)

제네릭 타입

  • T: 엔티티 타입
  • ID: 엔티티 식별자 타입
  • S: 엔티티 또는 그 하위 타입

메서드 동작 원리

  • save(): 새로운 엔티티 저장, 기존 엔티티 병합
  • delete(): EntityManager.remove() 호출
  • findById(): EntityManager.find() 호출
  • getReferenceById(): EntityManager.getReference() 호출 (프록시)
  • findAll(): JPQL 생성 실행

2.7. 구현체 분석

프록시 객체 확인

@Test
void checkProxy() {
    System.out.println("memberRepository.getClass() = " +
                      memberRepository.getClass());
    // 출력: class com.sun.proxy.$ProxyXXX
    // Spring Data JPA가 구현 클래스를 동적으로 생성
}

Spring Data JPA 동작 원리

  1. 애플리케이션 로딩 시점
  2. @EnableJpaRepositories에 지정된 패키지 스캔
  3. Repository 인터페이스 상속받은 인터페이스 발견
  4. 동적 프록시 객체 생성
  5. 메서드 호출 시 적절한 JPQL 생성 및 실행

2.8. 실무 적용 팁

리포지토리 인터페이스 설계

// 기본 CRUD는 JpaRepository 상속
public interface MemberRepository extends JpaRepository<Member, Long> {

    // 커스텀 메서드 선언
    List<Member> findByUsername(String username);

    // 쿼리 메서드
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);

    // @Query 어노테이션 사용
    @Query("select m from Member m where m.username = :username")
    Member findByCustomQuery(@Param("username") String username);
}

// 커스텀 리포지토리 인터페이스
public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}

// 구현체
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    @PersistenceContext
    private EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m")
                .getResultList();
    }
}

// 상속
public interface MemberRepository extends
    JpaRepository<Member, Long>, MemberRepositoryCustom {
}

트랜잭션 관리

  • 서비스 계층에서 @Transactional 관리 권장
  • 리포지토리 계층은 기본적으로 읽기 전용
  • @Modifying으로 수정 쿼리 실행 시 @Transactional 필요

성능 최적화

  • @QueryHints로 쿼리 힌트 추가 가능
  • @EntityGraph로 페치 조인 최적화
  • Projections으로 필요한 데이터만 선택적 조회

3. 쿼리 메서드 기능

스프링 데이터 JPA가 제공하는 쿼리 메서드 기능은 리포지토리 인터페이스에 메서드만 선언하면 해당 메서드의 이름으로 적절한 JPQL 쿼리를 생성하여 실행한다. 이 기능은 크게 세 가지로 구분할 수 있다.

  1. 메서드 이름으로 쿼리 생성
  2. 메소드 이름으로 JPA NamedQuery 호출
  3. @Query 어노테이션을 사용하여 리포지토리 인터페이스에 쿼리 직접 정의

3.1. 메서드 이름으로 쿼리 생성

메서드 이름을 분석하여 JPQL 쿼리를 생성하고 실행하는 기능이다. 예를 들어, 이름과 나이를 기준으로 회원을 조회하려는 경우를 살펴본다.

순수 JPA 리포지토리 구현

public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
    return em.createQuery("select m from Member m where m.username = :username and m.age > :age", Member.class)
            .setParameter("username", username)
            .setParameter("age", age)
            .getResultList();
}

순수 JPA 테스트 코드

@Test
public void findByUsernameAndAgeGreaterThan() {
    Member m1 = new Member("AAA", 10);
    Member m2 = new Member("AAA", 20);
    memberJpaRepository.save(m1);
    memberJpaRepository.save(m2);

    List<Member> result = memberJpaRepository.findByUsernameAndAgeGreaterThan("AAA", 15);

    assertThat(result.get(0).getUsername()).isEqualTo("AAA");
    assertThat(result.get(0).getAge()).isEqualTo(20);
    assertThat(result.size()).isEqualTo(1);
}

스프링 데이터 JPA 적용

인터페이스에 메서드만 선언하면 동일한 기능을 사용할 수 있다.

java

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}

스프링 데이터 JPA는 메서드 이름을 분석하여 JPQL을 생성하고 실행한다.

3.2. 쿼리 메서드 필터 조건

스프링 데이터 JPA가 제공하는 쿼리 메서드 기능의 키워드와 반환 타입을 정리한다.

  • 조회: find...By, read...By, query...By, get...By
    • 예: findHelloBy처럼 ...에 식별 내용이 들어가도 된다.
  • COUNT: count...By (반환 타입 long)
  • EXISTS: exists...By (반환 타입 boolean)
  • 삭제: delete...By, remove...By (반환 타입 long)
  • DISTINCT: findDistinct, findMemberDistinctBy
  • LIMIT: findFirst3, findFirst, findTop, findTop3

참고: 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 함께 변경해야 한다. 애플리케이션 로딩 시점에 오류가 발생하기 때문에 사전에 문제를 인지할 수 있는 것이 스프링 데이터 JPA의 큰 장점이다.

3.3. JPA NamedQuery

JPA의 NamedQuery를 호출할 수 있는 기능이다.

@NamedQuery 어노테이션으로 Named 쿼리 정의

@Entity
@NamedQuery(
    name = "Member.findByUsername",
    query = "select m from Member m where m.username = :username"
)
public class Member {
    ...
}

JPA를 직접 사용하여 Named 쿼리 호출

public class MemberRepository {
    public List<Member> findByUsername(String username) {
        List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
                .setParameter("username", username)
                .getResultList();
    }
}

스프링 데이터 JPA로 NamedQuery 사용

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query(name = "Member.findByUsername")
    List<Member> findByUsername(@Param("username") String username);
}

 @Query를 생략하고 메서드 이름만으로 Named 쿼리를 호출할 수도 있다. 이 경우 스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점) + 메서드 이름"으로 Named 쿼리를 찾아서 실행한다. 만약 실행할 Named 쿼리가 없으면 메서드 이름으로 쿼리 생성 전략을 사용한다.

참고) 실무에서는 Named Query를 직접 등록하여 사용하는 경우는 드물다. 대신 @Query를 사용하여 리포지토리 메서드에 쿼리를 직접 정의하는 방식을 주로 사용한다.

3.4. @Query, 리포지토리 메소드에 쿼리 정의하기

메서드에 JPQL 쿼리를 직접 작성하는 방식이다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m where m.username = :username and m.age = :age")
    List<Member> findUser(@Param("username") String username, @Param("age") int age);
}

@org.springframework.data.jpa.repository.Query 어노테이션을 사용하며, 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 Named 쿼리라고 할 수 있다. JPA Named 쿼리처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있어 큰 장점이 있다.

참고: 실무에서는 메서드 이름으로 쿼리 생성 기능은 파라미터가 증가하면 메서드 이름이 매우 지저분해지므로, @Query 기능을 자주 사용하게 된다.

3.5. @Query, 값, DTO 조회하기

단순히 값 하나를 조회하는 경우와 DTO로 직접 조회하는 방법을 살펴본다.

단순 값 조회

@Query("select m.username from Member m")
List<String> findUsernameList();

JPA 값 타입(@Embedded)도 이 방식으로 조회할 수 있다.

DTO 직접 조회

@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
       "from Member m join m.team t")
List<MemberDto> findMemberDto();

주의할 점은 DTO로 직접 조회하려면 JPA의 new 명령어를 사용해야 하며, 생성자가 맞는 DTO가 필요하다.

MemberDto 클래스

package study.datajpa.dto;
import lombok.Data;

@Data
public class MemberDto {
    private Long id;
    private String username;
    private String teamName;

    public MemberDto(Long id, String username, String teamName) {
        this.id = id;
        this.username = username;
        this.teamName = teamName;
    }
}

3.6. 파라미터 바인딩

파라미터 바인딩에는 위치 기반과 이름 기반이 있다. 코드 가독성과 유지보수를 위해 이름 기반 파라미터 바인딩을 사용하는 것이 권장된다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m where m.username = :name")
    Member findMembers(@Param("name") String username);
}

컬렉션 파라미터 바인딩

Collection 타입으로 in 절을 지원한다.

@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);

3.7. 반환 타입

스프링 데이터 JPA는 유연한 반환 타입을 지원한다.

List<Member> findByUsername(String name);         // 컬렉션
Member findByUsername(String name);               // 단건
Optional<Member> findByUsername(String name);     // 단건 Optional

반환 타입에 따른 동작 방식은 다음과 같다.

  • 컬렉션: 결과가 없으면 빈 컬렉션 반환
  • 단건 조회: 결과가 없으면 null 반환, 결과가 2건 이상이면 javax.persistence.NonUniqueResultException 예외 발생
참고) 단건으로 지정한 메서드를 호출하면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출한다. 이 메서드를 호출했을 때 조회 결과가 없으면 javax.persistence.NoResultException 예외가 발생하는데, 스프링 데이터 JPA는 이 예외를 무시하고 대신에 null을 반환한다.

3.8. 순수 JPA 페이징과 정렬

JPA에서 페이징을 구현하는 방법을 살펴본다. 예를 들어, 다음과 같은 조건으로 페이징과 정렬을 사용한다고 가정한다.

  • 검색 조건: 나이가 10살
  • 정렬 조건: 이름으로 내림차순
  • 페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건

JPA 페이징 리포지토리 코드

public List<Member> findByPage(int age, int offset, int limit) {
    return em.createQuery("select m from Member m where m.age = :age order by m.username desc", Member.class)
            .setParameter("age", age)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

public long totalCount(int age) {
    return em.createQuery("select count(m) from Member m where m.age = :age", Long.class)
            .setParameter("age", age)
            .getSingleResult();
}

JPA 페이징 테스트 코드

@Test
public void paging() {
    //given
    memberJpaRepository.save(new Member("member1", 10));
    memberJpaRepository.save(new Member("member2", 10));
    memberJpaRepository.save(new Member("member3", 10));
    memberJpaRepository.save(new Member("member4", 10));
    memberJpaRepository.save(new Member("member5", 10));

    int age = 10;
    int offset = 0;
    int limit = 3;

    //when
    List<Member> members = memberJpaRepository.findByPage(age, offset, limit);
    long totalCount = memberJpaRepository.totalCount(age);

    //then
    assertThat(members.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
}

3.9. 스프링 데이터 JPA 페이징과 정렬

스프링 데이터 JPA는 페이징과 정렬을 편리하게 사용할 수 있는 기능을 제공한다.

페이징과 정렬 파라미터

  • org.springframework.data.domain.Sort: 정렬 기능
  • org.springframework.data.domain.Pageable: 페이징 기능 (내부에 Sort 포함)

특별한 반환 타입

  • org.springframework.data.domain.Page: 추가 count 쿼리 결과를 포함하는 페이징
  • org.springframework.data.domain.Slice: 추가 count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1 조회)
  • List: 추가 count 쿼리 없이 결과만 반환

페이징과 정렬 사용 예제

Page<Member> findByUsername(String name, Pageable pageable);     // count 쿼리 사용
Slice<Member> findByUsername(String name, Pageable pageable);    // count 쿼리 사용 안함
List<Member> findByUsername(String name, Pageable pageable);     // count 쿼리 사용 안함
List<Member> findByUsername(String name, Sort sort);

이전과 동일한 조건(나이가 10살, 이름 내림차순 정렬, 첫 페이지 3건)으로 예제를 작성해본다.

Page 사용 예제 정의 코드

public interface MemberRepository extends Repository<Member, Long> {
    Page<Member> findByAge(int age, Pageable pageable);
}

Page 사용 예제 실행 코드

@Test
public void page() {
    //given
    memberRepository.save(new Member("member1", 10));
    memberRepository.save(new Member("member2", 10));
    memberRepository.save(new Member("member3", 10));
    memberRepository.save(new Member("member4", 10));
    memberRepository.save(new Member("member5", 10));

    //when
    PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
    Page<Member> page = memberRepository.findByAge(10, pageRequest);

    //then
    List<Member> content = page.getContent();          // 조회된 데이터
    assertThat(content.size()).isEqualTo(3);           // 조회된 데이터 수
    assertThat(page.getTotalElements()).isEqualTo(5);  // 전체 데이터 수
    assertThat(page.getNumber()).isEqualTo(0);         // 페이지 번호
    assertThat(page.getTotalPages()).isEqualTo(2);     // 전체 페이지 번호
    assertThat(page.isFirst()).isTrue();               // 첫번째 항목인가?
    assertThat(page.hasNext()).isTrue();               // 다음 페이지가 있는가?
}

 두 번째 파라미터로 받은 Pageable은 인터페이스이므로, 실제 사용할 때는 org.springframework.data.domain.PageRequest 객체를 사용한다. PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 추가로 정렬 정보도 파라미터로 사용할 수 있다. 페이지는 0부터 시작한다.

Page 인터페이스 주요 메서드

public interface Page<T> extends Slice<T> {
    int getTotalPages();        // 전체 페이지 수
    long getTotalElements();    // 전체 데이터 수
    <U> Page<U> map(Function<? super T, ? extends U> converter); // 변환기
}

Slice 인터페이스 주요 메서드

public interface Slice<T> extends Streamable<T> {
    int getNumber();                    // 현재 페이지
    int getSize();                      // 페이지 크기
    int getNumberOfElements();          // 현재 페이지에 나올 데이터 수
    List<T> getContent();               // 조회된 데이터
    boolean hasContent();               // 조회된 데이터 존재 여부
    Sort getSort();                     // 정렬 정보
    boolean isFirst();                  // 현재 페이지가 첫 페이지 인지 여부
    boolean isLast();                   // 현재 페이지가 마지막 페이지 인지 여부
    boolean hasNext();                  // 다음 페이지 여부
    boolean hasPrevious();              // 이전 페이지 여부
    Pageable getPageable();             // 페이지 요청 정보
    Pageable nextPageable();            // 다음 페이지 객체
    Pageable previousPageable();        // 이전 페이지 객체
    <U> Slice<U> map(Function<? super T, ? extends U> converter); // 변환기
}

count 쿼리 분리

복잡한 SQL에서 카운트 쿼리는 성능에 큰 영향을 미칠 수 있으므로 분리할 수 있다.

@Query(value = "select m from Member m",
       countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);

Top, First 사용

List<Member> findTop3By();

페이지를 유지하면서 엔티티를 DTO로 변환하기

Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());

실습 포인트

  • Page: count 쿼리 포함
  • Slice: count 쿼리 없음 (추가로 limit + 1을 조회하여 다음 페이지 여부 확인)
  • List: count 쿼리 없음
  • 카운트 쿼리 분리: 복잡한 SQL에서 성능 최적화

참고: 전체 count 쿼리는 매우 무겁기 때문에, 데이터가 많은 테이블에서는 카운트 쿼리 분리를 고려해야 한다.

스프링 부트 3 - 하이버네이트 6 left join 최적화 설명

스프링 부트 3 이상을 사용하면 하이버네이트 6이 적용된다. 하이버네이트 6에서는 의미없는 left join을 최적화한다.

@Query(value = "select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);

실행 결과 SQL:

select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0

하이버네이트 6은 select 절이나 where 절에서 Team을 사용하지 않는 경우 최적화를 통해 join 없이 SQL을 생성한다. 만약 Member와 Team을 하나의 SQL로 한번에 조회하고 싶다면 JPA가 제공하는 fetch join을 사용해야 한다.

select m from Member m left join fetch m.team t

이 경우 SQL에서 join문이 정상 수행된다.

3.10. 벌크성 수정 쿼리 (⭐⭐)

한번에 여러 데이터를 수정하는 벌크성 수정 쿼리를 살펴본다.

JPA를 사용한 벌크성 수정 쿼리

public int bulkAgePlus(int age) {
    int resultCount = em.createQuery(
            "update Member m set m.age = m.age + 1 " +
            "where m.age >= :age")
            .setParameter("age", age)
            .executeUpdate();
    return resultCount;
}

JPA를 사용한 벌크성 수정 쿼리 테스트

@Test
public void bulkUpdate() {
    //given
    memberJpaRepository.save(new Member("member1", 10));
    memberJpaRepository.save(new Member("member2", 19));
    memberJpaRepository.save(new Member("member3", 20));
    memberJpaRepository.save(new Member("member4", 21));
    memberJpaRepository.save(new Member("member5", 40));

    //when
    int resultCount = memberJpaRepository.bulkAgePlus(20);

    //then
    assertThat(resultCount).isEqualTo(3);
}

스프링 데이터 JPA를 사용한 벌크성 수정 쿼리 (@Modifying 사용 ⭐)

@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

스프링 데이터 JPA를 사용한 벌크성 수정 쿼리 테스트

@Test
public void bulkUpdate() {
    //given
    memberRepository.save(new Member("member1", 10));
    memberRepository.save(new Member("member2", 19));
    memberRepository.save(new Member("member3", 20));
    memberRepository.save(new Member("member4", 21));
    memberRepository.save(new Member("member5", 40));

    //when
    int resultCount = memberRepository.bulkAgePlus(20);

    //then
    assertThat(resultCount).isEqualTo(3);
}

벌크성 수정, 삭제 쿼리는 @Modifying 어노테이션을 사용해야 한다. 사용하지 않으면 QueryExecutionRequestException: Not supported for DML operations 예외가 발생한다.

 

벌크성 쿼리 실행 후 영속성 컨텍스트 초기화

@Modifying(clearAutomatically = true) 옵션을 사용하면 벌크 연산 실행 후 영속성 컨텍스트를 초기화할 수 있다. (기본값은 false) 이 옵션이 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아있어 문제가 발생할 수 있다. 따라서 다시 조회해야 하는 경우 영속성 컨텍스트를 초기화해야 한다.

참고) 벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.

 

권장하는 방안

  1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.
  2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화한다.

3.11. @EntityGraph

연관된 엔티티들을 SQL 한번에 조회하는 방법을 제공한다. 페치 조인(FETCH JOIN)의 간편 버전으로 볼 수 있다. member와 team은 지연로딩 관계이므로, team의 데이터를 조회할 때마다 쿼리가 실행된다. (N+1 문제 발생)

N+1 문제 예시

@Test
public void findMemberLazy() {
    //given
    Team teamA = new Team("teamA");
    Team teamB = new Team("teamB");
    teamRepository.save(teamA);
    teamRepository.save(teamB);

    memberRepository.save(new Member("member1", 10, teamA));
    memberRepository.save(new Member("member2", 20, teamB));

    em.flush();
    em.clear();

    //when
    List<Member> members = memberRepository.findAll();

    //then
    for (Member member : members) {
        member.getTeam().getName();
    }
}

지연 로딩 여부 확인 방법:

// Hibernate 기능으로 확인
Hibernate.isInitialized(member.getTeam())

// JPA 표준 방법으로 확인
PersistenceUnitUtil util = em.getEntityManagerFactory().getPersistenceUnitUtil();
util.isLoaded(member.getTeam());

연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.

JPQL 페치 조인

@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();

스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 편리하게 사용할 수 있게 도와준다. 이를 사용하면 JPQL 없이 페치 조인을 사용할 수 있다.

@EntityGraph 사용

// 공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

// JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

// 메서드 이름으로 쿼리에서 사용
@EntityGraph(attributePaths = {"team"})
List<Member> findByUsername(String username);

@EntityGraph 정리

  • 사실상 페치 조인(FETCH JOIN)의 간편 버전
  • LEFT OUTER JOIN 사용

NamedEntityGraph 사용 방법

@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
@Entity
public class Member {
    ...
}

 

@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

3.12. JPA Hint & Lock

JPA 쿼리 힌트와 락 기능을 살펴본다.

쿼리 힌트 사용

JPA 구현체(하이버네이트)에게 제공하는 힌트로, SQL 힌트가 아니다.

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);

쿼리 힌트 사용 확인

@Test
public void queryHint() {
    //given
    memberRepository.save(new Member("member1", 10));
    em.flush();
    em.clear();

    //when
    Member member = memberRepository.findReadOnlyByUsername("member1");
    member.setUsername("member2");
    em.flush(); // Update Query 실행되지 않음
}

쿼리 힌트 Page 추가 예제

@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly", value = "true")},
            forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);

org.springframework.data.jpa.repository.QueryHints 어노테이션을 사용한다. forCounting은 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리에도 쿼리 힌트를 적용한다. (기본값 true)

Lock

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);

org.springframework.data.jpa.repository.Lock 어노테이션을 사용한다. JPA가 제공하는 락에 대한 자세한 내용은 아래 포스팅을 참고하자.

 

[Lock-8][Optimization] 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock) 구현 및 비교

1. 들어가며 데이터베이스 동시성 문제를 해결하는 대표적인 전략으로 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)이 존재한다. 두 방식의 핵심적인 차이는 충돌을 사전에 방지할 것인지(

receiver40.tistory.com

 


4. 확장 기능

스프링 데이터 JPA의 다양한 확장 기능을 살펴본다.

4.1. 사용자 정의 리포지토리 구현

스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동 생성한다. 하지만 다양한 이유로 인터페이스의 메서드를 직접 구현하고 싶은 경우가 있다.

  • JPA 직접 사용(EntityManager)
  • 스프링 JDBC Template 사용
  • MyBatis 사용
  • 데이터베이스 커넥션 직접 사용
  • Querydsl 사용

사용자 정의 인터페이스

public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}

사용자 정의 인터페이스 구현 클래스

@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
    private final EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }
}

사용자 정의 인터페이스 상속

public interface MemberRepository
        extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}

사용자 정의 메서드 호출 코드

List<Member> result = memberRepository.findMemberCustom();

사용자 정의 구현 클래스 규칙

  • 리포지토리 인터페이스 이름 + Impl
  • 스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

Impl 대신 다른 이름으로 변경하고 싶으면 다음과 같이 설정할 수 있다.

XML 설정

<repositories base-package="study.datajpa.repository"
              repository-impl-postfix="Impl" />

JavaConfig 설정

@EnableJpaRepositories(basePackages = "study.datajpa.repository",
                       repositoryImplementationPostfix = "Impl")

참고: 실무에서는 주로 QueryDSL이나 SpringJdbcTemplate을 함께 사용할 때 사용자 정의 리포지토리 기능을 자주 사용한다. 항상 사용자 정의 리포지토리가 필요한 것은 아니며, 임의의 리포지토리를 만들어도 된다. 예를 들어 MemberQueryRepository를 인터페이스가 아닌 클래스로 만들고 스프링 빈으로 등록해서 직접 사용해도 된다. 이 경우 스프링 데이터 JPA와는 아무런 관계 없이 별도로 동작한다.

 

사용자 정의 리포지토리 구현 최신 방식

스프링 데이터 2.x부터는 사용자 정의 구현 클래스에 리포지토리 인터페이스 이름 + Impl을 적용하는 대신에, 사용자 정의 인터페이스 명 + Impl 방식도 지원한다. 예를 들어 MemberRepositoryImpl 대신 MemberRepositoryCustomImpl으로 구현해도 된다.

최신 사용자 정의 인터페이스 구현 클래스 예제

@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }
}

이 방식이 사용자 정의 인터페이스 이름과 구현 클래스 이름이 비슷하므로 더 직관적이다. 추가로 여러 인터페이스를 분리해서 구현하는 것도 가능하기 때문에 이 방식을 더 권장한다.

4.2. Auditing

엔티티를 생성, 변경할 때 변경한 사람과 시간을 추적하고 싶은 경우가 있다.

  • 등록일
  • 수정일
  • 등록자
  • 수정자

순수 JPA 사용 - 등록일, 수정일 적용

@MappedSuperclass
@Getter
public class JpaBaseEntity {

    @Column(updatable = false)
    private LocalDateTime createdDate;
    private LocalDateTime updatedDate;

    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        createdDate = now;
        updatedDate = now;
    }

    @PreUpdate
    public void preUpdate() {
        updatedDate = LocalDateTime.now();
    }
}

 

public class Member extends JpaBaseEntity {
    ...
}

확인 코드

@Test
public void jpaEventBaseEntity() {
    //given
    Member member = new Member("member1");
    memberRepository.save(member); // @PrePersist

    Thread.sleep(100);
    member.setUsername("member2");
    em.flush(); // @PreUpdate
    em.clear();

    //when
    Member findMember = memberRepository.findById(member.getId()).get();

    //then
    System.out.println("findMember.createdDate = " + findMember.getCreatedDate());
    System.out.println("findMember.updatedDate = " + findMember.getUpdatedDate());
}

JPA 주요 이벤트 어노테이션

  • @PrePersist, @PostPersist
  • @PreUpdate, @PostUpdate

스프링 데이터 JPA 사용

설정

  • @EnableJpaAuditing: 스프링 부트 설정 클래스에 적용해야 함
  • @EntityListeners(AuditingEntityListener.class): 엔티티에 적용

사용 어노테이션

  • @CreatedDate
  • @LastModifiedDate
  • @CreatedBy
  • @LastModifiedBy

스프링 데이터 Auditing 적용 - 등록일, 수정일

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

스프링 데이터 Auditing 적용 - 등록자, 수정자

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String lastModifiedBy;
}

등록자, 수정자를 처리해주는 AuditorAware 스프링 빈 등록

@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataJpaApplication.class, args);
    }

    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.of(UUID.randomUUID().toString());
    }
}

주의: DataJpaApplication에 @EnableJpaAuditing도 함께 등록해야 한다.. 실무에서는 세션 정보나 스프링 시큐리티 로그인 정보에서 ID를 받는다.

참고) 실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만, 등록자, 수정자는 없을 수도 있다. 따라서 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속한다.
public class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

public class BaseEntity extends BaseTimeEntity {

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String lastModifiedBy;
}

참고: 저장시점에 등록일, 등록자는 물론이고, 수정일, 수정자도 같은 데이터가 저장된다. 데이터가 중복 저장되는 것 같지만, 이렇게 해두면 변경 컬럼만 확인해도 마지막에 업데이트한 유저를 확인할 수 있어 유지보수 관점에서 편리하다. 저장시점에 저장데이터만 입력하고 싶으면 @EnableJpaAuditing(modifyOnCreate = false) 옵션을 사용하면 된다.

4.3. Web 확장 - 도메인 클래스 컨버터

HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩해주는 기능이다.

도메인 클래스 컨버터 사용 전

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String findMember(@PathVariable("id") Long id) {
        Member member = memberRepository.findById(id).get();
        return member.getUsername();
    }
}

도메인 클래스 컨버터 사용 후

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String findMember(@PathVariable("id") Member member) {
        return member.getUsername();
    }
}

HTTP 요청은 회원 id를 받지만, 도메인 클래스 컨버터가 중간에 동작해서 회원 엔티티 객체를 반환한다. 도메인 클래스 컨버터도 리포지토리를 사용해서 엔티티를 찾는다.

주의) 도메인 클래스 컨버터로 엔티티를 파라미터로 받으면, 이 엔티티는 단순 조회용으로만 사용해야 한다. 트랜잭션이 없는 범위에서 엔티티를 조회했으므로, 엔티티를 변경해도 DB에 반영되지 않는다.

4.4. Web 확장 - 페이징과 정렬

스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.

페이징과 정렬 예제

@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
    Page<Member> page = memberRepository.findAll(pageable);
    return page;
}

파라미터로 Pageable을 받을 수 있다. Pageable은 인터페이스이며, 실제는 org.springframework.data.domain.PageRequest 객체가 생성된다.

요청 파라미터 예시

/members?page=0&size=3&sort=id,desc&sort=username,desc
  • page: 현재 페이지, 0부터 시작
  • size: 한 페이지에 노출할 데이터 건수
  • sort: 정렬 조건 정의 (예: 정렬 속성, 정렬 속성...(ASC | DESC), 정렬 방향 변경 시 sort 파라미터 추가 (asc 생략 가능))

기본값 설정

글로벌 설정 (스프링 부트):

spring.data.web.pageable.default-page-size=20 # 기본 페이지 사이즈
spring.data.web.pageable.max-page-size=2000   # 최대 페이지 사이즈

개별 설정

@PageableDefault 어노테이션 사용

@RequestMapping(value = "/members_page", method = RequestMethod.GET)
public String list(@PageableDefault(size = 12, sort = "username",
                                    direction = Sort.Direction.DESC) Pageable pageable) {
    ...
}

접두사

페이징 정보가 둘 이상이면 접두사로 구분한다. @Qualifier에 접두사명 추가 ({접두사명}_xxx)

예제: /members?member_page=0&order_page=1

public String list(
        @Qualifier("member") Pageable memberPageable,
        @Qualifier("order") Pageable orderPageable, ...) {
    ...
}

Page 내용을 DTO로 변환하기

엔티티를 API로 노출하면 다양한 문제가 발생하므로, 엔티티를 꼭 DTO로 변환해서 반환해야 한다. Page는 map()을 지원해서 내부 데이터를 다른 것으로 변경할 수 있다.

Member DTO

@Data
public class MemberDto {
    private Long id;
    private String username;

    public MemberDto(Member m) {
        this.id = m.getId();
        this.username = m.getUsername();
    }
}

Page.map() 사용

@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
    Page<Member> page = memberRepository.findAll(pageable);
    Page<MemberDto> pageDto = page.map(MemberDto::new);
    return pageDto;
}

Page.map() 코드 최적화

@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::new);
}

Page를 1부터 시작하기

스프링 데이터는 Page를 0부터 시작한다. 만약 1부터 시작하려면 다음과 같은 방법을 사용한다.

  1. Pageable, Page를 파라미터와 응답 값으로 사용하지 않고, 직접 클래스를 만들어서 처리한다. 그리고 직접 PageRequest(Pageable 구현체)를 생성해서 리포지토리에 넘긴다. 응답값도 Page 대신 직접 만들어서 제공해야 한다.
  2. spring.data.web.pageable.one-indexed-parameters를 true로 설정한다. 이 방법은 web에서 page 파라미터를 -1 처리할 뿐이다. 따라서 응답값인 Page에는 모두 0 페이지 인덱스를 사용하는 한계가 있다.

one-indexed-parameters 설정 시 Page 1요청

<http://localhost:8080/members?page=1>

응답(json):

{
    "content": [
        ...
    ],
    "pageable": {
        "offset": 0,
        "pageSize": 10,
        "pageNumber": 0  // 0 인덱스
    },
    "number": 0,  // 0 인덱스
    "empty": false
}

5. 스프링 데이터 JPA 분석

5.1. 스프링 데이터 JPA 구현체 분석

스프링 데이터 JPA가 제공하는 공통 인터페이스의 구현체는  org.springframework.data.jpa.repository.support.SimpleJpaRepository이다.

SimpleJpaRepository 주요 부분

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> ... {

    @Transactional
    public <S extends T> S save(S entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }
    ...
}

특징 분석

  • @Repository 적용: JPA 예외를 스프링이 추상화한 예외로 변환
  • @Transactional 트랜잭션 적용
    • JPA의 모든 변경은 트랜잭션 안에서 동작
    • 스프링 데이터 JPA는 변경(등록, 수정, 삭제) 메서드를 트랜잭션 처리
    • 서비스 계층에서 트랜잭션을 시작하지 않으면 리포지토리에서 트랜잭션 시작
    • 서비스 계층에서 트랜잭션을 시작하면 리포지토리는 해당 트랜잭션을 전파 받아서 사용
    • 따라서 스프링 데이터 JPA를 사용할 때 트랜잭션이 없어도 데이터 등록, 변경이 가능했음
  • @Transactional(readOnly = true)
    • 데이터를 단순히 조회만 하고 변경하지 않는 트랜잭션에서 readOnly = true 옵션 사용
    • 플러시를 생략해서 약간의 성능 향상
    • 자세한 내용은 JPA 책 "읽기 전용 쿼리의 성능 최적화" 참고

매우 중요: save() 메서드

  • 새로운 엔티티면 저장(persist)
  • 새로운 엔티티가 아니면 병합(merge)

5.2. 새로운 엔티티를 구별하는 방법

새로운 엔티티를 판단하는 기본 전략

  • 식별자가 객체일 때 null로 판단
  • 식별자가 자바 기본 타입일 때 0으로 판단
  • Persistable 인터페이스를 구현해서 판단 로직 변경 가능

Persistable 인터페이스

package org.springframework.data.domain;

public interface Persistable<ID> {
    ID getId();
    boolean isNew();
}

참고: JPA 식별자 생성 전략이 @GenerateValue면 save() 호출 시점에 식별자가 없으므로 새로운 엔티티로 인식해서 정상 동작한다. 그러나 JPA 식별자 생성 전략이 @Id만 사용해서 직접 할당이면 이미 식별자 값이 있는 상태로 save()를 호출한다. 따라서 이 경우 merge()가 호출된다. merge()는 우선 DB를 호출해서 값을 확인하고, DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율적이다. 따라서 Persistable을 사용해서 새로운 엔티티 확인 여부를 직접 구현하는 것이 효과적이다. 등록시간(@CreatedDate)을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다. (@CreatedDate에 값이 없으면 새로운 엔티티로 판단)

Persistable 구현 예제

@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {

    @Id
    private String id;

    @CreatedDate
    private LocalDateTime createdDate;

    public Item(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}

6. 실무 적용 팁 (정리)

6.1. 리포지토리 인터페이스 설계

// 기본 CRUD는 JpaRepository 상속
public interface MemberRepository extends JpaRepository<Member, Long> {

    // 커스텀 메서드 선언
    List<Member> findByUsername(String username);

    // 쿼리 메서드
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);

    // @Query 어노테이션 사용
    @Query("select m from Member m where m.username = :username")
    Member findByCustomQuery(@Param("username") String username);
}

6.2. 커스텀 리포지토리 패턴

// 커스텀 리포지토리 인터페이스
public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}

// 구현체
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    private final EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }
}

// 상속
public interface MemberRepository extends
        JpaRepository<Member, Long>,
        MemberRepositoryCustom {
}

6.3. 트랜잭션 관리

  • 서비스 계층에서 @Transactional 관리 권장
  • 리포지토리 계층은 기본적으로 읽기 전용
  • @Modifying으로 수정 쿼리 실행 시 @Transactional 필요

6.4. 성능 최적화

  • @QueryHints로 쿼리 힌트 추가 가능
  • @EntityGraph로 페치 조인 최적화
  • Projections으로 필요한 데이터만 선택적 조회

6.5. N+1 문제 해결

  • @EntityGraph 사용
  • 페치 조인 적절히 활용
  • 배치 사이즈 조정 고려

6.6. Auditing 활용

  • 등록일, 수정일은 거의 모든 엔티티에 필요
  • 등록자, 수정자는 필요에 따라 선택
  • BaseTimeEntity와 BaseEntity 분리 권장

6.7. 도메인 이벤트 발행

  • AbstractAggregateRoot 상속하여 도메인 이벤트 발행 가능
  • 복잡한 비즈니스 로직 처리 시 유용

6.8. Specification vs Querydsl

  • 복잡한 동적 쿼리는 Querydsl 사용 권장
  • Specification은 JPA Criteria API 기반으로 가독성이 낮음

6.9. 프로젝션 활용

  • 인터페이스 기반 프로젝션
  • 클래스 기반 프로젝션(DTO)
  • 동적 프로젝션

6.10. Native Query 사용 시 주의사항

  • 가급적 JPQL 사용
  • Native Query는 최후의 수단으로 사용
  • 객체 매핑이 어려움
  • DB 종속적 쿼리 발생

6.11. 복합 키 처리

  • @IdClass 또는 @EmbeddedId 사용
  • 가능하면 단일 키 사용 권장

6.12. 영속성 컨텍스트 관리

  • 벌크 연산 후 영속성 컨텍스트 초기화
  • @Modifying(clearAutomatically = true) 활용

6.13. Lock 전략

  • 낙관적 락: @Version 사용
  • 비관적 락: 필요시 사용, 데드락 주의

6.14. 성능 모니터링

  • spring.jpa.properties.hibernate.generate_statistics로 통계 확인
  • 적절한 인덱스 설계
  • 쿼리 최적화

이러한 팁들을 적절히 활용하면 스프링 데이터 JPA를 효과적으로 사용할 수 있으며, 생산성과 유지보수성을 크게 향상시킬 수 있다.

'Spring > JPA' 카테고리의 다른 글

[Advanced-3] QueryDSL(2): 문법  (1) 2026.01.07
[Advanced-2] QueryDSL(1): 기본  (0) 2026.01.06
[Basic-9] 객체지향 쿼리 언어(JPQL)  (0) 2026.01.06
[Basic-8] 값 타입  (0) 2026.01.06
[Basic-7] 프록시와 연관관계 관리  (0) 2026.01.06
'Spring/JPA' 카테고리의 다른 글
  • [Advanced-3] QueryDSL(2): 문법
  • [Advanced-2] QueryDSL(1): 기본
  • [Basic-9] 객체지향 쿼리 언어(JPQL)
  • [Basic-8] 값 타입
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Advanced-1] Spring Data Jpa
상단으로

티스토리툴바