참고자료
JPA랑 Spring Data JPA랑 뭐가 다른건가요?에 대한 질문에 정확한 답변을 할 수 없다면 JPA와 스프링을 다시 공부해야 한다. JPA를 모르면 Spring Data JPA를 제대로 사용할 수 없다. JPA는 데이터 접근 기술이고 ORM이다. 지금은 스프링을 사용하는 개발자들에게는 거의 필수인 데이터 접근 기술인데 JPA를 사용하던, 다른 데이터 접근 기술을 사용하던 기본적인 CRUD 기능은 거의 대부분이 비슷하다. 심지어 이건 관계형 데이터베이스가 아닌 데이터베이스도 마찬가지로 기본적인 CRUD는 거의 대부분이 동일하다. 이렇게 유사한 기능을 가지고 있는데 기술이 가지각색인 경우 스프링은 항상 뭐다? 추상화를 한다.
그래서, 스프링 진영에서 데이터 접근 기술들이 이것 저것 많지만 기본적인 CRUD는 모두 동일하니 이거 추상화하자! 해서 만든 표준이 Spring Data라는 표준이고 그 표준을 특정 기술(여기서는 JPA)로 구현한 구현체 중 하나가 Spring Data JPA이다.
그럼 도대체 JPA가 있는데 뭐 얼마나 유사하다고 이 표준을 만든걸까? 한번 Spring Data JPA를 사용하지 않고 순수 JPA를 사용해서 레포지토리를 만들고 데이터 접근 기술을 사용해보자.
엔티티
Member
package cwchoiit.datajpa.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Builder
@AllArgsConstructor
@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 = username;
}
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
}
Team
package cwchoiit.datajpa.entity;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name", "members"})
public class Team {
@Id
@GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
@Builder.Default
private List<Member> members = new ArrayList<>();
}
- 간단하게 Member, Team 두 엔티티를 만들었다.
- 둘은 다대일 양방향 연관관계를 가지고 있고, 그렇기에 연관관계의 주인을 지정해야 한다. JPA 관련 포스팅에서 열심히 말했다. 연관관계의 주인은 항상 테이블 관점에서 외래키를 가지고 있는 쪽이 주인이 되면 된다고 했다. 고로 연관관계의 주인을 Member로 지정했다. 양방향 연관관계에서 연관관계의 주인이 아닌 Team은 mappedBy 속성으로 "나는 주인인 Member의 필드인 team에 매핑된 필드입니다."라고 알려주어야 한다.
- 지연로딩은 당연히 모두 다 걸어야 한다.
- 여기서 @ToString을 유심히 보면, Member에는 Team 필드를 제외한 것을 볼 수 있다. 왜 그러냐면, toString()을 호출할 때 팀까지 넣어버리면 팀을 호출하고, 팀은 멤버 리스트를 가지고 있기 때문에 멤버를 다시 호출한다. 그럼 팀이 멤버를 멤버는 팀을 계속해서 호출하는 문제가 발생해서 스택 오버플로우 에러를 마주하게 된다.
순수 JPA 레포지토리
MemberJpaRepository
package cwchoiit.datajpa.repository;
import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
public Member save(Member member) {
em.persist(member);
return member;
}
public Member find(Long id) {
return em.find(Member.class, id);
}
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 List<Member> findAll() {
return em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
}
public void delete(Member member) {
em.remove(member);
}
}
TeamJpaRepository
package cwchoiit.datajpa.repository;
import cwchoiit.datajpa.entity.Team;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
public class TeamJpaRepository {
private final 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();
}
}
- 직접 EntityManager를 주입받아서 순수한 JPA 레포지토리 코드를 작성한 모습이다. 코드만 봐도 알겠지만, Team과 Member가 참조하는 객체만 다를뿐 코드가 거의 100% 동일하다.
- save(), delete(), findAll(), findById(), count() 모두 둘 다 가지고 있는 메서드이다.
- @Repository 애노테이션을 달아서 이 클래스를 스프링 컨테이너를 띄울때 컴포넌트 스캔을 할 수 있도록 해줘야 한다. 참고로, @Repository를 달면 스프링은 데이터 접근 예외 추상화 기술도 넣어 프록시를 만든다. 그래서 어떤 데이터베이스를 사용하든지 예외가 발생하더라도 동일한 스프링 데이터 접근 예외가 발생한다.
- 이후에 Spring Data JPA를 사용하면 이 @Repository 애노테이션은 달 필요가 없다. 왜냐하면 JpaRepository를 상속받는 시점부터 이미 알아서 스프링 데이터 접근 예외 추상화도 해준다. 이건 이후에 직접 사용하면서 다시 보자.
순수하게 JPA 레포지토리를 작성하고 보니, 코드가 거의 동일하다. 엔티티가 10개면 10개의 레포지토리가 동일하게 가지는 이 기능들을 매번 구현해줘야 한다. 개발자는 게으르다. 이것을 참을 수 없다.
스프링 데이터 JPA 사용
스프링 데이터 JPA를 사용하기 위해선 다음 작업이 필요하다.
package cwchoiit.datajpa;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
@EnableJpaRepositories(basePackages = "cwchoiit.datajpa.repository")
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
}
- 엔트리 지점인 메인 클래스에 @EnableJpaRepositories 애노테이션을 달아서 어떤 패키지에 스프링 데이터 JPA 레포지토리를 가지고 있는지 알려줘야 한다. 그래야 그 지점에 가서 레포지토리들을 컴포넌트 스캔으로 빈 등록하기 때문이다.
- 그런데, 스프링 부트를 사용하는 우리는 이게 필요없다. 스프링 부트가 자동으로 컴포넌트 스캔을 해주는데 어디서부터 해주냐? 바로 이 클래스(DataJpaApplication)가 존재하는 패키지 하위부터 모든 패키지를 다 뒤져서 찾는다.
그래서 다시 제거한 모습으로 놔두자!
package cwchoiit.datajpa;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
}
스프링 데이터 JPA 공통 인터페이스
이제, 스프링 데이터 JPA가 제공하는 인터페이스를 상속받는 인터페이스를 만들기만 하면 끝이다.
MemberRepository
package cwchoiit.datajpa.repository.springdatajpa;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
}
- 이게 끝이다.
- 네? 구현체는요? → 필요가 없다. 스프링 데이터 JPA가 직접 만들어서 꽂아준다.
- 프록시로 만들어서 공통으로 사용되는 기본적인 CRUD 기능도 가지고 있고, 스프링 데이터 접근 예외 추상화도 적용해서 만들어준다.
실제로 확인해볼 수 있는데, 다음과 같이 테스트 코드를 작성해보자.
package cwchoiit.datajpa.repository;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Test
void save() {
log.info("memberRepository class = {}", memberRepository.getClass());
}
}
- 위에서 만든 MemberRepository를 주입받아서 이 클래스 정보를 찍어보면 다음과 같이 나온다.
실행 결과
2024-12-12T14:18:13.905+09:00 INFO 15409 --- [ Test worker] c.d.repository.MemberRepositoryTest : memberRepository class = class jdk.proxy3.$Proxy125
- JDK 동적 프록시를 사용해서 프록시로 만든 모습을 확인할 수 있다.
스프링 데이터 JPA 사용하여 테스트하기
MemberRepositoryTest
package cwchoiit.datajpa.repository;
import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Test
void crud() {
Member member1 = Member.builder()
.username("member1")
.build();
Member member2 = Member.builder()
.username("member2")
.build();
memberRepository.save(member1);
memberRepository.save(member2);
Member findMember1 = memberRepository.findById(member1.getId()).orElseThrow();
Member findMember2 = memberRepository.findById(member2.getId()).orElseThrow();
assertThat(findMember1).isEqualTo(member1);
assertThat(findMember2).isEqualTo(member2);
List<Member> members = memberRepository.findAll();
assertThat(members.size()).isEqualTo(2);
long memberCount = memberRepository.count();
assertThat(memberCount).isEqualTo(2);
memberRepository.delete(member1);
memberRepository.delete(member2);
long afterRemovedCount = memberRepository.count();
assertThat(afterRemovedCount).isEqualTo(0);
}
}
- 이런 코드를 작성했다. 어? 나는 findAll(), count(), delete(), findById(), save() 만든 적이 없는데 잘 작성이 된다. 당연히 되지! 스프링 데이터 JPA가 구현체를 다 만들어 놨으니까!
실행 결과
스프링 데이터 JPA 공통 인터페이스 분석
그럼 스프링 데이터 JPA 공통 인터페이스인 JpaRepository가 어떻게 생긴걸까?
- 이런 모양으로 생겼다. 참고로, Deprecated 된 메서드도 있다. getOne()이 대표적이다. 그리고 findOne()은 findById()로 변경되었다. 메서드 명이나 이런게 중요한게 아니고 여기서 핵심은 이런 구조를 가지고 있다는 점이다.
- 맨 위에서 스프링이 데이터 접근 기술을 추상화했고 그게 Spring Data라고 했다. 그리고 그 여러 데이터 접근 기술 중 하나인 JPA를 가지고 구현한 구현체가 Spring Data JPA일 뿐이다.
- 그래서 이거 타고 올라가보면, JpaRepository는 패키지 명이 다음과 같다.
- 보다시피, org.springframework.data.jpa.repository이다.
- 이 위로 올라가서 PagingAndSortingRepository 올라가보면 패키지 명이 다음과 같다.
- 보다시피 org.springframework.data.repository이다.
- 이 말은, 스프링 데이터 JPA보다 위에있는 것들은 JPA뿐 아니라 Spring Data를 구현한 구현체들은 모두 가지고 있는 기능들이란 뜻이다. 그래서 위에서 말한대로 어떤 데이터베이스를 사용하든지 공통 기능을 추상화했다고 말한것이다.
스프링 데이터 JPA가 제공하는 공통 CRUD 말고는요?
그럼, save(), delete(), findAll() 등 공통적으로 어떤 데이터베이스든 필요한 메서드는 편리하게 사용할 수 있다는 것을 알았다. 근데 당연히 비즈니스적으로 프로젝트마다 엔티티도 다를 것이다. 예를 들면 어떤 프로젝트는 Member라는 엔티티가 있는가 하면 User라는 엔티티를 가진 프로젝트도 있을 것이다. 이런것까지 스프링 데이터 JPA가 다 만들어주진 않는다. 그럼 뭐 마냥 좋은게 아니네? 라고 생각이 들 수 있는데 이 스프링 데이터 JPA가 또 마법같은 일을 부린다.
바로, 메서드 시그니처만으로 어떤 쿼리를 사용할지를 유추해서 그냥 해당 메서드 쿼리를 만들어버린다! 예를 들면 다음과 같이 코드를 작성하자.
package cwchoiit.datajpa.repository.springdatajpa;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByUsername(String username);
}
- 위에서 만든 스프링 데이터 JPA 공통 인터페이스에 시그니처 하나를 추가했다.
- 유저이름으로 Member를 찾아내는 메서드이다. 이거 한 줄만 추가하면 스프링 데이터 JPA가 알아서 이 구현체를 만들어낸다!
- 이 내용은 다음 포스팅에서 본격적으로 다뤄보자!
'Spring Data JPA' 카테고리의 다른 글
Spring Data JPA (3) (0) | 2024.12.18 |
---|---|
Spring Data JPA (2) (0) | 2024.12.12 |