Lined Notebook

[Spring Data JPA] 34. JPA 예제도메인모델과 공통인터페이스 기능

by ymkim

01. 예제 도메인 모델과 동작 확인

스프링 데이터 JPA는 기본적으로 JPA를 사용하기 위한 프레임워크라 생각 하면 된다. 이번 시간에는 간단한 Member(N), Team(1) 도메인 모델을 설계하는 시간을 가져보자.

01-1. Member 엔티티 수정

// AS-IS : 엔티티 변경 전
@Entity
@Getter @Setter
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    private String userName;

    protected Member() {
    }

    public Member(String userName) {
        this.userName = userName;
    }
}
  • id, userName 필드를 갖는 간단한 Member 클래스
  • protected 생성자의 경우 JPA 기본 스펙상 열어두어야 한다
  • 위와 같은 Member 엔티티를 아래와 같이 변경 하였다
// TO-BE : 엔티티 변경 후
@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;

    // Team과의 연관관계 설정
    // Member(N) : Team(1)
    // xToOne 의 경우 기본값이 EAGER이기에, LAZY(지연 로딩) 설정이 필요하다
    // : EAGER의 경우 성능이 안 나올수 있음
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="team_id")
    private Team team;

    // protected Member() {
    // }

    public Member(String userName) {
        this.userName = userName;
    }

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

    // 연관관계 편의 메서드 생성
    // : Team과의 연관관계를 편의 메서드를 통해 설정
    // : 양방향 연관관계 시 사용
    public void changeTeam(Team team) {
        this.team = team;
        team.getMember().add(this);
    }
}

keyword : @ManyToOne, @JoinColumn, changeTeam() 편의 메서드

  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 인자가 없는 생성자를 만들겠다는 의미
    • 기본 생성자를 막고 싶은 경우, JPA 스펙상 Level을 PROTECTED 설정해야함
  • @ToString
    • 객체가 가지고 있는 정보나 값들을 문자열로 리턴하는 메서드
    • ToString은 가급적이면 내부 필드에만(연관관계 없는 필드) 사용하는게 좋다
  • changeTeam()
    • 양방향 연관관계 설정 시 연관관계를 한번에 처리하기 위한 편의 메서드
  • @JoinColumm(name = “team_id”)
    • 에 속하는 Team의 기본키(PK)를 지정한다
    • 여기서 team_id가 Member 입장에서는 외래키(FK)가 된다

위에서 Member 엔티티를 수정하고, 해당 엔티티의 변경 사항에 대해 간략히 알아 보았다. 이번에는 Member와 연관관계 설정을 해야 하는 Team 엔티티를 생성 해보자.

01-2. Team 엔티티 생성

@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; // 팀명(예제니까, name으로)

    // 연관관계의 주인은 외래키가 있는 쪽에 설정
    // 참고 : <https://steady-coding.tistory.com/529>
    @OneToMany(mappedBy = "team")
    private List members = new ArrayList();

    public Team(String name) {
        this.name = name;
    }
}
  • @OneToMany(mappedBy = “team”)
    • Team(1) : Member(N) 연관관계 설정을 위해 위 어노테이션을 사용 한다
    • mappedBy는 연관관계의 주인을 설정할 때 사용이 된다(양방향 참조시 사용)

01-3. mappedBy 왜 사용하는가?

mappedBy를 외래키가 있는 쪽에 지정하라는 이유는 다음과 같은데, 우선 패러다임의 불일치 때문이다. DB 테이블을 외래 키 하나로 두 테이블의 연관관계를 관리 하지만 객체는 두 객체의 연관관계를 관리하는 것이 2곳이다. 따라서 양방향 연관관계 설정 시 객체 참조가 2인 것에 반해 외래키는 하나라는 패러다임 불일치가 발생한다.

01-4. 양방향 매핑의 규칙: 연관관계의 주인

규칙

  • 연관관계의 주인만 외래키를 관리(등록, 수정, 삭제) 할 수 있다
    • 주인은 mappedBy 속성을 사용하지 않는다
  • 연관관계의 주인이 아닌 쪽은 읽기만 할 수 있다
    • 주인이 아닌 경우 mappedBy 속성을 사용, 연관관계의 주인을 지정

01-5. Member 엔티티 연관관계 설정 테스트 코드 작성

@SpringBootTest
public class MemberTest {

    @PersistenceContext
    EntityManager em;

    @Test
    @Transactional
    @Rollback(false)
    public void testEntity() {
        // 1. team 생성
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("ymkim", 29, teamA);
        Member member2 = new Member("youngsik", 20, teamA);
        Member member3 = new Member("sh", 30, teamB);
        Member member4 = new Member("mina", 31, teamB);
        em.persiste(member1);
        em.persiste(member2);
        em.persiste(member3);
        em.persiste(member4);

        // 초기화 : 1차 캐시(초기화), 영속성 컨텍스트 // 쓰기 지연 저장소에 존재하는 모든 쿼리 요청
        em.flush();
        em.clear();

        List<Member> members = em.createQuery("select m from Member m", Member.class);
        for (Member member : members) {
            System.out.println("member=" + member);
            System.out.println("-> member.team=" + member.getTeam());
        }
    }
}
  • Member(1) : Team(N)의 연관관계 설정을 확인하기 위해 간단한 테스트 코드 작성
  • 결과는 따로 기재 안함
    • 여기서 중요한 부분은 Member가 Fetch.LAZY로 설정이 되어 있다는 점
    • member.getTeam() 메서드 호출 시 지연 로딩 발생, 실 객체 조회

02. 공통 인터페이스 기능

스프링 데이터 JPA는 결국 JPA를 알아야 사용할 수 있는 기술 순수 JPA -> 스프링 데이터 JPA 순으로 진행이 된다.

  1. 순수 JPA 기반 리포지토리 생성
  2. 스프링 JPA 공통 인터페이스 소개
  3. 스프링 데이터 JPA 공통 인터페이스 활용

02-1. 순수 JPA 기반 리포지토리 생성

  • 순수한 JPA 가지고 리포지토리를 생성한다
  • 기본 CRUD
    • 저장
    • 변경 -> 변경 감지 사용
    • 삭제
    • 전체 조회
    • 단건 조회
    • 카운트

참고 : JPA에서 수정은 변경감지 기능을 사용하면 된다.트랜잭션 안에서 엔티티를 조회한 다음에 데이터를 변경하면, 트랜잭션 종료 시점에 변경감지 기능이 작동해서 변경된 엔티티를 감지하고 UPDATE SQL을 실행한다.

02-2. MemberJpaRepository 수정

@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() {
        // em.findAll(); -> 순수 JPA 만으로는 불가능하다, JPQL을 사용해서 값을 출력해야 함
        return em.createQuery("select m from Member m", Member.class).getResultList();
    }

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

    // 카운팅
    public long count() {
        // 단건인 경우에는 getSingleResult()를 사용하면 된다
        return em.createQuery("select count(m) from Member m", Long.class).getSingleResult();
    }

    public Member find(Long id) {
        return em.find(Member.class, id);
    }
}

위와 같은 코드를 짜는 이유는 순수 JPA 기반으로 CRUD를 작성하고 후에 스프링 데이터 JPA를 사용하여 반복적이고, 중복되는 코드가 사라지는 부분을 확인하기 위함이다.

  • 기본적인 CRUD 기능을 하는 메서드 작성
  • 수정의 경우 변경 감지를 통해 수정이 가능하기에 따로 추가 안함
  • 다음은 TeamRepository를 작성 한다

참고 : 하나의 트랜잭션(Transaction) 단위 내에서 엔티티를 수정하게 되면 dirty checking(변경 감지) 가 발생하여, 해당 엔티티의 수정이 가능하다. (JPA 정석 수정 방식)

@Repository
public class TeamRepository {

    @PersistenceContext
    private EntityManager em; // JPA EntityManger

    // 저장
    public Team saveTeam(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(int id) {
        Team team = em.find(Team.class, id);
        return Optional.ofNullable(team);
    }

    // 카운팅
    public long count() {
        return em.createQuery("select t from Team t", Long.class).getSingleResult();
    }
}
  • 위에서 작성한 MemberJpaRepository와 별반 다를게 없음 (물론 예제)
    • 여기서 말하고자 하는 것은 별반 다르지 않은 로직(중복)이 존재한다는 점
@SpringBootTest
@Transactional
@DisplayName("순수 JPA 기반 테스트 진행")
class MemberJpaRepositoryTest {

    @Autowired MemberJpaRepository memberJpaRepository;

    @Test
    @DisplayName("맴버 저장 후 조회 테스트")
    public void testMember() throws Exception {
        //given
        Member member = new Member("memberA");

        //when
        Member savedMember = memberJpaRepository.save(member);
        Member findMember = memberJpaRepository.find(savedMember.getId());

        //then
        assertThat(findMember.getId()).isEqualTo(member.getId());
        assertThat(findMember.getUserName()).isEqualTo(member.getUserName());
        assertThat(findMember).isEqualTo(member); // 동일한 하나의 Transaction 단위에서 JPA는 동일한 객체 반환을 보장 한다, 1차 캐시
    }

    @Test
    @Rollback(false)
    @DisplayName("기본 CRUD 기능 테스트")
    public void basicCRUD() throws Exception {
        //given
        Member member1 = new Member("member1");
        Member member2 = new Member("member2");

        //when
        Member savedMember1 = memberJpaRepository.save(member1);
        Member savedMember2 = memberJpaRepository.save(member2);
        Member findMember1 = memberJpaRepository.findById(savedMember1.getId()).get();
        Member findMember2 = memberJpaRepository.findById(savedMember2.getId()).get();

        //then
        assertThat(findMember1).isEqualTo(member1);
        assertThat(findMember2).isEqualTo(member2);

        //HINT : 하나의 Transaction 단위 내에서 엔티티 수정 시 dirty checking(변경 감지)가 발생한다
        findMember1.setUserName("member!!!!!!!!");

        //리스트 조회 검증
        List<Member> all = memberJpaRepository.findAll();
        assertThat(all.size()).isEqualTo(2); // 위에서 등록한 Member는 2명, size = 2 (success)

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

        //삭제 검증
        memberJpaRepository.delete(member1);
        memberJpaRepository.delete(member2);

        long deletedCount = memberJpaRepository.count();
        assertThat(deletedCount).isEqualTo(0);
    }
}
  • 기존 MemberJpaRepositoryTest에 두번째 테스트 코드 추가
  • 기본적인 CRUD를 테스트 하기 위한 테스트 코드로, 등록 -> 조회 -> 카운트 -> 삭제 순
  • 다음 시간에는 이러한 부분(중복)들을 스프링 데이터 JPA 인터페이스로 처리하는 실습 진행

03. 공통 인터페이스 설정

package study.datajpa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
//@EnableJpaRepositories(basePackages = "study.datajpa.repository")
public class DataJpaApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataJpaApplication.class, args);
	}
}
  • @EnableJpaRepositories(basePackages = “study.datajpa.repository”)
    • 스프링 부트를 사용하면 해당 어노테이션을 사용하지 않아도 된다
    • 만약 다른 패키지 구조를 가지고 있는 경우 위와 같이 지정할 수 있다

위 사진은 스프링 데이터 JPA가 애플리케이션 로딩 시점에 JPA 관련 인터페이스를 구현한 클래스에 구현체를 생성하는 그림이다. 자세한 부분은 아래에서 설명 한다.

03-1. MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {

}

MemberRepository 인터페이스를 보면 해당 인터페이스를 제외하고는 구현부가 따로 존재하지 않는다. 그렇다면 이전에 작성한 MemberJPARepository와는 다르게 구현체(Body)가 없는데 어떻게 코드가 동작하는지에 대해 알아보자.

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired MemberRepository memberRepository;

    @Test
    @Rollback(false)
    public void test() throws Exception {
        // 추가한 부분, memberRepository가 어떤 객체인지 확인
        System.out.println("TEST = memberRepository = " + memberRepository.getClass());

        //given
        Member member = new Member("memberA");

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

        //then
        assertThat(findMember.getId()).isEqualTo(member.getId());
        assertThat(findMember.getUserName()).isEqualTo(member.getUserName());
        assertThat(findMember).isEqualTo(member);
    }
}

위 소스는 MemberRepository를 테스트하기 위해 작성한 테스트 코드다. 여기서 우리는 memberRepository.getClass() 를 통해 해당 객체의 정보를 가져올 것이다. 결과를 한번 살펴보자.

# 스프링 데이터 JPA에 의해 만들어진 가짜 Proxy 객체
TEST = memberRepository = class com.sun.proxy.$Proxy120

객체 정보를 찍었는데, Proxy 객체가 출력이 된다?

스프링 데이터 JPA가 해당 인터페이스를 확인하고 스프링 데이터 JPA가 구현체를 만들어서 Injection을 해준다. 즉, 애플리케이션 로딩 시점에 JPA 관련 인터페이스를 구현한 소스가 존재할 경우 구현체를 생성해 주입 한다는 의미다.

04. 공통 인터페이스 분석

04-1. 인터페이스 JPARepository

package org.springframework.data.jpa.repository;

import java.util.List;

import javax.persistence.EntityManager;

import org.springframework.data.domain.Example;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.PagingAndSortingRepository;
import org.springframework.data.repository.query.QueryByExampleExecutor;

/**
 * JPA specific extension of {@link org.springframework.data.repository.Repository}.
 *
 * @author Oliver Gierke
 * @author Christoph Strobl
 * @author Mark Paluch
 * @author Sander Krabbenborg
 * @author Jesse Wouters
 * @author Greg Turnquist
 */
@NoRepositoryBean
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {

    /*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.CrudRepository#findAll()
	 */
	@Override
	List<T> findAll();

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.PagingAndSortingRepository#findAll(org.springframework.data.domain.Sort)
	 */
	@Override
	List<T> findAll(Sort sort);

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.CrudRepository#findAll(java.lang.Iterable)
	 */
	@Override
	List<T> findAllById(Iterable<ID> ids);

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.CrudRepository#save(java.lang.Iterable)
	 */
	@Override
	<S extends T> List<S> saveAll(Iterable<S> entities);
  //..중략
}
  • 기본적으로 JPARepository 인터페이스 안에 많은 메서드가 존재한다
    • JPA에서 핵심이 되는 메서드들이 존재하는 인터페이스
  • spring data 라는 공통 프로젝트가 존재한다
  • 해당 패키지 안에는 JPA 외에도 다른 모듈이 존재할 수 있다
    • ex) org.springframework.data.redis
    • ex) org.springframework.data.jpa
    • ex) org.springframework.data.*

04-2. 인터페이스 PagingAndSortingRepository

package org.springframework.data.repository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

/**
 * Extension of {@link CrudRepository} to provide additional methods to retrieve entities using the pagination and
 * sorting abstraction.
 *
 * @author Oliver Gierke
 * @see Sort
 * @see Pageable
 * @see Page
 */
@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> {

	/**
	 * Returns all entities sorted by the given options.
	 *
	 * @param sort the {@link Sort} specification to sort the results by, can be {@link Sort#unsorted()}, must not be
	 *          {@literal null}.
	 * @return all entities sorted by the given options
	 */
	Iterable<T> findAll(Sort sort);

	/**
	 * Returns a {@link Page} of entities meeting the paging restriction provided in the {@link Pageable} object.
	 *
	 * @param pageable the pageable to request a paged result, can be {@link Pageable#unpaged()}, must not be
	 *          {@literal null}.
	 * @return a page of entities
	 */
	Page<T> findAll(Pageable pageable);
}

  • 스프링 데이터 JPA 에서의 페이징, 정렬 제공 인터페이스
    • Mongo DB, RDBMS, NoSQL 등 페이징, 정렬 기능은 비슷하다
    • 위와 같은 이유로 인해 위 인터페이스는 공통 패키지로 구분이 된다
    • spring-data-commons 패키지

04-3. 인터페이스 CrudRepository

@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {

  /**
	 * Saves a given entity. Use the returned instance for further operations as the save operation might have changed the
	 * entity instance completely.
	 *
	 * @param entity must not be {@literal null}.
	 * @return the saved entity; will never be {@literal null}.
	 * @throws IllegalArgumentException in case the given {@literal entity} is {@literal null}.
	 */
	<S extends T> S save(S entity);

	/**
	 * Saves all given entities.
	 *
	 * @param entities must not be {@literal null} nor must it contain {@literal null}.
	 * @return the saved entities; will never be {@literal null}. The returned {@literal Iterable} will have the same size
	 *         as the {@literal Iterable} passed as an argument.
	 * @throws IllegalArgumentException in case the given {@link Iterable entities} or one of its entities is
	 *           {@literal null}.
	 */
	<S extends T> Iterable<S> saveAll(Iterable<S> entities);

	/**
	 * Retrieves an entity by its id.
	 *
	 * @param id must not be {@literal null}.
	 * @return the entity with the given id or {@literal Optional#empty()} if none found.
	 * @throws IllegalArgumentException if {@literal id} is {@literal null}.
	 */
	Optional<T> findById(ID id);

	/**
	 * Returns whether an entity with the given id exists.
	 *
	 * @param id must not be {@literal null}.
	 * @return {@literal true} if an entity with the given id exists, {@literal false} otherwise.
	 * @throws IllegalArgumentException if {@literal id} is {@literal null}.
	 */
	boolean existsById(ID id);
}
  • 기본적인 CRUD 기능을 제공하는 CrudRepository

04-3. 인터페이스 Repository

package org.springframework.data.repository;

import org.springframework.stereotype.Indexed;

/**
 * Central repository marker interface. Captures the domain type to manage as well as the domain type's id type. General
 * purpose is to hold type information as well as being able to discover interfaces that extend this one during
 * classpath scanning for easy Spring bean creation.
 * <p>
 * Domain repositories extending this interface can selectively expose CRUD methods by simply declaring methods of the
 * same signature as those declared in {@link CrudRepository}.
 *
 * @see CrudRepository
 * @param <T> the domain type the repository manages
 * @param <ID> the type of the id of the entity the repository manages
 * @author Oliver Gierke
 */
@Indexed
public interface Repository<T, ID> {

}
  • 스프링 데이터 JPA가 제공하는 마커 인터페이스
  • class path scan을 쉽게 하기위해 사용 됨

04-4. 공통 인터페이스 구성도

스프링 데이터 JPA 관련 공통 인터페이스를 간략히 정리 해보자

  • (I/F) Repository : 마커 인터페이스
  • (I/F) CrudRepository : 기본적인 CRUD 기능을 제공하는 인터페이스
  • (I/F) PagingAndSortingRepository : 페이징, 정렬 기능 제공하는 인터페이스
  • (I/F) JpaRepository : JPA의 핵심이 되는 메서드들을 제공하는 인터페이스

주의점

  • T findOne(ID) -> Optional
  • findById(ID) 로 변경

제네릭 타입

  • T : 엔티티
  • ID : 엔티티의 식별자 타입
  • S : 엔티티와 그 자식 타입

주요 메서드

참고 : JpaRepository는 대부분의 공통 메서드 제공

  • save(S) : 새로운 엔티티는 저장하고, 이미 있는 엔티티는 병합
  • delete(T) : 엔티티 하나를 삭제, 내부에서 EntityManager.remove() 호출
  • findById(ID) : 엔티티 하나를 조회, 내부에서 EntityManager.find() 호출
  • getOne(ID) : 엔티티를 프록시로 조회, 내부에서 EntityManager.getReference() 호출
  • findAll(..) : 모든 엔티티 조회, 정렬(sort), 페이징(Pageable) 조건을 파라미터로 제공 가능

여기서 잠깐, 그렇다면 기존에 JpaRepository에 의해 제공이 되는 메서드가 아니라 도메인에 특화된 메서드(findByUserName)를 구현하려면 어떻게 해야할까? 스프링 데이터 JPA는 쿼리 메서드라는 기능을 제공하여 위와 같은 문제를 해결할 수 있게 설계.

public interface MemberRepository extends JpaRepository<Member, Long> {

    List<Member> findByUserName(String userName); // 기존에 구현이 되어있지 않은 메서드
}
  • findByUserName을 구현하지 않아도 위 코드는 동작 한다
  • 다음 장인 쿼리 메서드에서 해당 내용을 알아보자

참고 자료

블로그의 정보

기록하고, 복기하고

ymkim

활동하기