Spring JPA를 실무 흐름으로 이해하기
Spring Boot 기반 Spring Data JPA와 QueryDSL로 Entity 설계, 연관관계 매핑, JPQL/QueryDSL, N+1 해결, 트랜잭션, 페이징, 성능 최적화까지 실전 데이터 접근 계층을 구성합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
Spring Boot 기반 Spring Data JPA와 QueryDSL로 Entity 설계, 연관관계 매핑, JPQL/QueryDSL, N+1 해결, 트랜잭션, 페이징, 성능 최적화까지 실전 데이터 접근 계층을 구성합니다.
Spring Boot 기반 Spring Data JPA와 QueryDSL로 Entity 설계, 연관관계 매핑, JPQL/QueryDSL, N+1 해결, 트랜잭션, 페이징, 성능 최적화까지 실전 데이터 접근 계층을 구성합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
문법보다 요청이 들어와 검증, 처리, 저장, 응답으로 이어지는 경계를 먼저 잡습니다.
글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Spring JPA를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.
Spring JPA를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.
| 계층 | 기술 | 역할 |
|---|---|---|
| ORM | JPA / Hibernate | 객체-관계 매핑, JPQL 쿼리 실행 |
| Repository | Spring Data JPA | 메서드 이름 기반 쿼리 자동 생성 |
| 동적 쿼리 | QueryDSL | 타입 안전한 조건절 조합 |
| 스키마 관리 | Flyway / Liquibase | DB 마이그레이션 버전 관리 |
여기서는 Spring Boot 프로젝트 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
plugins {
id("java")
id("org.springframework.boot") version "3.3.5"
id("io.spring.dependency-management") version "1.1.6"
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER}
password: ${DB_PASS}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100 # N+1 방지여기서는 Entity 설계을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Entity @Table(name = "users")
@Getter @Builder @NoArgsConstructor @AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserStatus status;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
// 비즈니스 메서드 (Setter 대신)
public void activate() { this.status = UserStatus.ACTIVE; }
public void deactivate() { this.status = UserStatus.INACTIVE; }
}여기서는 연관관계 매핑을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
// ── One-to-Many (게시글 : 댓글) ─────────────────
@Entity
public class Post {
@Id @GeneratedValue(strategy = IDENTITY) private Long id;
private String title;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
public void addComment(Comment comment) {
comments.add(comment);
comment.setPost(this);
}
}
@Entity
public class Comment {
@Id @GeneratedValue(strategy = IDENTITY) private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY) // 반드시 LAZY
@JoinColumn(name = "post_id")
private Post post;
}
// ── Many-to-Many → 중간 테이블 Entity ────────────
@Entity
public class UserRole {
@Id @GeneratedValue(strategy = IDENTITY) private Long id;
@ManyToOne(fetch = LAZY) @JoinColumn(name = "user_id") private User user;
@ManyToOne(fetch = LAZY) @JoinColumn(name = "role_id") private Role role;
private LocalDateTime assignedAt;
}여기서는 Repository & Query 메서드을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
public interface UserRepository extends JpaRepository<User, Long> {
// 메서드 이름으로 쿼리 자동 생성
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByStatusOrderByCreatedAtDesc(UserStatus status);
long countByStatus(UserStatus status);
// @Query — JPQL
@Query("SELECT u FROM User u WHERE u.createdAt >= :from AND u.status = :status")
List<User> findActiveAfter(@Param("from") LocalDateTime from,
@Param("status") UserStatus status);
// Bulk update — @Modifying 필수
@Modifying
@Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
int bulkUpdateStatus(@Param("ids") List<Long> ids,
@Param("status") UserStatus status);
}여기서는 JPQL & @Query을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
// ── Fetch Join (N+1 해결) ─────────────────────────
@Query("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.id = :id")
Optional<Post> findWithComments(@Param("id") Long id);
// ── DTO Projection ────────────────────────────────
@Query("SELECT new com.example.dto.UserSummary(u.id, u.name, u.email) " +
"FROM User u WHERE u.status = 'ACTIVE'")
List<UserSummary> findActiveSummaries();
// ── Native Query ──────────────────────────────────
@Query(value = "SELECT * FROM users WHERE email ILIKE %:keyword%",
nativeQuery = true)
List<User> searchByEmailNative(@Param("keyword") String keyword);여기서는 QueryDSL 동적 쿼리을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Repository
@RequiredArgsConstructor
public class UserQueryRepository {
private final JPAQueryFactory queryFactory;
private final QUser user = QUser.user;
public List<User> search(UserSearchCondition cond) {
return queryFactory
.selectFrom(user)
.where(
emailContains(cond.getEmail()),
statusEq(cond.getStatus()),
createdAfter(cond.getFrom())
)
.orderBy(user.createdAt.desc())
.offset(cond.getOffset())
.limit(cond.getSize())
.fetch();
}
// null이면 조건에서 제외 (null-safe 동적 조건)
private BooleanExpression emailContains(String email) {
return StringUtils.hasText(email) ? user.email.containsIgnoreCase(email) : null;
}
private BooleanExpression statusEq(UserStatus status) {
return status != null ? user.status.eq(status) : null;
}
private BooleanExpression createdAfter(LocalDateTime from) {
return from != null ? user.createdAt.goe(from) : null;
}
}여기서는 N+1 문제 해결을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
// ── 문제 상황 ─────────────────────────────────────
// N+1 발생: posts 조회 후 각 post마다 comments SELECT 추가 발생
List<Post> posts = postRepository.findAll();
posts.forEach(p -> p.getComments().size()); // N번 추가 쿼리
// ── 해결 1: Fetch Join ────────────────────────────
@Query("SELECT DISTINCT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
// ── 해결 2: @EntityGraph ──────────────────────────
@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();
// ── 해결 3: application.yml 글로벌 설정 ──────────
// hibernate.default_batch_fetch_size: 100
// → IN 쿼리로 한 번에 100개씩 로딩 (컬렉션 N+1에 효과적)여기서는 트랜잭션 & 영속성 컨텍스트을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 기본을 읽기 전용으로 설정
public class UserService {
private final UserRepository userRepository;
// 조회 — readOnly 그대로
public UserResponse findById(Long id) {
return userRepository.findById(id)
.map(UserResponse::from)
.orElseThrow(() -> new EntityNotFoundException("User: " + id));
}
// 쓰기 — readOnly 오버라이드
@Transactional
public UserResponse create(CreateUserRequest req) {
if (userRepository.existsByEmail(req.getEmail())) {
throw new DuplicateEmailException(req.getEmail());
}
User user = userRepository.save(
User.builder()
.name(req.getName())
.email(req.getEmail())
.status(UserStatus.ACTIVE)
.build()
);
return UserResponse.from(user);
}
// 변경 감지 (Dirty Checking) — save() 불필요
@Transactional
public void activate(Long id) {
User user = userRepository.findById(id).orElseThrow();
user.activate(); // 트랜잭션 커밋 시 자동 UPDATE
}
}여기서는 페이징 & 정렬을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public Page<UserResponse> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String[] sort) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Arrays.stream(sort)
.map(s -> s.split(","))
.map(a -> a[1].equalsIgnoreCase("desc")
? Sort.Order.desc(a[0])
: Sort.Order.asc(a[0]))
.collect(Collectors.toList())));
return userService.findAll(pageable);
}
}
// QueryDSL 페이징 (count 쿼리 분리)
public Page<UserSummary> searchPaged(UserSearchCondition cond, Pageable pageable) {
List<UserSummary> content = queryFactory
.select(Projections.constructor(UserSummary.class, user.id, user.name, user.email))
.from(user)
.where(statusEq(cond.getStatus()))
.orderBy(user.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(user.count())
.from(user)
.where(statusEq(cond.getStatus()));
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}