이 글에서 얻는 것
- JpaRepository 인터페이스만으로 CRUD를 자동 구현할 수 있습니다.
- 쿼리 메서드(메서드 이름으로 쿼리 생성)를 활용해 간단한 조회를 작성합니다.
- @Query로 복잡한 JPQL/네이티브 쿼리를 작성합니다.
- Specification으로 동적 쿼리를 타입 세이프하게 구현합니다.
0) Spring Data JPA는 “반복적인 데이터 접근 코드"를 자동화한다
전통적인 JPA (EntityManager 직접 사용):
@Repository
public class UserRepository {
@PersistenceContext
private EntityManager em;
public User save(User user) {
em.persist(user);
return user;
}
public Optional<User> findById(Long id) {
return Optional.ofNullable(em.find(User.class, id));
}
public List<User> findAll() {
return em.createQuery("SELECT u FROM User u", User.class)
.getResultList();
}
public void delete(User user) {
em.remove(user);
}
}
Spring Data JPA (인터페이스만 정의):
public interface UserRepository extends JpaRepository<User, Long> {
// 구현체가 자동 생성됨!
}
1) JpaRepository 인터페이스
1-1) 기본 CRUD 메서드
public interface UserRepository extends JpaRepository<User, Long> {
// 인터페이스만 정의, 구현체는 Spring Data JPA가 자동 생성
}
// 사용
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void examples() {
// 1. 저장
User user = new User("Alice", "alice@example.com");
userRepository.save(user); // INSERT or UPDATE
// 2. 조회
Optional<User> found = userRepository.findById(1L);
User user1 = userRepository.getReferenceById(1L); // 프록시 반환
// 3. 존재 확인
boolean exists = userRepository.existsById(1L);
// 4. 목록 조회
List<User> all = userRepository.findAll();
List<User> someUsers = userRepository.findAllById(Arrays.asList(1L, 2L, 3L));
// 5. 개수
long count = userRepository.count();
// 6. 삭제
userRepository.deleteById(1L);
userRepository.delete(user);
userRepository.deleteAll();
}
}
1-2) JpaRepository 계층 구조
Repository (마커 인터페이스)
↑
CrudRepository (기본 CRUD)
↑
PagingAndSortingRepository (페이징/정렬)
↑
JpaRepository (JPA 특화 기능: flush, batch 등)
2) 쿼리 메서드: 메서드 이름으로 쿼리 자동 생성
2-1) 기본 규칙
public interface UserRepository extends JpaRepository<User, Long> {
// findBy + 필드명
List<User> findByName(String name);
// SELECT u FROM User u WHERE u.name = ?1
Optional<User> findByEmail(String email);
// SELECT u FROM User u WHERE u.email = ?1
// And 조건
List<User> findByNameAndAge(String name, int age);
// SELECT u FROM User u WHERE u.name = ?1 AND u.age = ?2
// Or 조건
List<User> findByNameOrEmail(String name, String email);
// SELECT u FROM User u WHERE u.name = ?1 OR u.email = ?2
// 비교 연산자
List<User> findByAgeGreaterThan(int age);
// WHERE age > ?1
List<User> findByAgeGreaterThanEqual(int age);
// WHERE age >= ?1
List<User> findByAgeLessThan(int age);
// WHERE age < ?1
List<User> findByAgeBetween(int start, int end);
// WHERE age BETWEEN ?1 AND ?2
// LIKE
List<User> findByNameContaining(String keyword);
// WHERE name LIKE '%?1%'
List<User> findByNameStartingWith(String prefix);
// WHERE name LIKE '?1%'
List<User> findByNameEndingWith(String suffix);
// WHERE name LIKE '%?1'
// IN
List<User> findByIdIn(List<Long> ids);
// WHERE id IN (?1)
// NULL 체크
List<User> findByEmailIsNull();
// WHERE email IS NULL
List<User> findByEmailIsNotNull();
// WHERE email IS NOT NULL
// 정렬
List<User> findByNameOrderByAgeDesc(String name);
// WHERE name = ?1 ORDER BY age DESC
// 상위 N개
List<User> findTop5ByOrderByCreatedAtDesc();
// ORDER BY created_at DESC LIMIT 5
User findFirstByOrderByIdDesc();
// ORDER BY id DESC LIMIT 1
// EXISTS
boolean existsByEmail(String email);
// SELECT COUNT(*) > 0 FROM User WHERE email = ?1
// COUNT
long countByStatus(String status);
// SELECT COUNT(*) FROM User WHERE status = ?1
// DELETE
void deleteByStatus(String status);
// DELETE FROM User WHERE status = ?1
}
2-2) 페이징과 정렬
public interface UserRepository extends JpaRepository<User, Long> {
// Pageable로 페이징
Page<User> findByStatus(String status, Pageable pageable);
// Slice (다음 페이지 존재 여부만)
Slice<User> findByAgeGreaterThan(int age, Pageable pageable);
// Sort로 정렬
List<User> findByStatus(String status, Sort sort);
}
// 사용
@Service
public class UserService {
private final UserRepository userRepository;
public Page<User> getUsers(int page, int size) {
// 페이징 + 정렬
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return userRepository.findByStatus("ACTIVE", pageable);
}
public List<User> getSortedUsers() {
// 정렬만
Sort sort = Sort.by(Sort.Order.desc("age"), Sort.Order.asc("name"));
return userRepository.findByStatus("ACTIVE", sort);
}
}
3) @Query: 직접 쿼리 작성
3-1) JPQL 쿼리
public interface UserRepository extends JpaRepository<User, Long> {
// JPQL (엔티티 기반)
@Query("SELECT u FROM User u WHERE u.name = :name")
List<User> findUsersByName(@Param("name") String name);
// 복잡한 조건
@Query("SELECT u FROM User u WHERE u.age > :minAge AND u.status = :status")
List<User> findActiveUsers(@Param("minAge") int minAge, @Param("status") String status);
// JOIN
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findOrdersWithUser(@Param("status") String status);
// DTO 프로젝션
@Query("SELECT new com.example.dto.UserDTO(u.id, u.name, u.email) " +
"FROM User u WHERE u.status = :status")
List<UserDTO> findUserDTOs(@Param("status") String status);
// 집계
@Query("SELECT COUNT(u) FROM User u WHERE u.status = :status")
long countByStatus(@Param("status") String status);
// 서브쿼리
@Query("SELECT u FROM User u WHERE u.id IN " +
"(SELECT o.user.id FROM Order o WHERE o.amount > :amount)")
List<User> findUsersWithHighValueOrders(@Param("amount") int amount);
}
3-2) 네이티브 쿼리
public interface UserRepository extends JpaRepository<User, Long> {
// Native SQL
@Query(value = "SELECT * FROM users WHERE name = :name", nativeQuery = true)
List<User> findByNameNative(@Param("name") String name);
// 복잡한 Native SQL (통계)
@Query(value = """
SELECT u.city, COUNT(*) as user_count
FROM users u
WHERE u.status = :status
GROUP BY u.city
HAVING COUNT(*) > :minCount
ORDER BY user_count DESC
""", nativeQuery = true)
List<Object[]> getCityStatistics(@Param("status") String status,
@Param("minCount") int minCount);
// DTO 매핑 (Spring 3.0+)
@Query(value = "SELECT id, name, email FROM users WHERE status = :status",
nativeQuery = true)
List<UserDTO> findUserDTOsNative(@Param("status") String status);
}
3-3) 수정 쿼리
public interface UserRepository extends JpaRepository<User, Long> {
@Modifying // 수정/삭제 쿼리임을 표시
@Query("UPDATE User u SET u.status = :status WHERE u.lastLoginAt < :date")
int updateInactiveUsers(@Param("status") String status, @Param("date") LocalDateTime date);
@Modifying
@Query("DELETE FROM User u WHERE u.status = :status AND u.deletedAt < :date")
int deleteOldUsers(@Param("status") String status, @Param("date") LocalDateTime date);
// clearAutomatically: 영속성 컨텍스트 초기화
@Modifying(clearAutomatically = true)
@Query("UPDATE User u SET u.name = :newName WHERE u.id = :id")
int updateUserName(@Param("id") Long id, @Param("newName") String newName);
}
// 사용 시 @Transactional 필수
@Service
public class UserService {
private final UserRepository userRepository;
@Transactional
public void deactivateInactiveUsers() {
LocalDateTime threshold = LocalDateTime.now().minusMonths(6);
int updated = userRepository.updateInactiveUsers("INACTIVE", threshold);
System.out.println("Updated: " + updated);
}
}
4) Specification: 동적 쿼리
4-1) JpaSpecificationExecutor 상속
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {
}
4-2) Specification 작성
public class UserSpecification {
// 이름으로 검색
public static Specification<User> hasName(String name) {
return (root, query, cb) ->
name == null ? null : cb.equal(root.get("name"), name);
}
// 나이 범위
public static Specification<User> ageGreaterThan(Integer age) {
return (root, query, cb) ->
age == null ? null : cb.greaterThan(root.get("age"), age);
}
// 이메일 포함
public static Specification<User> emailContains(String keyword) {
return (root, query, cb) ->
keyword == null ? null : cb.like(root.get("email"), "%" + keyword + "%");
}
// 상태
public static Specification<User> hasStatus(String status) {
return (root, query, cb) ->
status == null ? null : cb.equal(root.get("status"), status);
}
}
// 사용
@Service
public class UserService {
private final UserRepository userRepository;
public List<User> searchUsers(String name, Integer minAge, String email, String status) {
// 동적으로 조건 조합
Specification<User> spec = Specification.where(null);
if (name != null) {
spec = spec.and(UserSpecification.hasName(name));
}
if (minAge != null) {
spec = spec.and(UserSpecification.ageGreaterThan(minAge));
}
if (email != null) {
spec = spec.and(UserSpecification.emailContains(email));
}
if (status != null) {
spec = spec.and(UserSpecification.hasStatus(status));
}
return userRepository.findAll(spec);
}
public Page<User> searchUsersWithPaging(String name, Integer minAge, Pageable pageable) {
Specification<User> spec = Specification.where(UserSpecification.hasName(name))
.and(UserSpecification.ageGreaterThan(minAge));
return userRepository.findAll(spec, pageable);
}
}
5) Projections: 필요한 필드만 조회
5-1) Interface 기반 Projection
// Projection 인터페이스
public interface UserSummary {
Long getId();
String getName();
String getEmail();
}
public interface UserRepository extends JpaRepository<User, Long> {
List<UserSummary> findByStatus(String status);
// SELECT u.id, u.name, u.email FROM User u WHERE u.status = ?1
}
// 사용
List<UserSummary> summaries = userRepository.findByStatus("ACTIVE");
summaries.forEach(s -> System.out.println(s.getName() + " - " + s.getEmail()));
5-2) Class 기반 Projection (DTO)
// DTO 클래스
public class UserDTO {
private Long id;
private String name;
private String email;
public UserDTO(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// Getters
}
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT new com.example.dto.UserDTO(u.id, u.name, u.email) " +
"FROM User u WHERE u.status = :status")
List<UserDTO> findUserDTOs(@Param("status") String status);
}
6) 실전 패턴
6-1) 동적 검색 API
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/search")
public Page<User> searchUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) Integer minAge,
@RequestParam(required = false) String email,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Specification<User> spec = Specification.where(null);
if (name != null) {
spec = spec.and((root, query, cb) ->
cb.like(root.get("name"), "%" + name + "%"));
}
if (minAge != null) {
spec = spec.and((root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("age"), minAge));
}
if (email != null) {
spec = spec.and((root, query, cb) ->
cb.like(root.get("email"), "%" + email + "%"));
}
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return userRepository.findAll(spec, pageable);
}
}
6-2) 배치 처리
@Service
public class UserBatchService {
private final UserRepository userRepository;
@Transactional
public void bulkUpdate() {
// 대량 수정
LocalDateTime threshold = LocalDateTime.now().minusMonths(6);
int updated = userRepository.updateInactiveUsers("INACTIVE", threshold);
}
@Transactional
public void bulkInsert(List<User> users) {
// 배치 저장
userRepository.saveAll(users);
userRepository.flush(); // 즉시 DB 반영
}
}
7) 자주 하는 실수
❌ 실수 1: N+1 문제
// ❌ N+1 발생
List<Order> orders = orderRepository.findAll();
orders.forEach(order ->
System.out.println(order.getUser().getName()) // N번 쿼리 발생
);
// ✅ Fetch Join 사용
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
❌ 실수 2: @Modifying 없이 UPDATE
// ❌ 에러 발생
@Query("UPDATE User u SET u.status = 'INACTIVE'")
int deactivateAll();
// ✅ @Modifying 필수
@Modifying
@Query("UPDATE User u SET u.status = 'INACTIVE'")
int deactivateAll();
❌ 실수 3: 트랜잭션 없이 수정 쿼리
// ❌ 트랜잭션 없음
public void updateUsers() {
userRepository.updateInactiveUsers("INACTIVE", LocalDateTime.now());
// 실행 안 됨!
}
// ✅ @Transactional 필수
@Transactional
public void updateUsers() {
userRepository.updateInactiveUsers("INACTIVE", LocalDateTime.now());
}
연습 (추천)
기본 Repository 구현
- Entity 생성 (Post, Comment)
- JpaRepository 상속
- 쿼리 메서드 작성
동적 검색 기능
- Specification으로 동적 필터
- 페이징/정렬 적용
성능 최적화
- Projection으로 필요한 필드만 조회
- Fetch Join으로 N+1 해결
요약: 스스로 점검할 것
- JpaRepository의 기본 CRUD 메서드를 사용할 수 있다
- 쿼리 메서드 이름 규칙을 이해하고 활용할 수 있다
- @Query로 JPQL/네이티브 쿼리를 작성할 수 있다
- Specification으로 동적 쿼리를 구현할 수 있다
- Projection으로 필요한 필드만 조회할 수 있다
- N+1 문제를 인지하고 해결할 수 있다
다음 단계
- JPA N+1 문제:
/learning/deep-dive/deep-dive-jpa-n-plus-1/ - JPA 트랜잭션 경계:
/learning/deep-dive/deep-dive-jpa-transaction-boundaries/ - QueryDSL:
/learning/deep-dive/deep-dive-querydsl-basics/
💬 댓글