프레임워크 · JPA · 리포지토리 · 쿼리 메서드

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/queryQueryDSL? 로 나눠 둡니다.

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개수 세기 → longcountByActiveTrue
exists…By존재 여부 → booleanexistsByUserid
delete…By삭제(파생 삭제)deleteByUserid

find·read·get·query 는 모두 같은 "조회"입니다. 읽기 좋은 걸로 고르면 돼요.

조건 키워드 카탈로그 — 어떤 기준으로

By 뒤에 속성 이름과 조건 키워드를 붙입니다. 아래가 공식 문서에 정리된 키워드 전부예요.

키워드메서드 예생성되는 JPQL 조건
AndfindByLastnameAndFirstnamewhere x.lastname = ?1 and x.firstname = ?2
OrfindByLastnameOrFirstnamewhere x.lastname = ?1 or x.firstname = ?2
Is, EqualsfindByFirstname(Is/Equals)where x.firstname = ?1
BetweenfindByStartDateBetweenwhere x.startDate between ?1 and ?2
LessThanfindByAgeLessThanwhere x.age < ?1
LessThanEqualfindByAgeLessThanEqualwhere x.age <= ?1
GreaterThanfindByAgeGreaterThanwhere x.age > ?1
GreaterThanEqualfindByAgeGreaterThanEqualwhere x.age >= ?1
AfterfindByStartDateAfterwhere x.startDate > ?1
BeforefindByStartDateBeforewhere x.startDate < ?1
IsNull, NullfindByAge(Is)Nullwhere x.age is null
IsNotNull, NotNullfindByAge(Is)NotNullwhere x.age is not null
LikefindByFirstnameLikewhere x.firstname like ?1
NotLikefindByFirstnameNotLikewhere x.firstname not like ?1
StartingWithfindByFirstnameStartingWithlike ?1 (값 뒤에 % 자동 부착)
EndingWithfindByFirstnameEndingWithlike ?1 (값 앞에 % 자동 부착)
ContainingfindByFirstnameContaininglike ?1 (값 양쪽에 % 자동 부착)
InfindByAgeIn(Collection)where x.age in ?1
NotInfindByAgeNotIn(Collection)where x.age not in ?1
TruefindByActiveTrue()where x.active = true
FalsefindByActiveFalse()where x.active = false
Empty / NotEmptyfindByRolesIsEmpty()컬렉션 연관이 비었는지 / 안 비었는지
DistinctfindDistinctByLastnameselect distinct … (중복 행 제거)
IgnoreCasefindByFirstnameIgnoreCasewhere UPPER(x.firstname) = UPPER(?1)
OrderBy…Asc/DescfindByAgeOrderByLastnameDesc… order by x.lastname desc
NotfindByLastnameNotwhere x.lastname <> ?1
Top / First (N)findTop3ByOrderByAgeDesc결과를 N개로 제한(숫자 생략 시 1개)
Top 과 First 는 같은 뜻

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

@QueryUPDATE/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 = 변경된 행 수
clearAutomatically 가 필요한 이유

수정 쿼리는 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 와 읽기 전용 조회

연관 엔티티(예: Userroles)를 지연 로딩으로 두면, 반복 접근 때 쿼리가 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널 / 널 아님

리포지토리 제공 메서드

제공 인터페이스메서드설명
CrudRepositorysave(entity)저장/수정(있으면 update)
saveAll(entities)여러 개 저장
findById(id)PK로 1건 → Optional
existsById(id)PK 존재 여부 → boolean
findAll()전체 조회
count()전체 개수
deleteById(id)PK로 삭제
delete(entity)엔티티 삭제
deleteAll()전체 삭제
PagingAndSortingRepositoryfindAll(Sort)정렬 조회
findAll(Pageable)페이지 조회 → Page
JpaRepositoryflush()영속성 컨텍스트를 DB에 즉시 반영
saveAndFlush(entity)저장 후 즉시 flush
deleteAllInBatch()한 번의 쿼리로 일괄 삭제
getReferenceById(id)실제 로딩 없이 참조(프록시)만
이 프로젝트와의 관계

이 프로젝트는 데이터 접근을 둘로 나눠 둡니다. 단순 CRUD·이름 쿼리·@Querycore/repo 의 Spring Data JPA 리포지토리? 로, 조건이 동적으로 바뀌는 복잡한 조회는 core/queryQueryDSL? 로 처리해요. 실제 코드와 더 깊은 내용은 be-04 데이터 접근 에서 다룹니다.

다음 단계