Spring Data JPA는 JPA 기반의 데이터 접근 계층을 쉽게 구현할 수 있도록 도와주는 프레임워크이다. Repository 인터페이스만 정의하면 Spring이 런타임에 구현체를 자동으로 생성해준다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private LocalDateTime createdAt;
// constructors, getters, setters
}
public interface UserRepository extends JpaRepository<User, Long> {
// 메서드 정의만으로 구현체가 자동 생성됨
List<User> findByUsername(String username);
Optional<User> findByEmail(String email);
}
Spring Data JPA의 Repository는 다음과 같은 계층 구조를 가진다.
// 1. Repository - 마커 인터페이스
public interface Repository<T, ID> {
// 마커 인터페이스로 메서드가 없음
}
// 2. CrudRepository - 기본 CRUD 기능
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> saveAll(Iterable<S> entities);
Optional<T> findById(ID id);
boolean existsById(ID id);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> ids);
long count();
void deleteById(ID id);
void delete(T entity);
void deleteAllById(Iterable<? extends ID> ids);
void deleteAll(Iterable<? extends T> entities);
void deleteAll();
}
// 3. PagingAndSortingRepository - 페이징과 정렬 기능
public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> {
Iterable<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
}
// 4. JpaRepository - JPA 특화 기능
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
List<T> findAll();
List<T> findAll(Sort sort);
List<T> findAllById(Iterable<ID> ids);
<S extends T> List<S> saveAll(Iterable<S> entities);
void flush();
<S extends T> S saveAndFlush(S entity);
<S extends T> List<S> saveAllAndFlush(Iterable<S> entities);
void deleteAllInBatch(Iterable<T> entities);
void deleteAllByIdInBatch(Iterable<ID> ids);
void deleteAllInBatch();
T getOne(ID id);
T getById(ID id);
T getReferenceById(ID id);
<S extends T> List<S> findAll(Example<S> example);
<S extends T> List<S> findAll(Example<S> example, Sort sort);
}
마커 인터페이스로 Spring Data가 Repository임을 인식하는 용도이다.
기본적인 CRUD(Create, Read, Update, Delete) 기능을 제공한다.
CrudRepository의 기능에 페이징과 정렬 기능을 추가한다.
JPA에 특화된 기능들을 추가로 제공한다. 배치 삭제, flush, Query By Example 등의 기능이 있다.
Spring Data JPA는 프록시 패턴을 사용하여 런타임에 Repository 구현체를 생성한다. 인터페이스를 사용하면 프록시 객체를 쉽게 생성할 수 있다.
// Spring이 런타임에 생성하는 프록시 구현체 (개념적 표현)
public class UserRepositoryImpl implements UserRepository {
private EntityManager entityManager;
@Override
public List<User> findByUsername(String username) {
// Spring Data JPA가 메서드 이름을 분석하여 쿼리 생성
return entityManager.createQuery(
"SELECT u FROM User u WHERE u.username = :username", User.class)
.setParameter("username", username)
.getResultList();
}
}
인터페이스의 메서드 이름을 분석하여 자동으로 쿼리를 생성한다.
public interface UserRepository extends JpaRepository<User, Long> {
// findBy + 필드명 패턴
List<User> findByUsername(String username);
// findBy + 필드명 + And + 필드명 패턴
List<User> findByUsernameAndEmail(String username, String email);
// findBy + 필드명 + Or + 필드명 패턴
List<User> findByUsernameOrEmail(String usernameOrEmail);
// 조건 키워드 사용
List<User> findByAgeGreaterThan(Integer age);
List<User> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
}
구체적인 JPA 구현체(Hibernate, EclipseLink 등)에 의존하지 않고 추상화된 인터페이스를 제공한다.
Spring Data JPA는 다음 과정을 통해 Repository 구현체를 생성한다.
@SpringBootApplication
@EnableJpaRepositories(basePackages = "com.example.repository")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// Spring 내부에서 수행되는 과정 (개념적)
public class JpaRepositoryFactoryBean<T extends Repository<S, ID>, S, ID>
extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> {
@Override
protected RepositoryFactorySupport doCreateRepositoryFactory() {
return createRepositoryFactory(entityManager);
}
protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) {
return new JpaRepositoryFactory(entityManager);
}
}
// JpaRepositoryFactory에서 프록시 생성
public class JpaRepositoryFactory extends RepositoryFactorySupport {
@Override
protected Object getTargetRepository(RepositoryInformation information) {
SimpleJpaRepository<?, ?> repository = getTargetRepository(information, entityManager);
repository.setRepositoryMethodMetadata(crudMethodMetadata);
return repository;
}
@Override
protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
return SimpleJpaRepository.class;
}
}
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private final EntityManager em;
private final JpaEntityInformation<T, ?> entityInformation;
@Override
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
@Override
public Optional<T> findById(ID id) {
Class<T> domainType = getDomainClass();
if (metadata == null) {
return Optional.ofNullable(em.find(domainType, id));
}
LockModeType type = metadata.getLockModeType();
Map<String, Object> hints = getQueryHints().withFetchGraphs(em).asMap();
return Optional.ofNullable(type == null ? em.find(domainType, id, hints)
: em.find(domainType, id, type, hints));
}
}
public interface UserRepository extends JpaRepository<User, Long> {
// 이미 제공되는 기본 메서드들
}
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// 1. 저장
public User createUser(User user) {
return userRepository.save(user); // insert 또는 update
}
// 2. 조회
public Optional<User> getUser(Long id) {
return userRepository.findById(id);
}
public List<User> getAllUsers() {
return userRepository.findAll();
}
// 3. 존재 확인
public boolean userExists(Long id) {
return userRepository.existsById(id);
}
// 4. 개수 세기
public long getUserCount() {
return userRepository.count();
}
// 5. 삭제
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
public void deleteUser(User user) {
userRepository.delete(user);
}
public void deleteAllUsers() {
userRepository.deleteAll();
}
}
@Service
public class UserService {
private final UserRepository userRepository;
// 배치 저장
public List<User> createUsers(List<User> users) {
return userRepository.saveAll(users);
}
// 즉시 DB 반영
public User createUserAndFlush(User user) {
return userRepository.saveAndFlush(user);
}
// 영속성 컨텍스트 동기화
public void flushChanges() {
userRepository.flush();
}
// 배치 삭제 (성능 최적화)
public void deleteUsersInBatch(List<User> users) {
userRepository.deleteAllInBatch(users);
}
// 지연 로딩을 위한 레퍼런스 조회
public User getUserReference(Long id) {
return userRepository.getReferenceById(id); // 프록시 객체 반환
}
}
@Service
public class UserService {
private final UserRepository userRepository;
// 정렬
public List<User> getUsersSortedByUsername() {
return userRepository.findAll(Sort.by("username"));
}
public List<User> getUsersSortedByMultipleFields() {
return userRepository.findAll(
Sort.by("createdAt").descending()
.and(Sort.by("username").ascending())
);
}
// 페이징
public Page<User> getUsersWithPaging(int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return userRepository.findAll(pageable);
}
public Page<User> getUsersWithPagingAndSorting(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by("createdAt").descending());
return userRepository.findAll(pageable);
}
}
Spring Data JPA는 메서드 이름을 분석하여 자동으로 쿼리를 생성한다.
public interface UserRepository extends JpaRepository<User, Long> {
// 1. 단순 조건
List<User> findByUsername(String username);
List<User> findByEmail(String email);
Optional<User> findByUsernameAndEmail(String username, String email);
// 2. 비교 연산자
List<User> findByAgeGreaterThan(Integer age);
List<User> findByAgeGreaterThanEqual(Integer age);
List<User> findByAgeLessThan(Integer age);
List<User> findByAgeLessThanEqual(Integer age);
List<User> findByAgeBetween(Integer minAge, Integer maxAge);
// 3. 문자열 검색
List<User> findByUsernameStartingWith(String prefix);
List<User> findByUsernameEndingWith(String suffix);
List<User> findByUsernameContaining(String keyword);
List<User> findByUsernameIgnoreCase(String username);
List<User> findByUsernameContainingIgnoreCase(String keyword);
// 4. 날짜 조건
List<User> findByCreatedAtAfter(LocalDateTime date);
List<User> findByCreatedAtBefore(LocalDateTime date);
List<User> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
// 5. null 체크
List<User> findByEmailIsNull();
List<User> findByEmailIsNotNull();
// 6. 컬렉션 검색
List<User> findByUsernameIn(Collection<String> usernames);
List<User> findByUsernameNotIn(Collection<String> usernames);
// 7. Boolean 조건
List<User> findByActiveTrue();
List<User> findByActiveFalse();
List<User> findByActive(Boolean active);
}
public interface UserRepository extends JpaRepository<User, Long> {
// AND 조건
List<User> findByUsernameAndEmailAndActive(String username, String email, Boolean active);
// OR 조건
List<User> findByUsernameOrEmail(String usernameOrEmail);
// 복합 조건
List<User> findByUsernameContainingAndAgeGreaterThanAndActiveTrue(String keyword, Integer age);
// NOT 조건
List<User> findByUsernameNot(String username);
List<User> findByAgeNot(Integer age);
}
public interface UserRepository extends JpaRepository<User, Long> {
// 정렬
List<User> findByActiveOrderByCreatedAtDesc(Boolean active);
List<User> findByActiveOrderByUsernameAscCreatedAtDesc(Boolean active);
// 결과 제한
User findFirstByOrderByCreatedAtDesc();
User findTopByOrderByCreatedAtDesc();
List<User> findFirst3ByOrderByCreatedAtDesc();
List<User> findTop10ByActiveOrderByCreatedAtDesc(Boolean active);
// 페이징 적용
Page<User> findByActive(Boolean active, Pageable pageable);
Slice<User> findByUsernameContaining(String keyword, Pageable pageable);
}
| 키워드 | 샘플 | JPQL |
|---|---|---|
| And | findByLastnameAndFirstname | … where x.lastname = ?1 and x.firstname = ?2 |
| Or | findByLastnameOrFirstname | … where x.lastname = ?1 or x.firstname = ?2 |
| Is, Equals | findByFirstname, findByFirstnameIs, findByFirstnameEquals | … 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 not null |
| Like | findByFirstnameLike | … where x.firstname like ?1 |
| NotLike | findByFirstnameNotLike | … where x.firstname not like ?1 |
| StartingWith | findByFirstnameStartingWith | … where x.firstname like ?1 (parameter bound with appended %) |
| EndingWith | findByFirstnameEndingWith | … where x.firstname like ?1 (parameter bound with prepended %) |
| Containing | findByFirstnameContaining | … where x.firstname like ?1 (parameter bound wrapped in %) |
| OrderBy | findByAgeOrderByLastnameDesc | … where x.age = ?1 order by x.lastname desc |
| Not | findByLastnameNot | … where x.lastname <> ?1 |
| In | findByAgeIn(Collection ages) | … where x.age in ?1 |
| NotIn | findByAgeNotIn(Collection ages) | … where x.age not in ?1 |
| True | findByActiveTrue() | … where x.active = true |
| False | findByActiveFalse() | … where x.active = false |
| IgnoreCase | findByFirstnameIgnoreCase | … where UPPER(x.firstname) = UPPER(?1) |
Named Query는 엔티티 클래스나 외부 파일에 미리 정의된 쿼리를 사용하는 방법이다.
@Entity
@NamedQuery(
name = "User.findByEmailDomain",
query = "SELECT u FROM User u WHERE u.email LIKE CONCAT('%@', :domain)"
)
@NamedQueries({
@NamedQuery(
name = "User.findActiveUsers",
query = "SELECT u FROM User u WHERE u.active = true ORDER BY u.createdAt DESC"
),
@NamedQuery(
name = "User.findByAgeRange",
query = "SELECT u FROM User u WHERE u.age BETWEEN :minAge AND :maxAge"
)
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private Integer age;
private Boolean active;
private LocalDateTime createdAt;
// constructors, getters, setters
}
public interface UserRepository extends JpaRepository<User, Long> {
// 메서드 이름이 Named Query와 일치하면 자동으로 매핑됨
List<User> findByEmailDomain(@Param("domain") String domain);
List<User> findActiveUsers();
List<User> findByAgeRange(@Param("minAge") Integer minAge, @Param("maxAge") Integer maxAge);
// 명시적으로 Named Query 지정
@Query(name = "User.findActiveUsers")
List<User> getActiveUsers();
}
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm
http://java.sun.com/xml/ns/persistence/orm_2_0.xsd"
version="2.0">
<named-query name="User.findByCreatedAtRange">
<query>
SELECT u FROM User u
WHERE u.createdAt BETWEEN :startDate AND :endDate
ORDER BY u.createdAt DESC
</query>
</named-query>
<named-query name="User.countActiveUsers">
<query>
SELECT COUNT(u) FROM User u WHERE u.active = true
</query>
</named-query>
</entity-mappings>
@Entity
@NamedNativeQuery(
name = "User.findUserStatistics",
query = "SELECT " +
"COUNT(*) as total_users, " +
"COUNT(CASE WHEN active = true THEN 1 END) as active_users, " +
"AVG(age) as average_age " +
"FROM users",
resultSetMapping = "UserStatisticsMapping"
)
@SqlResultSetMapping(
name = "UserStatisticsMapping",
classes = @ConstructorResult(
targetClass = UserStatistics.class,
columns = {
@ColumnResult(name = "total_users", type = Long.class),
@ColumnResult(name = "active_users", type = Long.class),
@ColumnResult(name = "average_age", type = Double.class)
}
)
)
public class User {
// 필드들...
}
public class UserStatistics {
private Long totalUsers;
private Long activeUsers;
private Double averageAge;
public UserStatistics(Long totalUsers, Long activeUsers, Double averageAge) {
this.totalUsers = totalUsers;
this.activeUsers = activeUsers;
this.averageAge = averageAge;
}
// getters, setters
}
@Query 어노테이션을 사용하면 JPQL이나 Native SQL을 직접 작성할 수 있다.
public interface UserRepository extends JpaRepository<User, Long> {
// 기본 JPQL
@Query("SELECT u FROM User u WHERE u.username = ?1")
Optional<User> findByUsernameJPQL(String username);
// Named Parameter 사용
@Query("SELECT u FROM User u WHERE u.username = :username AND u.active = :active")
List<User> findByUsernameAndActive(@Param("username") String username,
@Param("active") Boolean active);
// 복잡한 조건
@Query("SELECT u FROM User u WHERE " +
"(:username IS NULL OR u.username LIKE %:username%) AND " +
"(:email IS NULL OR u.email = :email) AND " +
"(:minAge IS NULL OR u.age >= :minAge) AND " +
"(:maxAge IS NULL OR u.age <= :maxAge)")
List<User> findUsersWithDynamicConditions(@Param("username") String username,
@Param("email") String email,
@Param("minAge") Integer minAge,
@Param("maxAge") Integer maxAge);
// 집계 함수
@Query("SELECT COUNT(u) FROM User u WHERE u.active = :active")
Long countByActive(@Param("active") Boolean active);
@Query("SELECT AVG(u.age) FROM User u WHERE u.active = true")
Double getAverageAgeOfActiveUsers();
// JOIN 쿼리
@Query("SELECT u FROM User u JOIN u.roles r WHERE r.name = :roleName")
List<User> findUsersByRoleName(@Param("roleName") String roleName);
// DISTINCT
@Query("SELECT DISTINCT u.email FROM User u WHERE u.email IS NOT NULL")
List<String> findAllDistinctEmails();
}
public interface UserRepository extends JpaRepository<User, Long> {
// 기본 Native Query
@Query(value = "SELECT * FROM users WHERE username = ?1", nativeQuery = true)
Optional<User> findByUsernameNative(String username);
// 복잡한 Native Query
@Query(value = "SELECT u.* FROM users u " +
"WHERE u.created_at >= DATE_SUB(NOW(), INTERVAL :days DAY) " +
"ORDER BY u.created_at DESC",
nativeQuery = true)
List<User> findUsersCreatedInLastDays(@Param("days") Integer days);
// 페이징과 Native Query
@Query(value = "SELECT * FROM users WHERE active = :active ORDER BY created_at DESC",
countQuery = "SELECT COUNT(*) FROM users WHERE active = :active",
nativeQuery = true)
Page<User> findActiveUsersNative(@Param("active") Boolean active, Pageable pageable);
// Projection을 위한 Native Query
@Query(value = "SELECT u.username, u.email, COUNT(o.id) as order_count " +
"FROM users u LEFT JOIN orders o ON u.id = o.user_id " +
"GROUP BY u.id, u.username, u.email",
nativeQuery = true)
List<Object[]> findUsersWithOrderCount();
// DTO Projection
@Query(value = "SELECT u.username as username, u.email as email, " +
"COUNT(o.id) as orderCount " +
"FROM users u LEFT JOIN orders o ON u.id = o.user_id " +
"GROUP BY u.id",
nativeQuery = true)
List<UserOrderCountProjection> findUsersWithOrderCountProjection();
}
// Projection 인터페이스
public interface UserOrderCountProjection {
String getUsername();
String getEmail();
Long getOrderCount();
}
public interface UserRepository extends JpaRepository<User, Long> {
// 수정 쿼리
@Modifying
@Query("UPDATE User u SET u.active = :active WHERE u.id = :id")
int updateUserActive(@Param("id") Long id, @Param("active") Boolean active);
@Modifying
@Query("UPDATE User u SET u.lastLoginAt = :loginTime WHERE u.username = :username")
int updateLastLoginTime(@Param("username") String username,
@Param("loginTime") LocalDateTime loginTime);
// 삭제 쿼리
@Modifying
@Query("DELETE FROM User u WHERE u.active = false AND u.createdAt < :date")
int deleteInactiveUsersCreatedBefore(@Param("date") LocalDateTime date);
// Native 수정 쿼리
@Modifying
@Query(value = "UPDATE users SET login_count = login_count + 1 WHERE id = :id",
nativeQuery = true)
int incrementLoginCount(@Param("id") Long id);
}
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
public void deactivateUser(Long id) {
userRepository.updateUserActive(id, false);
// @Modifying 쿼리 후에는 영속성 컨텍스트를 clear하는 것이 좋음
entityManager.clear();
}
}
public interface UserRepository extends JpaRepository<User, Long> {
// 엔티티 이름을 동적으로 참조
@Query("SELECT u FROM #{#entityName} u WHERE u.username = :username")
List<User> findByUsernameWithSpEL(@Param("username") String username);
// 현재 사용자 정보 사용
@Query("SELECT u FROM User u WHERE u.createdBy = ?#{principal.username}")
List<User> findUsersCreatedByCurrentUser();
// 메서드 파라미터 표현식
@Query("SELECT u FROM User u WHERE u.username = ?#{[0].toLowerCase()}")
List<User> findByUsernameLowerCase(String username);
}
엔티티 그래프는 JPA 2.1에서 도입된 기능으로, 어떤 연관관계를 함께 로딩할지 동적으로 정의할 수 있다.
@Entity
@NamedEntityGraph(
name = "User.withRoles",
attributeNodes = @NamedAttributeNode("roles")
)
@NamedEntityGraphs({
@NamedEntityGraph(
name = "User.withRolesAndProfile",
attributeNodes = {
@NamedAttributeNode("roles"),
@NamedAttributeNode("profile")
}
),
@NamedEntityGraph(
name = "User.withDetailedProfile",
attributeNodes = {
@NamedAttributeNode(
value = "profile",
subgraph = "profile.address"
)
},
subgraphs = @NamedSubgraph(
name = "profile.address",
attributeNodes = @NamedAttributeNode("address")
)
)
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Role> roles = new HashSet<>();
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserProfile profile;
// constructors, getters, setters
}
@Entity
public class UserProfile {
@Id
private Long id;
private String firstName;
private String lastName;
private String phoneNumber;
@OneToOne
@JoinColumn(name = "user_id")
private User user;
@OneToOne(fetch = FetchType.LAZY)
private Address address;
// constructors, getters, setters
}
@Entity
public class Address {
@Id
private Long id;
private String street;
private String city;
private String zipCode;
// constructors, getters, setters
}
public interface UserRepository extends JpaRepository<User, Long> {
// @EntityGraph로 Named Entity Graph 사용
@EntityGraph("User.withRoles")
Optional<User> findWithRolesById(Long id);
@EntityGraph("User.withRolesAndProfile")
List<User> findWithRolesAndProfileByActiveTrue();
@EntityGraph("User.withDetailedProfile")
Optional<User> findWithDetailedProfileByUsername(String username);
// 동적 엔티티 그래프 (attributePaths 사용)
@EntityGraph(attributePaths = {"roles"})
List<User> findByUsername(String username);
@EntityGraph(attributePaths = {"roles", "profile"})
List<User> findByActive(Boolean active);
@EntityGraph(attributePaths = {"profile.address"})
Optional<User> findByEmail(String email);
// 복잡한 중첩 관계
@EntityGraph(attributePaths = {"roles.permissions", "profile.address"})
Optional<User> findWithCompleteDataById(Long id);
// @Query와 @EntityGraph 조합
@Query("SELECT u FROM User u WHERE u.createdAt >= :date")
@EntityGraph(attributePaths = {"roles", "profile"})
List<User> findUsersCreatedAfterWithDetails(@Param("date") LocalDateTime date);
}
public interface UserRepository extends JpaRepository<User, Long> {
// FETCH 타입 (기본값) - 명시된 속성만 즉시 로딩
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findWithRolesFetchById(Long id);
// LOAD 타입 - 명시된 속성은 즉시 로딩, 나머지는 기본 전략 사용
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.LOAD)
Optional<User> findWithRolesLoadById(Long id);
}
@Repository
public class UserRepositoryCustomImpl {
@PersistenceContext
private EntityManager entityManager;
public Optional<User> findWithDynamicGraph(Long id, List<String> attributes) {
EntityGraph<User> entityGraph = entityManager.createEntityGraph(User.class);
for (String attribute : attributes) {
entityGraph.addAttributeNodes(attribute);
}
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.fetchgraph", entityGraph);
User user = entityManager.find(User.class, id, hints);
return Optional.ofNullable(user);
}
public List<User> findWithSubgraph() {
EntityGraph<User> entityGraph = entityManager.createEntityGraph(User.class);
entityGraph.addAttributeNodes("profile");
Subgraph<UserProfile> profileSubgraph = entityGraph.addSubgraph("profile");
profileSubgraph.addAttributeNodes("address");
return entityManager.createQuery("SELECT u FROM User u", User.class)
.setHint("javax.persistence.fetchgraph", entityGraph)
.getResultList();
}
}
기본 제공 메서드로 충분하지 않을 때 커스텀 구현을 추가할 수 있다.
public interface UserRepositoryCustom {
List<User> findUsersWithComplexCondition(UserSearchCriteria criteria);
Page<User> findUsersWithDynamicQuery(UserSearchFilter filter, Pageable pageable);
List<UserStatistics> getUserStatisticsByRole();
int bulkUpdateUserStatus(List<Long> userIds, UserStatus status);
}
@Repository
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<User> findUsersWithComplexCondition(UserSearchCriteria criteria) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
List<Predicate> predicates = new ArrayList<>();
// 동적 조건 추가
if (criteria.getUsername() != null) {
predicates.add(cb.like(root.get("username"), "%" + criteria.getUsername() + "%"));
}
if (criteria.getEmail() != null) {
predicates.add(cb.equal(root.get("email"), criteria.getEmail()));
}
if (criteria.getMinAge() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("age"), criteria.getMinAge()));
}
if (criteria.getMaxAge() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("age"), criteria.getMaxAge()));
}
if (criteria.getCreatedAfter() != null) {
predicates.add(cb.greaterThan(root.get("createdAt"), criteria.getCreatedAfter()));
}
if (criteria.getRoleName() != null) {
Join<User, Role> roleJoin = root.join("roles");
predicates.add(cb.equal(roleJoin.get("name"), criteria.getRoleName()));
}
query.select(root)
.where(predicates.toArray(new Predicate[0]))
.orderBy(cb.desc(root.get("createdAt")));
return entityManager.createQuery(query).getResultList();
}
@Override
public Page<User> findUsersWithDynamicQuery(UserSearchFilter filter, Pageable pageable) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
// 메인 쿼리
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
// 카운트 쿼리
CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
Root<User> countRoot = countQuery.from(User.class);
// 공통 조건 생성
List<Predicate> predicates = buildPredicates(cb, root, filter);
List<Predicate> countPredicates = buildPredicates(cb, countRoot, filter);
// 메인 쿼리 설정
query.select(root).where(predicates.toArray(new Predicate[0]));
// 정렬 추가
if (pageable.getSort().isSorted()) {
List<Order> orders = new ArrayList<>();
for (Sort.Order sortOrder : pageable.getSort()) {
if (sortOrder.isAscending()) {
orders.add(cb.asc(root.get(sortOrder.getProperty())));
} else {
orders.add(cb.desc(root.get(sortOrder.getProperty())));
}
}
query.orderBy(orders);
}
// 카운트 쿼리 설정
countQuery.select(cb.count(countRoot)).where(countPredicates.toArray(new Predicate[0]));
// 쿼리 실행
TypedQuery<User> typedQuery = entityManager.createQuery(query);
typedQuery.setFirstResult((int) pageable.getOffset());
typedQuery.setMaxResults(pageable.getPageSize());
List<User> users = typedQuery.getResultList();
Long total = entityManager.createQuery(countQuery).getSingleResult();
return new PageImpl<>(users, pageable, total);
}
private List<Predicate> buildPredicates(CriteriaBuilder cb, Root<User> root, UserSearchFilter filter) {
List<Predicate> predicates = new ArrayList<>();
if (filter.getKeyword() != null && !filter.getKeyword().trim().isEmpty()) {
String keyword = "%" + filter.getKeyword().trim() + "%";
Predicate usernamePredicate = cb.like(root.get("username"), keyword);
Predicate emailPredicate = cb.like(root.get("email"), keyword);
predicates.add(cb.or(usernamePredicate, emailPredicate));
}
if (filter.getActive() != null) {
predicates.add(cb.equal(root.get("active"), filter.getActive()));
}
if (filter.getStartDate() != null && filter.getEndDate() != null) {
predicates.add(cb.between(root.get("createdAt"), filter.getStartDate(), filter.getEndDate()));
}
return predicates;
}
@Override
public List<UserStatistics> getUserStatisticsByRole() {
String jpql = """
SELECT new com.example.dto.UserStatistics(
r.name,
COUNT(u),
AVG(u.age),
MIN(u.createdAt),
MAX(u.createdAt)
)
FROM User u JOIN u.roles r
WHERE u.active = true
GROUP BY r.name
ORDER BY COUNT(u) DESC
""";
return entityManager.createQuery(jpql, UserStatistics.class)
.getResultList();
}
@Override
@Transactional
public int bulkUpdateUserStatus(List<Long> userIds, UserStatus status) {
String jpql = "UPDATE User u SET u.status = :status, u.updatedAt = :now WHERE u.id IN :userIds";
return entityManager.createQuery(jpql)
.setParameter("status", status)
.setParameter("now", LocalDateTime.now())
.setParameter("userIds", userIds)
.executeUpdate();
}
}
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
// JpaRepository의 기본 메서드들과 커스텀 메서드들을 모두 사용 가능
// 기본 쿼리 메서드
Optional<User> findByUsername(String username);
List<User> findByActive(Boolean active);
// @Query 사용
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);
// 커스텀 메서드들 (UserRepositoryCustom에서 정의)
// - findUsersWithComplexCondition()
// - findUsersWithDynamicQuery()
// - getUserStatisticsByRole()
// - bulkUpdateUserStatus()
}
// Specification 인터페이스 구현
public class UserSpecifications {
public static Specification<User> hasUsername(String username) {
return (root, query, criteriaBuilder) ->
username == null ? null : criteriaBuilder.equal(root.get("username"), username);
}
public static Specification<User> hasEmailDomain(String domain) {
return (root, query, criteriaBuilder) ->
domain == null ? null : criteriaBuilder.like(root.get("email"), "%" + domain);
}
public static Specification<User> isActive() {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.get("active"), true);
}
public static Specification<User> createdBetween(LocalDateTime start, LocalDateTime end) {
return (root, query, criteriaBuilder) -> {
if (start == null && end == null) return null;
if (start == null) return criteriaBuilder.lessThanOrEqualTo(root.get("createdAt"), end);
if (end == null) return criteriaBuilder.greaterThanOrEqualTo(root.get("createdAt"), start);
return criteriaBuilder.between(root.get("createdAt"), start, end);
};
}
public static Specification<User> hasRole(String roleName) {
return (root, query, criteriaBuilder) -> {
if (roleName == null) return null;
Join<User, Role> roleJoin = root.join("roles");
return criteriaBuilder.equal(roleJoin.get("name"), roleName);
};
}
}
// Repository에서 Specification 사용
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {
// JpaSpecificationExecutor가 제공하는 메서드들 자동 사용 가능
}
@Service
public class UserService {
private final UserRepository userRepository;
public List<User> findUsersWithSpecification(UserSearchRequest request) {
Specification<User> spec = Specification.where(null);
if (request.getUsername() != null) {
spec = spec.and(UserSpecifications.hasUsername(request.getUsername()));
}
if (request.getEmailDomain() != null) {
spec = spec.and(UserSpecifications.hasEmailDomain(request.getEmailDomain()));
}
if (request.isActiveOnly()) {
spec = spec.and(UserSpecifications.isActive());
}
if (request.getStartDate() != null || request.getEndDate() != null) {
spec = spec.and(UserSpecifications.createdBetween(
request.getStartDate(), request.getEndDate()));
}
if (request.getRoleName() != null) {
spec = spec.and(UserSpecifications.hasRole(request.getRoleName()));
}
return userRepository.findAll(spec);
}
public Page<User> findUsersWithSpecificationAndPaging(UserSearchRequest request, Pageable pageable) {
Specification<User> spec = buildSpecification(request);
return userRepository.findAll(spec, pageable);
}
}
@Service
public class UserService {
private final UserRepository userRepository;
public List<User> findUsersByExample(User probe) {
// Example 생성
Example<User> example = Example.of(probe);
return userRepository.findAll(example);
}
public List<User> findUsersWithMatcher(User probe) {
// ExampleMatcher를 사용한 고급 설정
ExampleMatcher matcher = ExampleMatcher.matching()
.withIgnoreCase("username") // 대소문자 무시
.withStringMatcher(StringMatcher.CONTAINING) // 포함 검색
.withIgnoreNullValues() // null 값 무시
.withIgnorePaths("id", "createdAt"); // 특정 필드 무시
Example<User> example = Example.of(probe, matcher);
return userRepository.findAll(example);
}
public Page<User> findUsersWithExampleAndPaging(User probe, Pageable pageable) {
ExampleMatcher matcher = ExampleMatcher.matching()
.withIgnoreCase()
.withStringMatcher(StringMatcher.CONTAINING);
Example<User> example = Example.of(probe, matcher);
return userRepository.findAll(example, pageable);
}
}
// 인터페이스 기반 Projection
public interface UserSummary {
String getUsername();
String getEmail();
LocalDateTime getCreatedAt();
// @Value로 계산된 값 제공
@Value("#{target.username + ' (' + target.email + ')'}")
String getDisplayName();
}
// 클래스 기반 Projection (DTO)
public class UserSummaryDto {
private String username;
private String email;
private LocalDateTime createdAt;
public UserSummaryDto(String username, String email, LocalDateTime createdAt) {
this.username = username;
this.email = email;
this.createdAt = createdAt;
}
// getters, setters
}
// 동적 Projection
public interface UserRepository extends JpaRepository<User, Long> {
// 인터페이스 기반 Projection
List<UserSummary> findByActiveTrue();
// 클래스 기반 Projection
@Query("SELECT new com.example.dto.UserSummaryDto(u.username, u.email, u.createdAt) " +
"FROM User u WHERE u.active = :active")
List<UserSummaryDto> findUserSummaries(@Param("active") Boolean active);
// 동적 Projection
<T> List<T> findByActive(Boolean active, Class<T> type);
// 복잡한 Projection
@Query("SELECT u.username as username, u.email as email, " +
"COUNT(o.id) as orderCount, SUM(o.totalAmount) as totalAmount " +
"FROM User u LEFT JOIN u.orders o " +
"GROUP BY u.id")
List<UserOrderSummary> findUserOrderSummaries();
}
public interface UserOrderSummary {
String getUsername();
String getEmail();
Long getOrderCount();
BigDecimal getTotalAmount();
}
@Service
public class UserService {
private final UserRepository userRepository;
public List<UserSummary> getActiveUserSummaries() {
return userRepository.findByActiveTrue();
}
public List<UserSummaryDto> getUserSummaryDtos() {
return userRepository.findUserSummaries(true);
}
// 동적 Projection 사용
public List<UserSummary> getUserSummariesDynamic() {
return userRepository.findByActive(true, UserSummary.class);
}
public List<UserSummaryDto> getUserDtosDynamic() {
return userRepository.findByActive(true, UserSummaryDto.class);
}
}