다음 장의 Spring Data JPA는 인터페이스만 선언하면 CRUD가 자동으로 만들어지는 편리한 도구다. 하지만 그 편리함이 어떻게 동작하는지는 한 겹 아래에 있는 JPA와 영속성 컨텍스트를 알아야 이해된다. 이 장에서는 "왜 save()를 부르지 않아도 수정이 되는가" 같은 질문에 답할 수 있도록 JPA의 핵심 개념을 먼저 다진다.
이 장의 위치
이 페이지는 순수 JPA의 개념을 설명한다. 실제 어노테이션 매핑·리포지토리·쿼리 메소드 같은 실습 코드는 다음 장 Spring Data JPA에서 이어서 다룬다.
1. ORM과 JPA
자바는 객체로 데이터를 다루고, 관계형 데이터베이스는 테이블(행과 열) 로 데이터를 다룬다. 이 둘은 사고방식이 다르다.
객체는 참조(
member.getTeam().getName())로 연관 데이터를 따라간다.테이블은 외래 키(FK)와 조인으로 연관 데이터를 잇는다.
이 차이를 객체-관계 패러다임 불일치라고 한다. 개발자가 직접 SQL을 쓰면 객체를 테이블에 맞춰 일일이 변환하는 반복 코드가 끝없이 생긴다.
ORM(Object-Relational Mapping) 은 객체와 테이블을 자동으로 매핑해 이 변환을 대신해 주는 기술이다. JPA는 자바의 ORM 표준 명세이고, Hibernate는 그 명세를 구현한 대표 라이브러리다.
graph LR
A[내 코드<br/>엔티티 객체] --> B[JPA 표준 명세]
B --> C[Hibernate 구현체]
C --> D[(데이터베이스)]용어 | 정체 |
|---|---|
JPA | 자바 ORM 표준 명세(인터페이스 모음). 그 자체로는 동작하지 않는다. |
Hibernate | JPA 명세의 구현체. 실제 SQL을 생성·실행한다. Spring Boot 기본값. |
Spring Data JPA | JPA를 더 쉽게 쓰도록 한 번 더 감싼 스프링 모듈(다음 장). |
SQL을 안 쓴다는 뜻이 아니다
JPA는 SQL을 대신 만들어 줄 뿐, SQL이 사라지는 게 아니다. 어떤 SQL이 나가는지 늘 의식해야 한다. 그래서 프로젝트 설정에서 show-sql을 켜 두고 콘솔의 SQL을 확인하는 습관이 중요하다.
2. EntityManager와 영속성 컨텍스트
JPA의 모든 동작은 EntityManager(엔티티 매니저) 를 통해 일어난다. 엔티티를 저장·조회·수정·삭제할 때 우리는 EntityManager에게 일을 시킨다.
그리고 EntityManager는 내부에 영속성 컨텍스트(Persistence Context) 라는 공간을 가진다. 이것이 JPA의 심장이다.
영속성 컨텍스트 = "엔티티를 보관하고 관리하는 가상의 메모리 공간"
엔티티를 직접 DB에 넣는 게 아니라, 먼저 이 영속성 컨텍스트에 넣는다. 그러면 JPA가 적절한 시점에 알아서 DB와 동기화한다.
// 순수 JPA 코드 (Spring Data JPA는 이 과정을 감춰 준다)
em.persist(member); // 영속성 컨텍스트에 member를 저장(아직 DB 아님)
Member found = em.find(Member.class, 1L); // 조회Spring Data JPA에서는 직접 안 보인다
실무에서 EntityManager를 직접 다루는 일은 드물다. Spring Data JPA의 Repository가 내부에서 대신 호출해 주기 때문이다. 하지만 그 안에서 영속성 컨텍스트가 동작하고 있다는 사실을 알아야 변경 감지·지연 로딩 같은 동작이 이해된다.
3. 엔티티의 생명주기
엔티티 객체는 영속성 컨텍스트와의 관계에 따라 네 가지 상태를 오간다.
stateDiagram-v2
비영속: 비영속 (new)
영속: 영속 (managed)
준영속: 준영속 (detached)
삭제: 삭제 (removed)
비영속 --> 영속: persist()
영속 --> 준영속: detach() / 트랜잭션 종료
영속 --> 삭제: remove()
준영속 --> 영속: merge()상태 | 의미 | 변경 감지 |
|---|---|---|
비영속 (new) |
| ❌ |
영속 (managed) |
| ✅ |
준영속 (detached) | 한때 영속이었으나 분리됨. 트랜잭션이 끝나면 이 상태가 된다. | ❌ |
삭제 (removed) |
| — |
Member member = new Member(); // 1) 비영속 — 그냥 자바 객체
member.setName("kim");
em.persist(member); // 2) 영속 — 영속성 컨텍스트가 관리 시작
member.setName("lee"); // 영속 상태라 이 변경은 자동 감지된다
em.detach(member); // 3) 준영속 — 더는 관리하지 않음
em.remove(member); // 4) 삭제이 구분이 왜 중요한가
영속 상태일 때만 1차 캐시·변경 감지·지연 로딩 같은 JPA의 혜택이 적용된다. 트랜잭션 밖(준영속)에서 값을 바꾸면 아무리 setXxx()를 호출해도 DB에 반영되지 않는다. 다음 장의 변경 감지 주의사항이 바로 이 이야기다.
4. 영속성 컨텍스트가 주는 이점
엔티티를 곧장 DB에 넣지 않고 영속성 컨텍스트를 한 단계 거치는 이유는, 그 사이에서 JPA가 여러 최적화를 해 주기 때문이다.
① 1차 캐시 — 같은 트랜잭션 안 반복 조회는 DB를 안 친다
영속성 컨텍스트는 관리 중인 엔티티를 내부 캐시(1차 캐시)에 들고 있다. 같은 트랜잭션 안에서 같은 식별자를 다시 조회하면 DB 대신 캐시에서 바로 꺼낸다.
Member m1 = em.find(Member.class, 1L); // DB에서 조회 → 1차 캐시에 저장
Member m2 = em.find(Member.class, 1L); // SELECT 쿼리 없음! 캐시에서 반환② 동일성 보장
같은 식별자로 조회한 엔티티는 같은 객체다. 1차 캐시 덕분에 == 비교가 참이 된다.
m1 == m2; // true③ 쓰기 지연 — INSERT를 모았다가 한 번에
persist()를 호출해도 INSERT SQL이 바로 나가지 않는다. SQL을 쓰기 지연 저장소에 모아 두었다가, 커밋(flush) 시점에 한꺼번에 DB로 보낸다.
em.persist(memberA); // INSERT SQL 만들어 저장소에 쌓기만
em.persist(memberB); // INSERT SQL 또 쌓기
// 아직 DB에는 아무 일도 일어나지 않음
tx.commit(); // 이 순간 모아 둔 INSERT가 한꺼번에 실행④ 변경 감지 (Dirty Checking) — update()가 없는 이유
JPA에는 update() 메소드가 없다. 영속 상태 엔티티의 값을 바꾸기만 하면 커밋 시점에 JPA가 알아서 UPDATE SQL을 만든다.
Member member = em.find(Member.class, 1L); // 영속 상태
member.setName("새이름"); // 값만 변경
// em.update(member) ← 이런 코드는 필요 없다(존재하지도 않는다)
tx.commit(); // 변경 감지 → UPDATE 자동 실행원리는 이렇다. 영속성 컨텍스트는 엔티티를 처음 읽어 온 시점의 스냅샷을 따로 보관한다. 커밋(flush) 때 현재 값과 스냅샷을 비교해, 달라진 필드가 있으면 UPDATE를 생성한다.
graph LR
A[조회 시점<br/>스냅샷 저장] --> B[필드 값 변경]
B --> C[커밋/flush]
C --> D{스냅샷과 비교}
D -->|다름| E[UPDATE 생성·실행]
D -->|같음| F[아무 일 없음]다음 장과 이어진다
이 변경 감지가 Spring Data JPA의 @Transactional 메소드 안에서 그대로 동작한다. 그래서 서비스 코드에서 엔티티 값만 바꾸고 save()를 부르지 않아도 수정이 된다. → Spring Data JPA의 변경 감지
5. flush — 영속성 컨텍스트를 DB에 반영
flush는 영속성 컨텍스트의 변경 내용(쌓인 INSERT/UPDATE/DELETE)을 DB에 실제로 내보내는 동작이다. flush가 일어나는 시점은 세 가지다.
flush 시점 | 설명 |
|---|---|
트랜잭션 커밋 시 | 가장 흔하다. 커밋 직전 자동으로 flush된다. |
JPQL 쿼리 실행 직전 | 쿼리가 최신 상태를 반영하도록 먼저 flush한다. |
| 수동 flush(거의 쓸 일 없음). |
flush는 1차 캐시를 비우지 않는다
flush는 변경분을 DB에 내보낼 뿐, 영속성 컨텍스트를 비우는 게 아니다. flush 후에도 엔티티는 여전히 영속 상태로 관리된다. (캐시를 비우는 건 clear() 또는 트랜잭션 종료다.)
6. JPQL — 객체를 향한 쿼리
find()는 식별자(PK) 하나로만 조회한다. "이름이 kim인 회원 전체"처럼 조건 검색이 필요하면 JPQL(Java Persistence Query Language) 을 쓴다. JPQL은 테이블이 아니라 엔티티 객체를 대상으로 하는 SQL과 비슷한 쿼리다.
// FROM 뒤가 테이블명(member)이 아니라 엔티티명(Member)이다
List<Member> members = em.createQuery(
"select m from Member m where m.name = :name", Member.class)
.setParameter("name", "kim")
.getResultList();JPA가 이 JPQL을 실제 DB 방언에 맞는 SQL로 번역해 실행한다. 다음 장에서 볼 Spring Data JPA의 쿼리 메소드(findByName)와 @Query 가 결국 이 JPQL을 더 편하게 쓰는 방법이다.
7. 연관관계의 이해
엔티티는 혼자 있지 않다. 회원은 여러 주문을 갖고, 주문은 한 회원에 속한다. 이런 엔티티 사이의 관계를 연관관계로 매핑한다. 여기서 핵심은 객체와 테이블이 관계를 표현하는 방식이 다르다는 점이다.
객체는 참조로 잇는다 —
order.getMember()테이블은 외래 키(FK)로 잇는다 —
orders.member_id
JPA가 이 둘을 연결해, 객체 참조를 따라가면 알맞은 조인/조회 SQL이 나가도록 해 준다.
방향 — 단방향과 양방향
테이블은 FK 하나면 양쪽 어디서든 조인할 수 있어 방향이 없다. 하지만 객체는 참조를 가진 쪽에서만 반대쪽을 찾아갈 수 있어 방향이 생긴다.
단방향: 한쪽만 참조를 가진다. (
Order만member를 가짐)양방향: 양쪽이 서로 참조를 가진다. (
Order.member+Member.orders)
양방향이 항상 좋은 건 아니다
양방향은 편리해 보이지만 관리할 지점이 늘어난다. 꼭 필요한 방향만 단방향으로 두고, 반대 탐색이 정말 필요할 때만 양방향으로 확장하는 편이 안전하다.
다중성
어노테이션 | 관계 | 설명 |
|---|---|---|
| N:1 | 가장 많이 쓴다. 주문 N → 회원 1 |
| 1:N | 회원 1 → 주문 N |
| 1:1 | 회원 1 ↔ 프로필 1 |
| N:M | 실무에서는 연결 엔티티로 풀어서 1:N + N:1로 쓴다(지양) |
연관관계의 주인 (owner) { #7-owner }
양방향 관계에서 가장 많이 헷갈리는 개념이다. 객체는 참조가 둘(Order.member, Member.orders)인데, 테이블의 FK는 orders.member_id 하나뿐이다. 그래서 JPA는 "이 FK를 누가 관리할지" 한 쪽을 정해야 한다. 이 쪽이 연관관계의 주인이다.
주인 = FK를 가진 쪽, 즉
@ManyToOne이 있는 쪽(여기서는Order)이다.주인이 아닌 쪽(
@OneToMany)은mappedBy로 "나는 저쪽 필드에 의해 매핑된, 읽기 전용 거울" 임을 표시한다.
class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id") // ← FK를 가진 '주인'
private Member member;
}
class Member {
@OneToMany(mappedBy = "member") // ← 주인이 아님(읽기 전용). FK를 바꾸지 못한다
private List<Order> orders = new ArrayList<>();
}주인이 아닌 쪽에만 값을 넣으면 저장되지 않는다
member.getOrders().add(order)만 하고 order.setMember(member)를 빠뜨리면, FK(member_id)는 null로 저장된다. 연관관계 값은 반드시 주인 쪽(order.setMember(...))에 설정해야 DB에 반영된다.
fetch 기본값 — @ManyToOne은 LAZY로 바꿔라
@ManyToOne·@OneToOne의 기본값은 EAGER(즉시 로딩) 다. 즉시 로딩은 다음 절의 N+1 문제를 키우므로, 연관관계는 항상 fetch = LAZY 로 두는 것이 원칙이다. (@OneToMany·@ManyToMany는 기본이 LAZY다.)
구체적인 매핑 실습은 다음 장
어노테이션을 실제로 붙여 매핑하는 코드는 Spring Data JPA — 연관관계 매핑에서 이어서 다룬다.
8. N+1 문제 { #8 }
연관관계와 지연 로딩(LAZY)을 쓰면 거의 반드시 만나는 대표적인 성능 함정이 N+1 문제다.
목록을 가져오는 쿼리 1번 + 그 목록의 각 항목마다 연관 엔티티를 가져오는 쿼리 N번 = 총 N+1번
어떻게 생기나
LAZY 연관 필드는 실제로 접근하는 순간 쿼리가 나간다. 그래서 목록을 돌면서 연관 엔티티에 접근하면, 건수만큼 추가 쿼리가 발생한다.
// 주문 10건 조회 → SELECT 1번
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
// order.member는 LAZY → 접근하는 순간 회원 SELECT가 매번 나간다
System.out.println(order.getMember().getName()); // ← 10번 추가 쿼리
}
// 합계: 1(주문) + 10(회원) = 11번select * from orders; -- 1번
select * from member where id = 1; -- +1
select * from member where id = 2; -- +1
... -- 주문 수만큼 반복데이터가 10건이면 11번이지만, 1000건이면 1001번이다. 목록이 커질수록 치명적이다.
해결 방법
방법 | 한 줄 요약 |
|---|---|
fetch join | JPQL에서 |
| 리포지토리 메소드에 어노테이션을 붙여 fetch join과 같은 효과를 낸다 |
배치 사이즈 |
|
// fetch join — 주문과 회원을 한 방에 조인해서 조회 (쿼리 1번)
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();EAGER(즉시 로딩)로 해결하려 하지 마라
"그럼 LAZY 말고 EAGER로 하면 되지 않나?" 싶지만, 즉시 로딩은 언제 어떤 쿼리가 나갈지 예측이 어렵고, 다른 조회에서 더 큰 N+1을 유발한다. 원칙은 항상 LAZY로 두고, 필요한 곳에서만 fetch join으로 한 번에 가져오는 것이다.
리포지토리에 적용하는 구체 코드는 다음 장
@Query + join fetch, @EntityGraph, default_batch_fetch_size 설정을 실제로 적용하는 방법은 Spring Data JPA에서 다룬다.
9. 이제 Spring Data JPA로
여기까지가 순수 JPA의 뼈대다. 정리하면, 우리는 엔티티를 영속성 컨텍스트에 맡기고, JPA가 1차 캐시·쓰기 지연·변경 감지로 DB와의 동기화를 똑똑하게 처리한다.
하지만 순수 JPA만 쓰면 EntityManager를 직접 열고 닫고, 트랜잭션을 일일이 관리하고, 반복적인 CRUD 코드를 매번 짜야 한다. Spring Data JPA는 이 반복을 걷어내고 JpaRepository 인터페이스 선언만으로 CRUD를 제공한다. 그 안에서 지금 배운 영속성 컨텍스트가 그대로 돌아간다.
정리
JPA는 자바 ORM 표준 명세, Hibernate는 그 구현체다. 객체-테이블 패러다임 불일치를 자동 매핑으로 해결한다.
모든 동작은 EntityManager를 거치고, 그 안의 영속성 컨텍스트가 엔티티를 관리한다.
엔티티는 비영속 → 영속 → 준영속/삭제 상태를 오가며, 영속 상태에서만 JPA의 혜택이 적용된다.
영속성 컨텍스트는 1차 캐시·동일성 보장·쓰기 지연·변경 감지를 제공한다. 특히 변경 감지 덕분에
update()가 따로 없다.flush는 변경분을 DB에 반영하는 동작이며, 보통 커밋 시 자동으로 일어난다.
조건 조회는 엔티티를 대상으로 하는 JPQL로 하고, 다음 장의 쿼리 메소드·
@Query가 이를 편하게 감싼다.연관관계는 객체 참조와 FK를 매핑한 것으로, FK를 가진 쪽이 연관관계의 주인이고 반대쪽은
mappedBy로 표시한다. fetch는 항상 LAZY가 원칙이다.LAZY + 목록 순회는 N+1 문제를 부른다. fetch join·
@EntityGraph·배치 사이즈로 해결하며, EAGER로 풀지 않는다.