Загружаем каталог…
Загружаем каталог…
🏗 예제 도메인 모델 순수 JPA로 리포지토리를 직접 구현해보고, Spring Data JPA의 공통 인터페이스가 어떻게 그 반복을 없애주는지, 내부적으로 어떻게 동작하는지 알아보도록 하자. ⛓️ Member와 Team의 관계 예제에서 사용할 도메인은 단순하다. 회원( Member )은 하나의 팀( Team )에 소속될 수 있고, 팀은 여러 회원을 가질 수 있다. 즉, 다대일(N:1) 관계다. 엔티티 필드 Member id (PK), username, age, team (FK) Team id (PK), name, members 🙋🏻♂️ Member 엔티티 @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, int age) { ... } public Member(String username, int age, Team team) { ... } public void changeTeam(Team team) { this.team = team; team.getMembers().add(this); } } 👫 Team 엔티티 @Entity @Getter @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") List<Member> members = new ArrayList<>(); } ❗️ 엔티티 설계 시 챙겨야 할 것들 @NoArgsConstructor(access = AccessLevel.PROTECTED) : JPA는 내부적으로 리플렉션을 사용해 기본 생성자를 호출한다. 완전히 막을 수는 없지만 PROTECTED 로 설정해두면 외부에서 new Member() 로 직접 생성하는 실수를 막을 수 있다. @ToString 에는 연관관계 필드 넣지 않기: team 을 @ToString 에 포함시키면 Member → Team → Member → ... 무한 루프로 StackOverflowError 가 난다. 반드시 기본 타입 필드만 포함해야 한다. @Setter 는 실무에서 지양: Setter를 열어두면 어디서든 엔티티 상태를 바꿀 수 있어 추적이 어려워진다. 상태 변경이 필요하다면 changeTeam() 처럼 의도가 명확한 메서드를 따로 만드는 게 좋다. 지금은 학습의 편의를 위해 임시로 사용했다. 연관관계 편의 메서드: 양방향 관계에서 한쪽만 설정하면 반대편이 동기화되지 않는다. changeTeam() 에서 this.team = team 과 team.getMembers().add(this) 를 함께 처리해두면 실수를 줄일 수 있다. 모든 연관관계는 지연 로딩( LAZY )으로 설정하는 게 기본이다. EAGER 는 예상치 못한 쿼리가 발생하기 쉽고, 특히 JPQL에서 N+1 문제의 원인이 된다. 💥 순수 JPA 리포지토리의 문제점 Spring Data JPA를 쓰기 전에, 순수 JPA로 리포지토리를 직접 구현해보자. EntityManager 를 사용해 기본 CRUD를 작성하면 아래와 같다. @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(); } 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(); } } 잘 동작한다. 근데 팀 리포지토리도 만들어야 한다. 그럼 TeamJpaRepository 에도 save , delete , findAll , findById , count ... 똑같은 코드가 반복될 것이다. 엔티티가 늘어날수록 이 반복은 계속된다. CRUD 코드가 엔티티마다 그대로 복붙된다 는 게 핵심 문제다. ✨ Spring Data JPA 공통 인터페이스 Spring Data JPA는 이런 반복을 단 한 줄로 해결해준다. // 구현체를 따로 만들 필요가 없음 public interface MemberRepository extends JpaRepository<Member, Long> { } JpaRepository<Member, Long> 을 상속하는 것만으로 위에서 직접 구현했던 save , delete , findAll , findById , count 가 모두 제공된다. 🤔 어떻게 동작하는 걸까? 애플리케이션이 로딩될 때 Spring Data JPA가 JpaRepository 를 상속한 인터페이스들을 스캔한다. 그리고 각 인터페이스에 대한 프록시 구현체를 자동으로 생성 해서 스프링 컨테이너에 빈으로 등록해준다. 1. 애플리케이션 로딩 2. Spring Data JPA가 JpaRepository 상속 인터페이스 스캔 3. 각 인터페이스에 대한 프록시 구현체 자동 생성 4. 스프링 컨테이너에 빈으로 등록 5. @Autowired로 주입받아 사용 실제로 주입된 객체를 출력해보면 직접 만든 클래스가 아니라 Spring Data JPA가 만든 프록시 객체 임을 확인할 수 있다. System.out.println(memberRepository.getClass()); // class com.sun.proxy.$ProxyXXX 😁 @Repository 애노테이션 생략 가능 Spring Data JPA 리포지토리는 @Repository 를 붙이지 않아도 동작한다. 컴포넌트 스캔과 JPA 예외 → Spring 공통 예외 변환 을 Spring Data JPA가 자동으로 처리해주기 때문이다. 자동 스캔 범위는 @SpringBootApplication 이 선언된 패키지와 그 하위 패키지다. 리포지토리가 다른 패키지에 있다면 @EnableJpaRepositories(basePackages = "...") 를 직접 지정해야 한다. 🔍 JPA 영속성 컨텍스트와 테스트 Spring Data JPA를 사용하다 보면 테스트에서 자주 막히는 지점이 있다. JPA의 영속성 컨텍스트 개념을 모르면 이유를 알 수 없는 실패들이다. 👥 1차 캐시와 엔티티 동일성 같은 트랜잭션 안에서 동일한 ID로 조회하면 DB가 아닌 1차 캐시 에서 같은 인스턴스를 반환한다. Member a = memberRepository.findById(1L).get(); Member b = memberRepository.findById(1L).get(); assertThat(a).isEqualTo(b); // true — DB 쿼리는 1번만 실행 JPA는 동일 트랜잭션 내에서 같은 PK로 조회한 엔티티는 항상 같은 인스턴스 임을 보장한다( a == b ). 별도로 equals() 를 구현하지 않아도 이 보장 덕분에 isEqualTo() 테스트가 통과한다. 🫣 @Transactional이 없으면 테스트가 실패하는 이유 @Transactional 이 없으면 save() 와 findById() 가 서로 다른 트랜잭션 으로 실행된다. findById() 호출 시점에 이전 영속성 컨텍스트는 이미 닫혀 있고, DB에서 새 인스턴스 를 만들어 반환한다. Member 에 equals() 가 없으면 Object.equals() (참조 비교)로 fallback되어 테스트가 실패한다. // @Transactional이 없으면 실패 @Test void test() { Member saved = memberRepository.save(new Member("kim", 20)); Member found = memberRepository.findById(saved.getId()).orElseThrow(); assertThat(found).isEqualTo(saved); // 다른 인스턴스 → 실패 } 테스트 클래스에 @Transactional 을 붙이면 각 테스트가 하나의 트랜잭션 안에서 실행되고, 끝나면 자동으로 롤백된다. 🚨 변경 감지 (Dirty Checking) JPA에서 수정은 별도의 update 메서드가 필요 없다. 트랜잭션 안에서 엔티티 필드를 직접 변경하면, 트랜잭션 커밋 시점에 자동으로 UPDATE SQL이 실행된다. @Transactional void update(Long id, String newName) { Member member = memberRepository.findById(id).orElseThrow(); member.changeUsername(newName); // 이것만으로 UPDATE 실행됨 // memberRepository.save(member) 불필요 } 동작 원리는 아래와 같다. 엔티티 조회 시 원본 스냅샷 저장 트랜잭션 커밋 직전 flush() 자동 호출 현재 상태와 스냅샷 비교 달라진 필드가 있으면 UPDATE SQL 실행 🎭 flush() vs clear() 메서드 동작 em.flush() 변경 내용을 DB에 반영 (트랜잭션은 유지) em.clear() 1차 캐시 초기화 → 이후 조회 시 DB에서 다시 로딩 테스트에서 DB 반영 결과를 캐시 없이 검증하고 싶을 때 flush() + clear() 를 함께 쓴다. em.flush(); // DB에 반영 em.clear(); // 1차 캐시 비우기 Member fresh = memberRepository.findById(id).get(); // DB에서 새로 조회 📐 JpaRepository 계층 구조 JpaRepository 가 제공하는 기능들이 어떻게 구성되어 있는지 계층 구조를 보면 이해하기 쉽다. <<스프링 데이터>> Repository ← 마커 인터페이스 (기능 없음) └── CrudRepository ← save, findById, delete, count 등 기본 CRUD └── PagingAndSortingRepository ← findAll(Sort), findAll(Pageable) │ <<스프링 데이터 JPA>> └── JpaRepository ← JPA 특화 기능 추가 (flush, deleteInBatch 등) Repository 부터 PagingAndSortingRepository 까지는 Spring Data Commons 에 속한다. JPA 말고 MongoDB, Redis 등 다른 저장소에서도 똑같은 인터페이스를 쓸 수 있도록 추상화되어 있다. JpaRepository 는 거기에 JPA에 특화된 기능을 얹은 것이다. 🖋️ 제네릭 타입 JpaRepository<T, ID> 타입 파라미터 의미 T 엔티티 타입 ID 식별자(PK) 타입 S 엔티티와 그 자식 타입 (save 등에서 사용) 📖 주요 메서드 메서드 설명 save(S) 새 엔티티면 persist , 이미 있으면 merge findById(ID) Optional<T> 반환. 내부적으로 em.find() 호출 getReferenceById(ID) 프록시 반환. 실제 필드 접근 시에 SELECT 실행. em.getReference() 호출 findAll(...) 전체 조회. Sort 나 Pageable 파라미터 가능 delete(T) 내부적으로 em.remove() 호출 count() 전체 개수 반환 existsById(ID) 존재 여부 확인 🎏 findById vs getReferenceById 연관관계를 설정할 때 굳이 엔티티 전체를 조회할 필요가 없다. ID만 필요한 경우 getReferenceById() 를 쓰면 SELECT 없이 프록시만 받아올 수 있다. // findById: SELECT 즉시 실행 Member member = memberRepository.findById(memberId).orElseThrow(); // getReferenceById: SELECT 없음. 연관관계 설정 등 ID만 필요한 경우에 유용 Member memberRef = memberRepository.getReferenceById(memberId); order.setMember(memberRef); // 여기서도 SELECT 안 함 😂 공통 인터페이스의 한계 JpaRepository 는 모든 엔티티에 공통으로 적용할 수 있는 기능만 제공한다. username 으로 회원을 찾는 것처럼 도메인에 특화된 쿼리는 제공하지 않는다. 이걸 보완하는 게 쿼리 메서드 다. 메서드 이름 규칙만 지키면 Spring Data JPA가 자동으로 쿼리를 만들어준다. // 선언만 해도 동작 List<Member> findByUsernameAndAgeGreaterThan(String username, int age); 📝 정리 단계 핵심 포인트 도메인 모델 연관관계는 LAZY, @ToString에 연관관계 필드 제외, 편의 메서드로 양방향 동기화 순수 JPA EntityManager로 직접 구현 — 엔티티마다 CRUD 코드 반복 Spring Data JPA 인터페이스 선언만으로 프록시 구현체 자동 생성, @Repository 생략 가능 영속성 컨텍스트 1차 캐시로 동일 트랜잭션 내 엔티티 동일성 보장, 변경 감지로 update 불필요 테스트 @Transactional 없으면 엔티티 동일성 테스트 실패 JpaRepository Repository → CrudRepository → PagingAndSortingRepository → JpaRepository 계층 구조
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
도메인 모델 설계부터 공통 인터페이스까지. 🏗 예제 도메인 모델 순수 JPA로 리포지토리를 직접 구현해보고, Spring Data JPA의 공통 인터페이스가 어떻게 그 반복을 없애주는지, 내부적으로 어떻게 동작하는지 알아보도록 하자. ⛓️ Member와 Team의 관계 예제에서 사용할 도메인은 단순하다. 회원( Member )은 하나의 팀( Team )에 소속될 수 있고, 팀은 여러 회원을 가질 수 있다. 즉, 다대일(N:1) 관계다. 엔티티 필드 Member id (PK), username, age, team (FK) Team id (PK), name, members 🙋🏻♂️ Member 엔티티 @Entity @Getter @Setter @NoArgsConstructor(access =…
Открыть источник