JPA · 리포지토리와 쿼리 메서드
Spring Data JPA? 는 인터페이스만 선언하면
저장·조회 코드를 스프링이 자동으로 만들어 줍니다. 메서드 이름을 규칙대로 짓는 것만으로
WHERE 조건이 붙은 쿼리가 생기고, 더 복잡한 건 @Query 로 직접 적습니다.
Spring Data JPA (Spring Boot 3.3.1) · Hibernate 6.x · Jakarta Persistence 3.1 · Java 17 기준입니다.
실제 프로젝트(study-backend)는 단순 조회를 core/repo 의 Spring Data JPA 리포지토리로,
동적 쿼리는 core/query 의 QueryDSL? 로 나눠 둡니다.
1. Spring Data JPA 개요 — 선언만 하면 구현 자동 생성
보통 리포지토리? 를 만들려면 "저장하라", "ID로 찾아라" 같은 코드를 일일이 써야 합니다. Spring Data JPA에서는 인터페이스만 선언하면 됩니다. 애플리케이션이 뜰 때 스프링이 그 인터페이스의 구현체(프록시)를 자동으로 만들어 빈? 으로 등록해요.
// 이 한 줄만 선언하면 save·findById·findAll 등이 바로 동작
public interface UserRepo extends JpaRepository<User, Long> { }
// └엔티티┘ └PK타입┘제네릭 두 개는 <엔티티 타입, 기본키(PK) 타입> 입니다. 위 예에서는 User 엔티티를 다루고 PK는 Long 이라는 뜻이에요.
리포지토리 계층 — 위로 갈수록 기능이 더 많음
최상위는 메서드가 하나도 없는 마커 인터페이스 Repository 입니다.
이걸 상속하는 것만으로 "나는 리포지토리다"라고 스프링에 알리고, 엔티티·PK 타입을 잡아 둬요.
그 아래로 갈수록 기능이 쌓입니다.
Repository<T, ID> // 마커(메서드 없음)
├─ CrudRepository<T, ID> // 기본 CRUD (반환: Iterable)
│ └─ ListCrudRepository<T, ID> // 같은 CRUD, 반환을 List 로
└─ PagingAndSortingRepository<T, ID> // 페이징·정렬 추가
└─ ListPagingAndSortingRepository<T, ID>
JpaRepository<T, ID>
extends ListCrudRepository, ListPagingAndSortingRepository // 둘을 합치고
// + JPA 전용 기능(flush, saveAndFlush, getReferenceById, 일괄 삭제 …)
대부분의 경우 그냥 JpaRepository 하나만 상속하면 됩니다. CRUD·페이징·정렬·JPA 전용 기능이 전부 들어 있고,
반환 타입도 Iterable 대신 다루기 편한 List 로 나오기 때문이에요.
2. 파생 쿼리 — 메서드 이름으로 쿼리 자동 생성
이름을 규칙대로 지으면 스프링이 그 이름을 읽어서 쿼리를 만들어 줍니다. 이걸 파생 쿼리(쿼리 메서드)라고 해요. 이름은 크게 주제 키워드(무엇을 할지) + By + 조건 키워드(어떤 기준으로)로 이뤄집니다.
Optional<User> findByUserid(String userid); // find By userid → SELECT ... WHERE userid = ?1 // 주제 ↑ 속성 이름
주제 키워드 — 무엇을 할지
| 키워드 | 의미 | 예 |
|---|---|---|
find…By | 조회(가장 흔함) | findByName |
read…By | 조회(find 와 동일) | readByName |
get…By | 조회(find 와 동일) | getByName |
query…By | 조회(find 와 동일) | queryByName |
count…By | 개수 세기 → long | countByActiveTrue |
exists…By | 존재 여부 → boolean | existsByUserid |
delete…By | 삭제(파생 삭제) | deleteByUserid |
find·read·get·query 는 모두 같은 "조회"입니다. 읽기 좋은 걸로 고르면 돼요.
조건 키워드 카탈로그 — 어떤 기준으로
By 뒤에 속성 이름과 조건 키워드를 붙입니다. 아래가 공식 문서에 정리된 키워드 전부예요.
| 키워드 | 메서드 예 | 생성되는 JPQL 조건 |
|---|---|---|
And | findByLastnameAndFirstname | where x.lastname = ?1 and x.firstname = ?2 |
Or | findByLastnameOrFirstname | where x.lastname = ?1 or x.firstname = ?2 |
Is, Equals | findByFirstname(Is/Equals) | where x.firstname = ?1 |
Between | findByStartDateBetween | where x.startDate between ?1 and ?2 |
LessThan | findByAgeLessThan | where x.age < ?1 |
LessThanEqual | findByAgeLessThanEqual | where x.age <= ?1 |
GreaterThan | findByAgeGreaterThan | where x.age > ?1 |
GreaterThanEqual | findByAgeGreaterThanEqual | where x.age >= ?1 |
After | findByStartDateAfter | where x.startDate > ?1 |
Before | findByStartDateBefore | where x.startDate < ?1 |
IsNull, Null | findByAge(Is)Null | where x.age is null |
IsNotNull, NotNull | findByAge(Is)NotNull | where x.age is not null |
Like | findByFirstnameLike | where x.firstname like ?1 |
NotLike | findByFirstnameNotLike | where x.firstname not like ?1 |
StartingWith | findByFirstnameStartingWith | like ?1 (값 뒤에 % 자동 부착) |
EndingWith | findByFirstnameEndingWith | like ?1 (값 앞에 % 자동 부착) |
Containing | findByFirstnameContaining | like ?1 (값 양쪽에 % 자동 부착) |
In | findByAgeIn(Collection) | where x.age in ?1 |
NotIn | findByAgeNotIn(Collection) | where x.age not in ?1 |
True | findByActiveTrue() | where x.active = true |
False | findByActiveFalse() | where x.active = false |
Empty / NotEmpty | findByRolesIsEmpty() | 컬렉션 연관이 비었는지 / 안 비었는지 |
Distinct | findDistinctByLastname | select distinct … (중복 행 제거) |
IgnoreCase | findByFirstnameIgnoreCase | where UPPER(x.firstname) = UPPER(?1) |
OrderBy…Asc/Desc | findByAgeOrderByLastnameDesc | … order by x.lastname desc |
Not | findByLastnameNot | where x.lastname <> ?1 |
Top / First (N) | findTop3ByOrderByAgeDesc | 결과를 N개로 제한(숫자 생략 시 1개) |
findTop10By… 와 findFirst10By… 는 완전히 동일합니다. 둘 다 결과 개수를 제한하고, 숫자를 빼면 1개만 가져와요. 취향에 맞는 쪽을 쓰면 됩니다.
3. @Query — JPQL/네이티브 SQL 직접 작성
이름만으로 표현하기 어려운 쿼리는 @Query 로 직접 적습니다. 기본은 테이블이 아니라
엔티티를 대상으로 하는 쿼리 언어인 JPQL 이에요. 파라미터는 두 가지 방식으로 묶습니다.
public interface UserRepo extends JpaRepository<User, Long> {
// 이름 바인딩: :name 자리에 @Param("name") 값이 들어감
@Query("select u from User u where u.name = :name and u.active = true")
List<User> searchActive(@Param("name") String name);
// 위치 바인딩: ?1 은 첫 번째 인자
@Query("select u from User u where u.userid = ?1")
Optional<User> byUserid(String userid);
// 네이티브 SQL: 실제 테이블 이름으로, DB 고유 문법 사용 가능
@Query(value = "select * from lds.tb_user where userid = :uid", nativeQuery = true)
Optional<User> byUseridNative(@Param("uid") String uid);
}
이름 바인딩(:name + @Param)은 어떤 값이 어디 들어가는지 한눈에 보여 추천됩니다.
위치 바인딩(?1)은 짧지만 인자 순서가 바뀌면 실수하기 쉬워요.
nativeQuery = true 를 주면 JPQL이 아니라 진짜 SQL 을 그대로 실행합니다(테이블 이름을 직접 씀).
수정 쿼리 — @Modifying
@Query 로 UPDATE/DELETE 를 실행하려면 @Modifying 을 함께 붙여야 합니다.
또한 데이터를 바꾸는 작업이므로 트랜잭션? 안에서 실행돼야 해요.
@Modifying(clearAutomatically = true) // 실행 후 영속성 컨텍스트 비움
@Transactional
@Query("update User u set u.active = false where u.lastLoginAt < :cutoff")
int deactivateIdle(@Param("cutoff") LocalDateTime cutoff);
// 반환 int = 변경된 행 수수정 쿼리는 DB를 직접 바꾸지만, 메모리에 있던(이미 읽어 둔) 엔티티는 옛 값 그대로 남습니다.
clearAutomatically = true 를 주면 실행 후 영속성 컨텍스트를 비워, 다음 조회 때 갱신된 값을 다시 읽어와요. 특히 네이티브 수정 쿼리에서 중요합니다.
4. 페이징과 정렬
목록을 한 번에 다 가져오면 느립니다. 페이지 단위로 잘라 가져오는 게 페이징이에요.
메서드 인자에 Pageable 을 받으면 됩니다.
// page=0(첫 페이지), size=20, 이름 오름차순 정렬
Pageable pageable = PageRequest.of(0, 20, Sort.by("name").ascending());
Page<User> page = userRepo.findByActiveTrue(pageable);
page.getContent(); // 이번 페이지 목록(List)
page.getTotalElements(); // 전체 개수
page.getTotalPages(); // 전체 페이지 수
page.hasNext(); // 다음 페이지 존재 여부반환 타입은 세 가지 중에 고릅니다.
| 반환 타입 | 주는 정보 | 전체 카운트 쿼리 |
|---|---|---|
Page<T> | 이번 페이지 + 전체 개수·전체 페이지 수 | 실행함(추가 쿼리) |
Slice<T> | 이번 페이지 + 다음 존재 여부만 | 안 함(가벼움) |
List<T> | 이번 페이지 목록만 | 안 함 |
전체 개수를 화면에 표시하는 페이지네이션이면 Page, "더 보기" 버튼처럼 다음만 있으면 되는 무한 스크롤이면
Slice 가 가볍고 효율적입니다. 정렬만 필요하면 Sort.by(...) 를 인자로 받을 수도 있어요.
5. 프로젝션 — 필요한 컬럼만
엔티티 전체가 아니라 일부 필드만 뽑고 싶을 때 프로젝션을 씁니다. 두 가지 방식이 있어요.
(1) 인터페이스 기반 — getter 만 선언하면 스프링이 프록시로 채워 줍니다. 선언한 속성만 SELECT 해서 효율적이에요.
interface UserNameOnly { // getter 이름이 엔티티 속성과 일치
String getUserid();
String getName();
}
List<UserNameOnly> findByActiveTrue(); // userid, name 만 조회(2) 클래스(DTO) 기반 — 생성자로 값을 받는 클래스/레코드로 받습니다. @Query 에서 new DTO(...) 로 직접 만들 수도 있어요.
record UserDTO(String userid, String name) {}
// @Query 에서 생성자 표현식으로 DTO 만들기 (FQDN 전체 경로 필요)
@Query("select new com.example.study.web.dto.UserDTO(u.userid, u.name) from User u where u.active = true")
List<UserDTO> findActiveDTOs();인터페이스 프로젝션은 중첩 프로젝션을 지원하지만, 클래스 DTO는 단순한 평면 구조에 적합합니다.
6. @EntityGraph 와 읽기 전용 조회
연관 엔티티(예: User 의 roles)를 지연 로딩으로 두면, 반복 접근 때 쿼리가 N번 더 나가는
N+1 문제가 생깁니다. @EntityGraph 로 "이번엔 같이 가져와라"라고 지정하면 한 번에(조인으로) 읽어요.
@EntityGraph(attributePaths = {"roles"}) // roles 를 함께 fetch
@Query("select u from User u where u.active = true")
List<User> findActiveWithRoles();
단순 조회만 하고 수정하지 않을 때는 읽기 전용으로 표시하면 좋습니다. 트랜잭션을
@Transactional(readOnly = true) 로 두면, 변경 감지를 위한 스냅숏을 만들지 않아 더 가벼워요.
@Transactional(readOnly = true)
public List<User> listActive() {
return userRepo.findByActiveTrue(); // 조회만 → 읽기 전용으로
}7. 동적 쿼리가 필요할 때 → QueryDSL
검색 화면처럼 조건이 런타임에 달라지는 경우(키워드가 있을 때만 LIKE, 상태 필터가 있을 때만 추가 등)는
메서드 이름이나 고정된 @Query 로 표현하기 어렵습니다. 조건 조합 수만큼 메서드를 만들 수도 없고요.
이때는 QueryDSL? 로 조건을 코드로 조립합니다.
var q = queryFactory.selectFrom(user); if (filter.keyword() != null) q.where(user.name.containsIgnoreCase(filter.keyword())); // 있을 때만 조건 추가 if (filter.activeOnly()) q.where(user.active.isTrue()); return q.fetch(); // 타입 안전 + 동적 조립
그래서 실제 프로젝트(study-backend)는 역할을 나눕니다: 단순 CRUD·이름 쿼리는 core/repo 의
Spring Data JPA 리포지토리로, 동적 조회는 core/query 의 QueryDSL로요.
예제 — 한 인터페이스로 모아 보기
public interface UserRepo extends JpaRepository<User, Long> {
// 파생 쿼리
Optional<User> findByUserid(String userid);
List<User> findByNameContainingIgnoreCase(String keyword);
boolean existsByUserid(String userid);
long countByActiveTrue();
List<User> findTop5ByOrderByCreatedAtDesc();
// @Query (JPQL + 이름 바인딩). LIKE 와일드카드는 concat 으로 붙인다
// (JPQL 에서 %:kw% 처럼 직접 붙이는 표기는 표준이 아니라 오류가 난다)
@Query("select u from User u where u.active = true and u.name like concat('%', :kw, '%')")
List<User> searchActive(@Param("kw") String kw);
// 페이징 + DTO 프로젝션
@Query("select new com.example.study.web.dto.UserDTO(u.userid, u.name) from User u where u.active = true")
Page<UserDTO> pageActive(Pageable pageable);
}한눈에 — 쿼리 메서드 키워드 카탈로그
| 분류 | 키워드 | 의미 |
|---|---|---|
| 주제 | find/read/get/query…By | 조회 |
count…By | 개수 | |
exists…By | 존재 여부 | |
delete…By | 삭제 | |
Top/First(N) | 결과 N개 제한 | |
Distinct | 중복 제거 | |
OrderBy…Asc/Desc | 정렬 | |
| 논리 | And | 그리고 |
Or | 또는 | |
Not | <> (같지 않음) | |
Is, Equals | 같음 = | |
| 비교 | Between | 범위 사이 |
LessThan / LessThanEqual | < / <= | |
GreaterThan / GreaterThanEqual | > / >= | |
After / Before | 이후 / 이전(날짜) | |
In / NotIn | 목록 포함 / 미포함 | |
True / False | 참 / 거짓 | |
| 문자/널 | Like / NotLike | 패턴 일치 / 불일치 |
StartingWith | ~로 시작 | |
EndingWith | ~로 끝 | |
Containing | ~를 포함 | |
IgnoreCase | 대소문자 무시 | |
IsNull / IsNotNull | 널 / 널 아님 |
리포지토리 제공 메서드
| 제공 인터페이스 | 메서드 | 설명 |
|---|---|---|
CrudRepository | save(entity) | 저장/수정(있으면 update) |
saveAll(entities) | 여러 개 저장 | |
findById(id) | PK로 1건 → Optional | |
existsById(id) | PK 존재 여부 → boolean | |
findAll() | 전체 조회 | |
count() | 전체 개수 | |
deleteById(id) | PK로 삭제 | |
delete(entity) | 엔티티 삭제 | |
deleteAll() | 전체 삭제 | |
PagingAndSortingRepository | findAll(Sort) | 정렬 조회 |
findAll(Pageable) | 페이지 조회 → Page | |
JpaRepository | flush() | 영속성 컨텍스트를 DB에 즉시 반영 |
saveAndFlush(entity) | 저장 후 즉시 flush | |
deleteAllInBatch() | 한 번의 쿼리로 일괄 삭제 | |
getReferenceById(id) | 실제 로딩 없이 참조(프록시)만 |
이 프로젝트는 데이터 접근을 둘로 나눠 둡니다. 단순 CRUD·이름 쿼리·@Query 는
core/repo 의 Spring Data JPA 리포지토리? 로,
조건이 동적으로 바뀌는 복잡한 조회는 core/query 의 QueryDSL? 로 처리해요.
실제 코드와 더 깊은 내용은 be-04 데이터 접근 에서 다룹니다.
다음 단계
- JPQL 문법을 더 깊이 → JPA · JPQL
- JPA 개요로 돌아가기 → JPA
- 실제 코드로 배우는 데이터 접근 → be-04 데이터 접근