1. 왜 JPA가 필요한가? (패러다임 불일치)
Spring은 자바(객체 지향) 기반이고, 데이터 저장은 MySQL(관계형 DB) 로 한다. 근데 이 둘은 근본적으로 추구하는 방향이 다름!
| 비교 | 자바 (객체 지향) | RDBMS (관계형 DB) |
|---|---|---|
| 목표 | 캡슐화, 상속, 다형성 활용 | 데이터를 정교하게 구성 |
| 연관관계 | 참조 변수(Store store)로 탐색 | FK로 조인해서 탐색 |
| 방향성 | A→B, A←B 따로 선언해야 양방향 | FK 하나로 양방향 탐색 가능 |
| 상속 | 있음 | 없음 |
| 타입 | LocalDateTime, enum 등 | DATE, NUMBER 등 스키마 타입 |
그 결과, 순수 JDBC로 개발하면 개발자가 직접 모든 SQL을 짜고, Java ↔ SQL 변환을 수동으로 해야 한다. 워크북의 회원가입 예시 코드처럼 prepareStatement, setLong, setString... 이런 걸 반복 CRUD마다 전부 써야 하는 것.
이 문제를 해결하는 게 ORM, 그리고 자바 표준 ORM 인터페이스가 JPA다.
2. ORM & JPA란?
ORM (Object-Relational Mapping) - 애플리케이션의 클래스(객체)와 DB 테이블을 자동으로 연결해주는 기술. 반복적인 CRUD SQL을 직접 안 짜도 된다.
JPA (Java Persistence API) - 자바 진영의 ORM 표준 인터페이스 모음. 인터페이스이기 때문에 실제 구현체가 필요하고, 그 대표적인 구현체가 Hibernate다.
개발자 코드
↓
JPA (인터페이스)
↓
Hibernate (구현체)
↓
MySQL
3. 영속성 컨텍스트 (Persistence Context)
JPA의 핵심 개념. 엔티티 객체를 관리하고, 변경 내용을 추적하는 메모리 상의 논리적 공간이다. 쉽게 말하면 애플리케이션과 DB 사이의 캐시 메모리 역할.
@EntityManager를 통해 접근한다.
엔티티의 생명주기
영속성 컨텍스트와의 관계에 따라 4가지 상태로 나뉜다.
- 비영속 (new/transient) - 영속성 컨텍스트와 아무 관계 없는 새 객체
- 영속 (managed) - 영속성 컨텍스트에 등록되어 관리되는 상태
- 준영속 (detached) - 한번 영속 상태였다가 분리된 상태
- 삭제 (removed) - 삭제된 상태
영속 상태의 3가지 장점
① 1차 캐시
엔티티를 처음 조회하면 영속성 컨텍스트에 저장되고, 같은 트랜잭션 내에서 같은 엔티티를 다시 조회할 때는 DB에 쿼리를 날리지 않고 캐시에서 바로 꺼내온다.
Member member = em.find(Member.class, 1L); // DB 쿼리 발생
Member sameMember = em.find(Member.class, 1L); // 캐시에서 반환, DB 쿼리 없음
[정리]
처음 em.find(Member.class, 1L) 를 호출하면:
- DB에 쿼리를 날려서 데이터를 가져오고
- 그 결과로
member엔티티 객체가 만들어지면서 - 영속성 컨텍스트에 자동으로 등록(캐싱) 된다
즉, 자동으로 만들어지는 건 영속성 컨텍스트 안의 캐시 엔트리임. 이게 있으니까 두 번째 em.find(Member.class, 1L) 때는 DB를 안 가고 여기서 바로 꺼내줄수 있는거
② 변경 감지 (Dirty Checking)
영속 상태의 엔티티 필드를 수정하면, 트랜잭션 커밋 시점에 JPA가 자동으로 변경을 감지해서 UPDATE SQL을 날려준다. 개발자가 직접 UPDATE를 호출할 필요가 없다.
Member member = em.find(Member.class, 1L);
member.setName("UMC10기최고"); // 수정만 하면 됨
em.getTransaction().commit(); // 이때 자동으로 UPDATE 쿼리 발생
③ 지연 로딩 (Lazy Loading)
- 아래 FetchType 파트에서 설명
쓰기 지연 SQL 저장소
1차 캐시에 엔티티를 올려두는 동안 실제 쿼리는 즉시 DB에 나가지 않는다. 트랜잭션의 commit() 시점에 내부적으로 flush()가 먼저 실행되고, 그동안 모아둔 SQL들이 일괄 전송된다.
flush()- 모아둔 쿼리를 즉시 실행 (트랜잭션은 아직 유지)
commit()- flush 후 트랜잭션 종료 및 DB 반영
4. 엔티티 설정 관련 어노테이션
BaseEntity
여러 테이블에서 공통으로 쓰는 컬럼(생성일자, 수정일자 등)을 모아둔 부모 클래스. 필요한 엔티티에서 extends해서 쓴다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
| 어노테이션 | 역할 |
|---|---|
@MappedSuperclass | 자식 클래스에게 컬럼 매핑 정보만 제공, 자체적으로 테이블 생성 안 함 |
@EntityListeners(AuditingEntityListener.class) | 저장/수정 이벤트 발생 시 자동 작업 수행 |
@CreatedDate | 저장 시점의 시각 자동 기록 |
@LastModifiedDate | 수정 시점의 시각 자동 기록 |
@EntityListeners가 동작하려면 @SpringBootApplication 클래스에 @EnableJpaAuditing 을 붙여야 한다.
Builder.Default
nullable = false 컬럼에 기본값을 설정하고 싶을 때 사용.
@Builder.Default
private Status status = Status.ACTIVE;
5. 연관관계 설정
단방향 vs 양방향
한쪽 엔티티에만 연관관계 어노테이션을 달면 단방향, 양쪽 다 달면 양방향이다.
주요 어노테이션
| 어노테이션 | 의미 |
|---|---|
@ManyToOne | N:1 관계에서 N 쪽 엔티티에 사용 |
@OneToMany | 1:N 관계에서 1 쪽 엔티티에 사용 |
@OneToOne | 1:1 관계 |
@JoinColumn | FK의 주인 설정 |
FetchType: 즉시 로딩 vs 지연 로딩
즉시 로딩 (EAGER) - 엔티티 조회 시 연관된 모든 객체를 함께 조회. 연관 테이블이 많을수록 무거운 조인 쿼리가 나간다. N+1 문제를 유발할 수 있어 주의.
지연 로딩 (LAZY) - 연관 객체를 실제로 사용하기 전까지는 조회하지 않고 프록시(껍데기) 상태로 둔다. 필요할 때만 DB를 조회하므로 성능상 권장.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
→ member 필드를 실제로 접근하기 전까지는 DB 쿼리가 나가지 않는다.
Cascade (양방향 매핑의 장점)
한 엔티티를 수정/삭제할 때 연관된 엔티티에도 자동으로 동일한 작업을 전파하는 기능.
@OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE)
private List<MemberFood> memberFoodList = new ArrayList<>();
위처럼 설정하면 Member 삭제 시 연관된 MemberFood도 자동 삭제된다.
주요 타입: ALL, PERSIST, REMOVE, MERGE, DETACH, REFRESH
⚠️ 주의: 꼭 함께 삭제되어야 하는 관계에만 사용할 것. 무분별하게 설정하면 의도치 않은 데이터 삭제가 발생한다.
6. JPQL & Repository 인터페이스
JPQL - DB 테이블이 아닌 JPA 엔티티(객체)를 대상으로 작성하는 쿼리 언어. SQL과 구조는 같지만 테이블명 대신 클래스명, 컬럼명 대신 필드명을 사용한다.
Spring Data JPA에서는 주로 Repository 인터페이스 방식으로 사용한다.
방법 1: 메서드 이름 기반 쿼리 자동 생성
// 자동으로 아래 쿼리 생성
// SELECT * FROM member WHERE name = ? AND deleted_at IS NULL
List<Member> findByNameAndDeletedAtIsNull(String name);
메서드 이름 규칙만 지키면 Spring Data JPA가 쿼리를 자동으로 만들어준다.
방법 2: @Query 어노테이션으로 직접 작성
복잡한 조건이나 성능 최적화가 필요할 때 사용.
@Query("SELECT m FROM Member m WHERE m.name = :name AND m.deletedAt IS NULL")
List<Member> findActiveByName(@Param("name") String name);
nativeQuery = true 옵션을 추가하면 JPQL 대신 순수 SQL을 직접 쓸 수도 있다.
핵심 키워드
- N+1 문제 - 즉시 로딩 사용 시 왜 발생하는지
- Fetch Join - N+1 문제 해결 방법 중 하나
- @EntityGraph - 또 다른 N+1 해결 방법
- commit vs flush 차이 - 둘 다 DB 반영과 관련 있지만 역할이 다름
- 지연 로딩 vs 즉시 로딩 성능 차이 심화
과제 정리
- JPA (Java Persistence API): 자바 객체와 DB 테이블을 매핑해 SQL 없이 객체지향적으로 DB를 다룰 수 있게 해주는 ORM 표준 인터페이스 (구현체로 Hibernate가 대표적)
- N+1 문제: 1번의 쿼리로 N개의 데이터를 조회한 후, 연관 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 성능 문제
- 즉시로딩 (EAGER): 엔티티 조회 시 연관 엔티티를 즉시 함께 JOIN해서 가져오는 방식 (N+1 문제 발생 위험)
- 지연로딩 (LAZY): 연관 엔티티를 실제로 사용하는 시점에 쿼리를 날려 가져오는 방식 (기본 권장, 단 N+1 주의)
- JPQL (Java Persistence Query Language): 테이블이 아닌 엔티티 객체를 대상으로 작성하는 JPA 전용 객체지향 쿼리 언어
- Fetch Join: JPQL에서
JOIN FETCH키워드로 연관 엔티티를 한 번의 쿼리로 함께 조회해 N+1 문제를 해결하는 방법 - @EntityGraph: JPQL 없이 어노테이션만으로 특정 연관 엔티티를 즉시로딩으로 오버라이드해 N+1 문제를 해결하는 방법
- flush: 영속성 컨텍스트의 변경사항을 DB에 SQL로 반영(전송) 하지만 트랜잭션은 아직 유지되는 것
- commit: 트랜잭션을 최종 확정해 DB에 영구 반영하는 것 (commit 시 flush가 먼저 자동 실행됨)
- flush vs commit 핵심 차이: flush는 SQL을 DB로 보내는 것이고, commit은 그 변경사항을 되돌릴 수 없게 확정짓는 것
