이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
기초개념 › JPA
리포지토리
리포지토리는 무엇이고, 구현은 누가 만드는가
스프링 데이터 JPA의 리포지토리는 엔티티를 저장하고 찾는 메서드를 모아 둔 인터페이스입니다. interface MemberRepository extends JpaRepository<Member, Long>이라고 선언만 하면, 스프링이 시작할 때 이 인터페이스를 구현한 객체를 대신 만들어 빈으로 등록합니다. 그래서 우리가 한 줄도 구현하지 않은 save 메서드를 부를 수 있습니다. save는 JpaRepository가 부모 인터페이스에서 물려받은 메서드를 다시 물려받은 것입니다. findByName처럼 정해진 이름 규칙(findBy 뒤에 필드 이름)에 맞춰 메서드를 선언하면, 스프링이 그 이름을 읽어 쿼리까지 만들어 줍니다. 이 실습은 extends JpaRepository와 찾는 메서드의 선언을 바꿔 가며 같은 코드를 컴파일하고 스프링을 시작해, 구현 객체가 만들어지는지와 어떤 SQL이 나가는지를 봅니다.
실행으로 대조 · 스프링 부트 4.1.1, 스프링 데이터 JPA 4.1.1, Hibernate 7.4.5, H2에서 여섯 가지 조합을 컴파일하고 실제 스프링 시작으로 구현 객체와 나간 SQL을 대조
이 실습에 나오는 개념
- 리포지토리
- 리포지토리(Repository)는 스프링 데이터에서 엔티티를 저장하고 찾는 메서드를 모아 둔 인터페이스다. 스프링 데이터가 정한 중심 인터페이스는 Repository이고, 다루는 엔티티 클래스와 그 id의 타입을 타입 인자로 받는다. 개발자는 이 인터페이스를 상속한 인터페이스를 선언만 하고 구현 클래스는 쓰지 않는다. 스프링은 시작할 때 설정된 패키지 아래에서 이런 인터페이스를 모두 찾아, 각각에 대해 호출을 처리하는 프록시 객체를 만들어 빈으로 등록한다. 그래서 그 빈을 주입받아 save 같은 메서드를 바로 부를 수 있다. Repository를 상속하지 않은 평범한 인터페이스는 찾지 않으므로 구현 객체도 만들어지지 않는다.
- JpaRepository
- JpaRepository는 스프링 데이터 JPA가 제공하는 리포지토리 인터페이스로, 엔티티 클래스와 id 타입을 타입 인자로 받는다(예: JpaRepository<Member, Long>). 저장 기술에 맞춘 이 인터페이스는 CrudRepository 계열 인터페이스를 확장하므로, 이를 상속하면 save, findById, findAll, count, delete 같은 기본 메서드를 선언하지 않고도 물려받아 쓸 수 있다. 이 인터페이스를 상속해 선언하면 스프링 데이터 JPA의 대상이 되어, 스프링이 시작할 때 그 인터페이스의 구현 객체를 만들어 준다. 상속하지 않으면 이런 메서드가 없어 save를 부르는 코드는 컴파일되지 않는다. 메서드 이름을 findByName처럼 규칙에 맞게 선언하면 쿼리 메서드가 되어 쿼리까지 만들어 준다.
- 쿼리 메서드
- 스프링 데이터 리포지토리 인터페이스에 선언한 메서드의 이름을 읽어 쿼리를 만들어 주는 기능이다. findByName처럼 find…By 뒤에 엔티티의 프로퍼티 이름을 쓰고, GreaterThan이나 Containing 같은 키워드를 프로퍼티 이름 뒤에 붙여 조건의 종류를 정하며, 여러 조건은 And나 Or로 잇는다. 이름을 쿼리로 바꾸는 일은 애플리케이션이 뜰 때 하므로, 엔티티에 없는 프로퍼티 이름을 쓰면 PropertyReferenceException이 나고 애플리케이션이 뜨지 않는다. 조건이 많아지면 이름이 지나치게 길어지므로 그때는 @Query에 JPQL을 직접 적는다.
- JPA, Hibernate, Spring Data JPA
- JPA는 자바의 ORM 표준 명세로, 어떤 기능을 어떻게 사용할지 정한 규칙(인터페이스)이다. Hibernate는 그 명세를 실제로 구현한 라이브러리다. Spring Data JPA는 JPA를 더 쉽게 쓰도록 리포지토리 인터페이스를 제공하는 스프링의 모듈이며 내부적으로 JPA(Hibernate)를 사용한다.
이렇게 바꿔 보세요
- 처음 설정으로 — 선언만 한 인터페이스. 스프링이 구현 객체를 만들어 주어 save와 findByName이 실행되고 SQL이 나갑니다.
- extends JpaRepository를 지우면 — 평범한 인터페이스가 되어 save가 없고, 컴파일에 실패합니다. 스프링도 구현 객체를 만들지 않습니다.
- findByName 선언을 지우면 — 선언한 메서드가 없어도 물려받은 save와 count가 실행됩니다.
- Member에 없는 필드로 선언하면 — 스프링이 쿼리를 만들지 못해 시작하다가 실패합니다.
처음 설정으로 실행하면
- 컴파일러가 MemberRepository.java와 쓰는 쪽을 확인합니다. 인터페이스가 extends JpaRepository<Member, Long>을 선언해 JpaRepository의 메서드를 물려받으므로, 우리가 선언하지 않은 save 메서드를 부르는 줄도 오류가 아닙니다. findByName 메서드는 우리가 선언한 것입니다. 컴파일에 성공합니다.
- main이 SpringApplication.run으로 스프링을 시작합니다. 스프링 데이터가 시작 클래스가 있는 app 패키지에서 JpaRepository 같은 리포지토리 인터페이스를 상속한 인터페이스를 찾습니다. extends JpaRepository가 붙은 MemberRepository가 찾아집니다. 우리가 쓴 코드에는 MemberRepository를 구현한 클래스도, 구현을 만드는 줄도 없습니다.
- 스프링 데이터가 MemberRepository를 구현한 객체를 만들어 빈으로 등록합니다. 이 객체는 우리가 쓴 클래스가 아니라 스프링이 실행 중에 만든 것이고, MemberRepository 타입으로 쓸 수 있습니다. save 같은 물려받은 메서드는 이 객체가 실행합니다.
- 이때 스프링 데이터가 findByName 메서드의 이름을 읽습니다. findBy는 "찾는다", 그 뒤의 Name은 Member의 name 필드라는 규칙에 따라 "name으로 찾는 쿼리"를 준비합니다. Member에 name 필드가 있어서 통과합니다. 이름이 규칙에 맞지 않으면 여기서 실패합니다.
- main이 스프링에서 MemberRepository 타입의 빈을 받아 memberRepository에 담습니다(화면에서는 줄여 둔 부분). 담긴 것은 방금 스프링이 만든 구현 객체입니다. new로 만든 객체가 아닙니다. 이 뒤에 나가는 SQL부터 셉니다.
- memberRepository.save(new Member(1L, "김철수"))가 실행됩니다. save 메서드를 구현한 코드는 우리에게 없고, JpaRepository에서 물려받은 것을 스프링이 만든 구현 객체가 실행합니다. Member의 id가 1로 이미 정해져 있어서, save 안에서 먼저 id가 1인 행이 이미 있는지 SELECT로 찾아봅니다(SELECT 1번). 그런 행은 없습니다.
- 찾은 행이 없으므로 구현 객체가 INSERT를 보내 member 테이블에 id 1, 김철수 행을 저장합니다(INSERT 1번). 우리가 쓴 SQL은 한 줄도 없습니다.
- memberRepository.findByName("김철수")가 실행됩니다. 구현 객체가 시작할 때 읽어 둔 이름 규칙대로 SELECT를 보냅니다: select m1_0.id,m1_0.name from member m1_0 where m1_0.name=? (name=? 자리에는 넘긴 "김철수"가 들어갑니다). 우리가 쓴 쿼리는 없습니다. 찾은 행 1개가 Member 객체가 되어 List에 담깁니다.
- println이 found.size()를 출력합니다. 찾은 Member가 1개이므로 1입니다. 우리가 쓴 것은 인터페이스의 선언 두 줄(인터페이스 선언과 findByName 한 줄)뿐이었습니다.
실행 결과
- 리포지토리는 엔티티를 저장하고 찾는 메서드를 모아 둔 인터페이스입니다. extends JpaRepository<Member, Long>을 선언하면 스프링이 시작할 때 이 인터페이스의 구현 객체를 대신 만들어 빈으로 등록하고, 그래서 우리가 구현하지 않은 save를 부를 수 있었습니다.
- save는 JpaRepository에서 물려받은 메서드이고, findByName은 이름의 규칙(findBy + 필드 이름)을 스프링이 읽어 SELECT를 만든 것입니다. 우리가 쓴 것은 인터페이스의 선언뿐이고, 구현 클래스도 SQL도 쓰지 않았습니다.
코드
// 자격: 스프링이 구현을 대신 만들어 준다 (구현 클래스는 쓰지 않는다)
public interface MemberRepository extends JpaRepository<Member, Long> {
// 이름의 규칙(findBy + 필드 이름)을 보고 스프링이 쿼리를 만든다
List<Member> findByName(String name);
}
// 쓰는 쪽
memberRepository.save(new Member(1L, "김철수"));
List<Member> found = memberRepository.findByName("김철수");
System.out.println(found.size());
이 모형의 전제
스프링 부트 4.1.1(스프링 데이터 JPA 4.1.1, Hibernate 7.4.5, 스프링 프레임워크 7.0.9)을 JDK 21에서, H2 2.4.240 메모리 DB에서 실행해 대조했고 MySQL에서의 실행 대조는 하지 않았습니다. 여섯 가지 조합을 모두, 화면의 코드와 같은 소스(패키지 app의 MemberRepository와 쓰는 쪽을 가진 Main, 끝에 PROBE 표시를 붙인 측정 줄만 더함)를 javax.tools로 컴파일하고, 컴파일에 성공한 것은 자바 프로세스를 새로 띄워 실행했습니다. 시작 클래스는 @SpringBootConfiguration과 @EnableAutoConfiguration을 붙였고 웹 서버는 띄우지 않았습니다. 엔티티 Member(id는 Long, name은 String)와 MemberRepository는 시작 클래스와 같은 패키지에 두고, 리포지토리를 손으로 등록하지 않고 스프링 데이터가 패키지를 훑어 인터페이스를 찾아 구현 객체를 만들게 했습니다. 대조한 것은 출력한 글, 컴파일 오류의 javac 진단 코드와 줄(cant.resolve.location.args, 첫 오류가 save이고 count가 있으면 둘째 오류), 스프링이 시작하다 실패한 예외의 사슬(BeanCreationException, QueryCreationException, PropertyReferenceException), 스프링 컨텍스트에 있는 MemberRepository 빈의 수, 그 객체가 우리가 쓴 클래스인지 아닌지(JDK 동적 프록시이고 MemberRepository를 구현한 우리 클래스는 없음), 시작이 끝난 뒤 DB로 나간 SQL의 종류별 수와 문장(Hibernate의 StatementInspector로 잡음)입니다. extends JpaRepository가 없는 세 조합은 컴파일이 실패하므로, 쓰는 쪽을 뺀 같은 인터페이스로 스프링을 따로 띄워 구현 객체가 0개인 것을 확인했습니다. 컴파일이 실패한 조합에서는 Main 클래스 파일이 만들어지지 않았습니다. 문서는 직접 열어 확인했습니다. 스프링 데이터 커먼즈 문서의 Creating Repository Instances 절은 스프링 데이터가 설정된 패키지 아래에서 리포지토리 하위 인터페이스를 상속한 인터페이스를 모두 찾아 그것마다 빈 객체를 만들고, 쿼리 메서드 호출을 처리하는 프록시를 만든다고 하며, 스프링 데이터 JPA 문서의 Core concepts 절은 Repository가 중심 인터페이스이고 JpaRepository 같은 저장 기술별 인터페이스가 CrudRepository를 확장한다고, Defining Repository Interfaces 절은 인터페이스가 Repository를 상속해야 하고 JpaRepository를 상속한 인터페이스가 스프링 데이터 JPA 모듈의 대상이라고 합니다. Query Methods 절의 Query Creation과 Property Expressions는 메서드 이름의 첫 By까지가 주제이고 그 뒤가 조건이라는 것, 쿼리를 만드는 때에 파싱한 이름이 관리하는 엔티티의 프로퍼티인지 확인한다는 것을 설명합니다. 그 확인이 스프링을 시작할 때 일어나 시작을 실패시킨다는 것과 예외의 이름은 문서가 정한 것이 아니라 이 버전에서 관찰한 동작입니다. 구현 객체의 실제 클래스가 JDK 프록시인 것, save가 id가 있는 엔티티에 대해 INSERT에 앞서 SELECT를 한 번 보내는 것, 나간 SQL의 모양과 개수도 이 버전과 설정에서 관찰한 것으로, 버전에 따라 다를 수 있습니다. 화면의 코드는 package와 import 줄을 줄였고, Member 엔티티, 스프링을 시작하고 구현 객체를 받는 main의 줄, DB 설정은 보이지 않습니다. 이 실습은 쿼리 메서드의 이름 규칙 가운데 findBy와 필드 이름 하나만 다루며, @Query, 조건 키워드(And, GreaterThan 등), 반환 타입, 페이지 요청, 트랜잭션은 다루지 않습니다. 이것들은 "세부 동작 실습: 스프링 데이터 JPA"에서 다룹니다.
앞 실습: 영속성 컨텍스트 · 다음 실습: 연관관계