데이터베이스에 데이터를 저장하고 조회하는 일은 모든 웹 애플리케이션의 핵심이다. JPA는 자바 객체와 관계형 데이터베이스 테이블을 매핑해 주는 표준 기술이고, Spring Data JPA는 그 JPA를 더 적은 코드로 쓰게 해 주는 스프링 모듈이다. 인터페이스만 선언하면 기본 CRUD 코드가 자동으로 만들어진다.
이 페이지에 대하여
원본 노션 페이지에는 "페이징 처리" 항목만 있었습니다. 이 페이지는 강의 흐름에 맞춰 JPA 전반을 새로 정리한 자료입니다. 실제 수업 내용에 맞게 자유롭게 가감하세요.
1. JPA와 Spring Data JPA
JPA (Java Persistence API): 자바 객체(엔티티)와 DB 테이블의 매핑을 정의하는 표준 명세다. SQL을 직접 쓰는 대신 객체를 다루면, JPA가 적절한 SQL을 생성·실행한다.
Hibernate: JPA 명세의 대표적인 구현체다. 스프링 부트의
spring-boot-starter-data-jpa는 기본으로 Hibernate를 사용한다.Spring Data JPA: JPA를 한 단계 더 추상화한 스프링 모듈이다.
JpaRepository를 상속한 인터페이스만 선언하면 구현 클래스가 자동 생성된다.
graph LR
A[내 코드<br/>Repository 인터페이스] --> B[Spring Data JPA]
B --> C[JPA 표준]
C --> D[Hibernate 구현체]
D --> E[(데이터베이스)]왜 JPA인가
MyBatis처럼 SQL을 직접 작성하는 방식과 달리, JPA는 객체 중심으로 개발하게 해 준다. 단순 CRUD에 들어가는 반복 코드를 크게 줄여 준다. 다만 복잡한 통계성 쿼리는 JPQL이나 QueryDSL, 네이티브 SQL을 함께 쓴다.
2. 엔티티 매핑
테이블과 매핑되는 클래스를 엔티티(Entity) 라고 한다.
import jakarta.persistence.*;
@Entity // JPA가 관리하는 엔티티
@Getter @Setter
@NoArgsConstructor // JPA는 기본 생성자가 필수
public class Member {
@Id // 기본 키(PK)
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 자동 증가
private Long id;
@Column(nullable = false, length = 20) // 컬럼 제약조건
private String name;
private int age;
@Enumerated(EnumType.STRING) // enum을 문자열로 저장
private Role role;
}어노테이션 | 역할 |
|---|---|
@Entity | 이 클래스를 JPA 엔티티로 지정 |
@Id | 기본 키 필드 지정 |
@GeneratedValue | 기본 키 생성 전략 ( |
@Column | 컬럼명·길이·null 여부 등 매핑 |
@Enumerated | enum 매핑. 반드시 |
@Table | 매핑할 테이블명 지정(클래스명과 다를 때) |
@Enumerated(EnumType.ORDINAL) 금지
기본값인 ORDINAL은 enum의 순서(0, 1, 2) 를 저장한다. 나중에 enum 중간에 값을 추가하면 기존 데이터의 의미가 어긋난다. 반드시 EnumType.STRING을 사용한다.
3. 리포지토리 (Repository)
JpaRepository<엔티티, 기본키타입>를 상속한 인터페이스를 만들면, 기본 CRUD 메소드가 자동으로 제공된다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}이것만으로 다음 메소드를 바로 쓸 수 있다.
메소드 | 기능 |
|---|---|
| 저장 / 수정 |
| 단건 조회 ( |
| 전체 조회 |
| 전체 개수 |
| 삭제 |
| 존재 여부 |
@Service
@RequiredArgsConstructor
@Transactional // 쓰기 작업은 트랜잭션 안에서
public class MemberService {
private final MemberRepository memberRepository;
public Long join(Member member) {
return memberRepository.save(member).getId();
}
@Transactional(readOnly = true) // 조회는 readOnly로 성능 최적화
public Member findOne(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("회원이 없습니다. id=" + id));
}
}4. 쿼리 메소드
메소드 이름만 규칙에 맞게 지으면 Spring Data JPA가 SQL을 자동 생성한다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// SELECT * FROM member WHERE name = ?
List<Member> findByName(String name);
// WHERE name = ? AND age > ?
List<Member> findByNameAndAgeGreaterThan(String name, int age);
// WHERE name LIKE ?
List<Member> findByNameContaining(String keyword);
// ORDER BY age DESC
List<Member> findByNameOrderByAgeDesc(String name);
// 단건 + null 안전
Optional<Member> findByEmail(String email);
// 개수
long countByAge(int age);
}자주 쓰는 키워드:
키워드 | 의미 | 예시 |
|---|---|---|
| 조건 결합 |
|
| 비교 |
|
| 범위 |
|
| 부분 일치 |
|
| 정렬 |
|
| 목록 포함 |
|
이름이 너무 길어지면
조건이 3개를 넘어가면 메소드 이름이 비현실적으로 길어진다. 그럴 때는 다음 절의 @Query를 사용한다.
5. @Query — JPQL 직접 작성
복잡한 쿼리는 @Query로 직접 작성한다. JPQL은 테이블이 아니라 엔티티를 대상으로 하는 객체 지향 쿼리다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.name = :name and m.age > :age")
List<Member> search(@Param("name") String name, @Param("age") int age);
// 특정 값만 조회 (DTO 프로젝션)
@Query("select new com.example.dto.MemberDto(m.id, m.name) from Member m")
List<MemberDto> findMemberDto();
// 네이티브 SQL이 필요하면 nativeQuery 사용
@Query(value = "select * from member where name = :name", nativeQuery = true)
List<Member> findByNameNative(@Param("name") String name);
}6. 페이징과 정렬
원본 노션의 "페이징 처리" 주제를 JPA 방식으로 풀어낸 부분이다. Pageable을 파라미터로 받으면 페이징·정렬이 자동으로 처리되고, Page 객체로 전체 개수·페이지 수까지 함께 받는다.
public interface MemberRepository extends JpaRepository<Member, Long> {
Page<Member> findByNameContaining(String keyword, Pageable pageable);
}@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Page<Member> getPage(String keyword, int page) {
// page: 0부터 시작, size: 10개씩, 정렬: id 내림차순
Pageable pageable = PageRequest.of(page, 10, Sort.by(Sort.Direction.DESC, "id"));
return memberRepository.findByNameContaining(keyword, pageable);
}
}Page 객체가 제공하는 정보:
메소드 | 의미 |
|---|---|
| 현재 페이지의 데이터 목록 |
| 전체 데이터 개수 |
| 전체 페이지 수 |
| 현재 페이지 번호(0부터) |
| 첫/마지막 페이지 여부 |
| 다음/이전 페이지 존재 여부 |
컨트롤러에서 받기
스프링 MVC는 요청 파라미터(?page=0&size=10&sort=id,desc)를 Pageable로 자동 바인딩한다.
@GetMapping("/members")
public String list(@RequestParam(defaultValue = "") String keyword,
@PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable,
Model model) {
Page<Member> members = memberRepository.findByNameContaining(keyword, pageable);
model.addAttribute("members", members);
return "members/list";
}타임리프에서 페이지 네비게이션
<tr th:each="member : ${members.content}">
<td th:text="${member.name}"></td>
</tr>
<nav>
<a th:if="${members.hasPrevious()}"
th:href="@{/members(page=${members.number - 1})}">이전</a>
<span th:text="${members.number + 1} + ' / ' + ${members.totalPages}"></span>
<a th:if="${members.hasNext()}"
th:href="@{/members(page=${members.number + 1})}">다음</a>
</nav>MVC 장의 직접 작성 SQL과 비교
Spring MVC — 파일·페이징에서는 페이징을 위해 MySQL LIMIT/OFFSET SQL을 직접 작성했다. JPA의 Pageable을 쓰면 DB에 맞는 페이징 SQL과 전체 개수 카운트 쿼리를 자동으로 만들어 준다. (DB를 바꿔도 코드를 고칠 필요가 없다.)
페이지 블록 — 1~10, 11~20 묶음
페이지가 많아지면 [이전] 1 2 … 100 [다음]을 다 그릴 수 없다. 보통 [이전] 11 12 … 20 [다음]처럼 10개씩 블록으로 보여 준다. 현재 페이지로 블록의 시작·끝을 계산한다.
int size = 10; // 블록당 페이지 수
int current = members.getNumber(); // 현재 페이지(0부터)
int start = (current / size) * size; // 블록 시작 (0,10,20…)
int end = Math.min(start + size, members.getTotalPages()); // 블록 끝
model.addAttribute("startPage", start);
model.addAttribute("endPage", end);<a th:if="${members.hasPrevious()}"
th:href="@{/members(page=${startPage - 1})}">이전</a>
<!-- startPage ~ endPage-1 까지 번호 출력 -->
<span th:each="i : ${#numbers.sequence(startPage, endPage - 1)}">
<a th:href="@{/members(page=${i})}"
th:text="${i + 1}"
th:classappend="${i == members.number} ? 'active'"></a>
</span>
<a th:if="${members.hasNext()}"
th:href="@{/members(page=${endPage})}">다음</a>Page vs Slice — 카운트 쿼리 비용
Page는 getTotalPages()를 제공하려고 매번 COUNT 쿼리를 추가로 실행한다. 데이터가 많으면 이 카운트가 부담이 될 수 있다.
전체 페이지 수가 필요 없고 "다음 페이지가 있는지"만 알면 되는 무한 스크롤·더보기라면 Slice<T>를 반환하자. Slice는 size + 1개를 조회해 다음 페이지 존재 여부만 판단하므로 카운트 쿼리를 날리지 않는다.
Slice<Member> findByNameContaining(String keyword, Pageable pageable);
// slice.hasNext() 는 되지만, slice.getTotalPages() 는 없음7. 연관관계 매핑 { #7 }
엔티티 간의 관계(회원 ↔ 주문 등)를 매핑한다. 한 명의 회원이 여러 주문을 갖는 1:N 관계 예시다. 방향·다중성·연관관계의 주인 같은 개념은 JPA 기초 — 연관관계의 이해에서 먼저 정리했으니, 여기서는 어노테이션으로 실제 매핑하는 코드에 집중한다.
@Entity
@Table(name = "orders") // MySQL에서 order는 예약어이므로 테이블명 지정
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // 주문 N : 회원 1
@JoinColumn(name = "member_id") // FK 컬럼
private Member member;
}MySQL 예약어 주의
order, user, group, desc 등은 MySQL의 예약어다. 엔티티 클래스명이 이런 단어이면 @Table(name = "orders")처럼 테이블명을 바꿔 지정해야 create table 시 문법 오류가 나지 않는다.
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "member") // Order.member 가 연관관계의 주인
private List<Order> orders = new ArrayList<>();
}어노테이션 | 관계 |
|---|---|
| N:1 (가장 많이 사용) |
| 1:N |
| 1:1 |
| N:M (실무에서는 연결 엔티티로 풀어 사용 권장) |
기본은 지연 로딩(LAZY)이 원칙
연관관계의 fetch는 항상 LAZY로 둔다. @ManyToOne·@OneToOne은 기본값이 EAGER이므로 명시적으로 fetch = FetchType.LAZY 를 지정해야 한다.
N+1 문제 해결하기
LAZY 연관을 목록 순회에서 접근하면, 목록 조회 1번에 더해 항목마다 연관 조회가 N번 나가는 N+1 문제가 생긴다. (개념과 발생 원리는 JPA 기초 — N+1 문제에서 다뤘다.) Spring Data JPA에서는 세 가지로 해결한다.
① @Query + join fetch — 연관 엔티티를 한 번의 조인 쿼리로 함께 조회한다.
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();② @EntityGraph — JPQL을 직접 쓰지 않고 어노테이션만으로 fetch join 효과를 낸다. 쿼리 메소드에도 붙일 수 있다.
@EntityGraph(attributePaths = "member")
List<Order> findAll();③ 배치 사이즈 — application.yml에 한 줄 켜 두면, N번의 단건 조회를 IN 쿼리로 묶어 1+1번으로 줄인다. 컬렉션(@OneToMany) 조회에 특히 유용하다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100EAGER로 해결하지 않는다
즉시 로딩으로 바꾸면 언제 어떤 쿼리가 나갈지 예측하기 어렵고 다른 조회에서 더 큰 N+1을 부른다. 항상 LAZY + 필요한 곳에서만 fetch join이 원칙이다.
8. 동작 확인 — SQL 로그
프로젝트 설정에서 켜 둔 show-sql/format_sql 덕분에 JPA가 생성하는 SQL을 콘솔에서 확인할 수 있다.
Hibernate: select m1_0.id, m1_0.age, m1_0.name, m1_0.role from member m1_0 where m1_0.name=? binding parameter [1] as [VARCHAR] - [kim]
파라미터 값까지 보려면 (Hibernate 6)
?에 실제로 바인딩된 값을 보려면 application.yml에 logging.level.org.hibernate.orm.jdbc.bind: trace 를 추가한다. (Boot 2.x의 org.hibernate.type=trace는 Hibernate 6에서 동작하지 않는다.)
9. 트랜잭션과 변경 감지(Dirty Checking) { #9 }
JPA에서 처음 놀라는 지점이 "수정할 때 save()를 부르지 않아도 된다" 는 것이다. 트랜잭션 안에서 조회한 엔티티의 값을 바꾸면, 트랜잭션이 커밋될 때 JPA가 변경을 감지해 자동으로 UPDATE SQL을 실행한다. 이를 변경 감지(dirty checking) 라고 한다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional // 메소드 전체가 하나의 트랜잭션
public void changeName(Long id, String newName) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName(newName); // 값만 바꾼다
// memberRepository.save(member); // ← 호출하지 않아도 된다!
} // 커밋 시점에 JPA가 UPDATE 자동 실행
}원리는 이렇다. 영속성 컨텍스트(Persistence Context)는 조회한 엔티티의 최초 상태(스냅샷) 를 보관한다. 커밋(또는 flush) 시점에 현재 값과 스냅샷을 비교해, 달라진 필드가 있으면 UPDATE를 생성한다.
변경 감지는 '영속 상태'에서만 동작한다
findById 등으로 트랜잭션 안에서 조회한 엔티티만 영속성 컨텍스트가 관리한다. 트랜잭션 밖에서 직접 만든 객체(new Member(...))나, 트랜잭션이 끝난 뒤(준영속 상태) 값을 바꾼 것은 자동 반영되지 않는다. 이때는 save()로 다시 저장해야 한다.
@Transactional 의 주요 옵션
옵션 | 의미 |
|---|---|
(기본) | 메소드 시작 시 트랜잭션 시작, 정상 종료 시 커밋 |
| 읽기 전용. 변경 감지용 스냅샷을 만들지 않아 조회 성능에 유리 |
| 트랜잭션 전파 방식. 기본 |
| 어떤 예외에서 롤백할지 지정 |
롤백 기본 규칙 — 체크 예외는 롤백되지 않는다
스프링은 기본적으로 RuntimeException과 Error에서만 롤백하고, IOException 같은 체크 예외(checked exception)에서는 롤백하지 않고 커밋한다. 체크 예외에서도 롤백하려면 @Transactional(rollbackFor = Exception.class)처럼 명시해야 한다.
조회엔 readOnly, 쓰기엔 일반 @Transactional
실무에서는 클래스에 @Transactional(readOnly = true)를 걸어 두고, 저장·수정·삭제 메소드에만 @Transactional을 다시 붙여 덮어쓰는 패턴을 자주 쓴다.
10. 엔티티와 DTO 분리 { #10-dto }
Spring MVC — 요청과 응답의 @ResponseBody 예제에서는 Member 엔티티를 그대로 JSON으로 반환했다. 학습 단계에서는 간단하지만, 엔티티를 그대로 외부에 노출하면 여러 문제가 생긴다.
민감 정보 노출:
password같은 필드까지 JSON에 그대로 나간다.양방향 연관관계 순환참조:
Member → orders → member → orders ...가 무한히 직렬화되어 오류가 난다.LAZY 직렬화 오류: 지연 로딩 연관 필드를 트랜잭션 밖에서 직렬화하려다 예외가 발생한다.
API 스펙이 엔티티에 묶임: 엔티티 필드를 바꾸면 API 응답 모양이 의도치 않게 함께 바뀐다.
그래서 화면·API로 내보낼 데이터는 DTO(Data Transfer Object) 라는 별도 클래스에 필요한 값만 담아 변환한다.
// 응답 전용 DTO — 필요한 필드만, 안전하게
public record MemberDto(Long id, String name, int age) {
public MemberDto(Member member) {
this(member.getId(), member.getName(), member.getAge());
}
}@GetMapping("/api/members")
@ResponseBody
public List<MemberDto> apiList() {
return memberRepository.findAll().stream()
.map(MemberDto::new) // 엔티티 → DTO 변환
.toList();
}record로 DTO를 간결하게
Java 16+(이 강의는 21)에서는 record로 불변 DTO를 한 줄에 정의할 수 있다. getter·생성자·equals/hashCode가 자동 생성된다.
@Query DTO 프로젝션과의 차이
5절 @Query에서 본 select new com.example.dto.MemberDto(...)는 아예 조회 시점에 DTO로 받아 오는 방법이다. 엔티티 전체를 가져온 뒤 변환하는 것보다, 필요한 컬럼만 읽어 효율적일 때가 있다. 둘 다 "엔티티를 직접 노출하지 않는다"는 같은 목표를 향한다.
정리
JPA는 객체와 테이블을 매핑하는 표준, Hibernate는 구현체, Spring Data JPA는 인터페이스만으로 CRUD를 제공하는 스프링 모듈이다.
엔티티는
@Entity/@Id/@GeneratedValue로 매핑하고, 리포지토리는JpaRepository를 상속해 만든다.간단한 조회는 쿼리 메소드(
findByXxx), 복잡한 조회는@Query(JPQL) 로 작성한다.페이징·정렬은
Pageable/Page로 처리하며, 컨트롤러와 타임리프까지 자연스럽게 이어진다.연관관계는
@ManyToOne/@OneToMany로 매핑하되, 항상 LAZY를 기본으로 하고 N+1 문제에 유의한다.트랜잭션 안에서 조회한 엔티티는 값만 바꿔도 변경 감지로 자동
UPDATE되며, 조회 메소드는@Transactional(readOnly = true)로 최적화한다.화면·API로는 엔티티를 직접 노출하지 말고 DTO로 변환해 내보낸다.