JPA · JPQL과 영속성 컨텍스트
JPA? 에는 두 개의 큰 기둥이 있습니다. 하나는 객체를 대상으로 질의하는 언어 JPQL, 다른 하나는 객체를 메모리에서 관리하는 영속성 컨텍스트입니다. 이 둘을 알면 JPA가 왜 그렇게 동작하는지 보이기 시작해요.
이 글은 두 파트로 나뉩니다.
- 파트 A — JPQL: 객체를 향한 질의 언어 (1~7번)
- 파트 B — 영속성 컨텍스트: 객체를 관리하는 메모리 공간과 엔티티 생명주기 (8~11번)
파트 A · 1. JPQL이란
JPQL(Jakarta Persistence Query Language) 은 SQL과 매우 비슷하게 생겼지만 결정적 차이가 하나 있습니다. SQL은 테이블·컬럼을 대상으로 하지만, JPQL은 엔티티(객체)·필드를 대상으로 질의해요.
// SQL — 테이블 tb_user 를 향해 SELECT * FROM tb_user WHERE name = '경수'; // JPQL — 엔티티 User(객체)를 향해 SELECT u FROM User u WHERE u.name = '경수'
여기서 User 는 테이블 이름이 아니라 엔티티? 클래스 이름이고,
u.name 은 컬럼이 아니라 자바 필드 이름입니다. JPA가 이 JPQL을 실제 DB에 맞는 SQL로 번역해 실행해요.
SELECT, FROM, WHERE 같은 키워드는 대소문자를 가리지 않습니다(소문자로 써도 됨).
반면 엔티티 이름과 필드 이름은 대소문자를 구분해요(User ≠ user, 자바 클래스명 그대로).
그리고 FROM User u 처럼 엔티티마다 별칭(alias)을 붙이고, 이후엔 u.name 처럼 별칭으로 필드에 접근합니다.
2. 기본 구조 — SELECT · FROM · WHERE · JOIN · GROUP BY
전체 골격은 SQL과 똑같습니다. 다만 모든 것이 객체 기준이라는 점만 기억하세요.
SELECT u FROM User u WHERE u.active = true ORDER BY u.name ASC
JOIN 도 테이블이 아니라 연관 관계(객체 참조)를 따라 들어갑니다. u.roles 처럼 객체의 필드를 그대로 조인해요.
// 내부 조인 — roles 가 있는 User 만 SELECT u FROM User u JOIN u.roles r WHERE r.name = 'ADMIN' // 외부 조인 — roles 가 없어도 User 는 나옴 SELECT u FROM User u LEFT JOIN u.roles r // fetch 조인 — 연관까지 한 번에 끌고 옴 (5번에서 자세히) SELECT u FROM User u JOIN FETCH u.roles
GROUP BY · HAVING 로 묶고 거르며, ORDER BY 로 정렬합니다.
SELECT r.name, COUNT(u) FROM User u JOIN u.roles r GROUP BY r.name HAVING COUNT(u) >= 2 ORDER BY COUNT(u) DESC
3. 파라미터 바인딩 — 이름 vs 위치
값을 문자열로 직접 이어 붙이지 말고(SQL 인젝션·성능 문제), 파라미터로 끼워 넣습니다. 두 방식이 있어요.
// 이름 기준 (:name) — 권장. 읽기 쉽고 순서에 안 흔들림
em.createQuery("SELECT u FROM User u WHERE u.name = :name", User.class)
.setParameter("name", "경수");
// 위치 기준 (?1) — 1부터 시작하는 번호
em.createQuery("SELECT u FROM User u WHERE u.name = ?1", User.class)
.setParameter(1, "경수");실무에서는 순서가 바뀌어도 안전한 이름 기준(:name)을 주로 씁니다.
4. 비교 · 함수 · 연산
WHERE 안에서 쓸 수 있는 조건과 함수들도 SQL과 거의 같습니다.
| 분류 | 예 |
|---|---|
| 비교 | =, <>, <, >, <=, >= |
| 범위 | u.age BETWEEN 20 AND 30 |
| 부분 일치 | u.name LIKE '경%' (% 여러 글자, _ 한 글자) |
| 목록 | u.name IN ('경수', '철수') |
| 널 검사 | u.email IS NULL / IS NOT NULL |
| 집계 | COUNT, SUM, AVG, MIN, MAX |
| 문자열 | CONCAT, SUBSTRING, LENGTH, LOWER, UPPER, TRIM |
SELECT u FROM User u WHERE LOWER(u.name) LIKE :kw AND u.age BETWEEN 20 AND 30 AND u.email IS NOT NULL
CASE 로 조건 분기를, COALESCE 로 "널이면 대체값"을 표현합니다.
SELECT u.name,
CASE WHEN u.age < 20 THEN '미성년'
ELSE '성인' END,
COALESCE(u.nickname, u.name) // nickname 이 null 이면 name 사용
FROM User u5. fetch 조인과 DTO 조회
JPA? 의 연관은 보통 지연 로딩(필요할 때 따로 조회)이라,
목록을 돌면서 연관을 하나씩 건드리면 쿼리가 줄줄이 나가는 N+1 문제가 생깁니다.
JOIN FETCH 는 연관까지 한 방의 쿼리로 즉시 로딩해 이 문제를 해결해요.
// roles 까지 한 번에 — 이후 u.getRoles() 를 써도 추가 쿼리 없음 SELECT u FROM User u JOIN FETCH u.roles WHERE u.active = true
그냥 JOIN 은 조건·필터용일 뿐 연관을 메모리에 채워 주지 않습니다(나중에 또 쿼리가 나감).
JOIN FETCH 라야 연관 객체를 실제로 끌고 와 영속성 컨텍스트에 채워 줘요.
화면에 필요한 일부 컬럼만 골라 전용 DTO로 바로 받는 것도 가능합니다.
SELECT new 에 패키지까지 포함한 전체 클래스 이름과 생성자 인자를 적으면, JPA가 그 생성자로 객체를 만들어 반환해요.
SELECT new com.example.study.web.dto.UserDTO(u.userid, u.name) FROM User u WHERE u.active = true
이렇게 하면 엔티티 전체가 아니라 userid·name 두 값만 담은 가벼운 DTO 목록을 받습니다. (이 DTO는 영속 상태가 아닌 일반 객체예요.)
6. 페이징과 TypedQuery
createQuery(jpql, User.class) 처럼 결과 타입을 함께 주면 TypedQuery<User> 가 되어
형변환 없이 타입 안전하게 결과를 받습니다. 페이징은 setFirstResult(건너뛸 개수)와
setMaxResults(가져올 개수)로 표현해요.
TypedQuery<User> query =
em.createQuery("SELECT u FROM User u ORDER BY u.name", User.class);
List<User> page2 = query
.setFirstResult(20) // 앞 20개는 건너뛰고
.setMaxResults(10) // 10개만 (2페이지)
.getResultList();
// 단건은 getSingleResult() — 없거나 둘 이상이면 예외7. 네이티브 쿼리와 대안
JPQL로 표현하기 어려운 DB 고유 문법이 필요하면 createNativeQuery(sql) 로
진짜 SQL을 직접 실행할 수 있습니다. 단, DB에 종속되고 결과 매핑을 직접 챙겨야 해요.
// 네이티브 — 실제 테이블명 tb_user 를 그대로 사용
em.createNativeQuery("SELECT * FROM lds.tb_user WHERE name = ?1", User.class)
.setParameter(1, "경수");문자열 JPQL이 부담스러운 동적 조회에는 Criteria API(JPA 표준)나 QueryDSL(타입 안전한 자바 코드)을 대안으로 씁니다. 이 프로젝트는 QueryDSL을 쓰며, 자세한 건 JPA 개요 에서 다룹니다.
파트 B · 8. EntityManager 핵심 메서드
이제부터는 객체를 관리하는 쪽입니다. 모든 작업의 출입구는 EntityManager예요.
(import jakarta.persistence.*; — 패키지가 javax 가 아니라 jakarta 임에 주의)
| 메서드 | 하는 일 |
|---|---|
persist(e) | 새 엔티티를 영속 상태로 만든다(저장 예약). 커밋/flush 때 INSERT |
find(C, id) | 기본키로 조회. 컨텍스트에 있으면 그걸, 없으면 DB에서. 없으면 null |
getReference(C, id) | 실제 조회는 미루고 프록시(가짜 객체)만 먼저 받음. 실제 접근 시 로딩(행이 없으면 그때 EntityNotFoundException) |
merge(e) | 준영속/새 객체 상태를 컨텍스트에 병합. 관리되는 복사본을 반환 |
remove(e) | 영속 엔티티를 삭제 상태로. 커밋/flush 때 DELETE |
flush() | 지금까지 쌓인 변경을 즉시 DB에 동기화(SQL 실행). 트랜잭션은 유지 |
clear() | 컨텍스트를 통째로 비움 → 모든 엔티티가 준영속이 됨 |
detach(e) | 특정 엔티티 하나만 컨텍스트에서 떼어냄 → 준영속 |
contains(e) | 이 엔티티가 현재 컨텍스트에서 관리 중인지 boolean 으로 |
createQuery(...) | JPQL 질의 생성 |
createNativeQuery(...) | 네이티브 SQL 질의 생성 |
9. 영속성 컨텍스트란
영속성 컨텍스트(Persistence Context) 는 엔티티를 보관·관리하는 메모리 안의 작업 공간입니다. EntityManager가 이 공간을 관리해요. 같은 식별자(기본키)를 가진 엔티티는 이 안에 오직 하나만 존재합니다. 이 단순한 규칙에서 강력한 기능 5가지가 나옵니다.
| 기능 | 설명 |
|---|---|
| 1차 캐시 | 한 번 조회한 엔티티는 컨텍스트에 보관. 같은 트랜잭션에서 또 find 하면 DB 안 가고 캐시에서 |
| 동일성 보장 | 같은 식별자면 항상 같은 인스턴스 → a == b 가 true (자바 객체 동일성까지) |
| 쓰기 지연 | persist 해도 SQL을 바로 안 보내고 모았다가, 커밋/flush 때 한꺼번에 보냄(write-behind) |
| 변경 감지 | 관리 중인 엔티티의 필드가 바뀌면, 커밋 때 JPA가 자동으로 UPDATE를 만듦(dirty checking) |
| 지연 로딩 | 연관 객체는 프록시로 두었다가, 실제로 쓸 때 그제서야 조회 |
변경 감지 예제 — setter만으로 UPDATE
가장 신기한 부분입니다. 조회한 엔티티의 값만 바꾸면, 명시적인 저장 호출 없이도 트랜잭션이 끝날 때 UPDATE가 나갑니다.
@Transactional
public void rename(UUID id) {
User u = em.find(User.class, id); // 영속 상태로 가져옴
u.setName("새이름"); // 그냥 값만 바꿈
// em.persist 도 em.merge 도 호출 안 함!
// → 트랜잭션 커밋 시 변경 감지로 UPDATE 자동 실행
}JPA는 조회 시점의 스냅샷을 기억해 두었다가, 커밋 직전에 현재 값과 비교해 달라진 필드만 UPDATE 해요.
10. 엔티티 생명주기 — 4가지 상태
하나의 엔티티 객체는 항상 아래 네 상태 중 하나에 있습니다.
| 상태 | 의미 |
|---|---|
| 비영속 (transient/new) | new 로 막 만든 객체. 아직 컨텍스트와 무관, DB와도 무관 |
| 영속 (managed) | 컨텍스트가 관리 중. 변경 감지·1차 캐시 대상. 여기 있어야 자동 반영됨 |
| 준영속 (detached) | 한때 관리됐지만 컨텍스트에서 떨어져 나옴. 더는 추적 안 됨 |
| 삭제 (removed) | 삭제 예약됨. 커밋/flush 때 DELETE |
1. 비영속 (new) — new User() 로 객체를 만들면
아직 영속성 컨텍스트와도, DB와도 아무 관계가 없습니다. 그냥 자바 객체일 뿐이에요.
new User()
2. 비영속 → 영속 — persist(u) 하면 컨텍스트가 관리를 시작합니다.
find 로 DB에서 가져온 객체도 곧바로 영속 상태예요. 이제부터 변경 감지·1차 캐시가 적용됩니다.
persist / find
3. 영속 → 준영속 — detach(u)(하나만), clear()(전부),
또는 EntityManager가 닫히면(close()) 엔티티는 컨텍스트에서 떨어져 나옵니다.
준영속이 되면 더는 변경 감지가 안 돼요(값을 바꿔도 UPDATE 안 됨).
detach / clear / close
4. 준영속 → 영속 (merge) — 떨어져 나온 객체를 다시 관리시키려면
merge(u) 를 씁니다. 단, 넘긴 객체 자체가 관리되는 게 아니라 새 관리 인스턴스(복사본)를 반환해요.
그래서 이후 작업은 반환값으로 해야 합니다: User m = em.merge(u);
merge 복사본 반환
5. 영속 → 삭제 (remove) — remove(u) 하면 삭제 예약 상태가 됩니다.
트랜잭션 커밋이나 flush 시점에 실제 DELETE가 나가요.
remove
EntityManager 사용 흐름 예제
영속 상태가 되면 1차 캐시가 작동해, persist 직후 같은 식별자로 find 하면 같은 인스턴스가 돌아옵니다.
@Transactional
public void demo() {
User u = new User(); // 비영속 (그냥 객체)
u.setName("경수");
em.persist(u); // 영속 — 저장 예약(아직 INSERT 안 됨)
em.flush(); // 여기서 INSERT SQL 실제 실행
User found = em.find(User.class, u.getId());
System.out.println(u == found); // true — 1차 캐시, 동일성 보장
}JPQL + TypedQuery 예제
@Transactional(readOnly = true)
public List<User> activeUsers(String keyword) {
return em.createQuery(
"SELECT u FROM User u " +
"WHERE u.active = true AND u.name LIKE :kw " +
"ORDER BY u.name", User.class)
.setParameter("kw", "%" + keyword + "%")
.setMaxResults(50)
.getResultList();
}11. flush 시점과 merge, 그리고 함정
flush 는 쌓아둔 변경을 DB로 밀어내는 동작입니다. 세 시점에 일어나요.
- 트랜잭션 커밋 직전 — 가장 흔한 경우(자동)
- JPQL 질의 실행 직전 — 안 보낸 변경이 질의 결과에 영향 줄 수 있으니, 먼저 DB에 반영(기본
FlushModeType.AUTO) - 명시적
em.flush()호출 시
주의: flush는 변경을 DB에 동기화할 뿐, 트랜잭션을 커밋하지는 않습니다. 1차 캐시도 그대로 남아요.
merge 는 준영속(또는 새) 객체를 다시 관리시키되, 넘긴 객체가 아니라 새 관리 인스턴스를 반환한다는 점이 핵심입니다.
// detached 였던 u User managed = em.merge(u); u == managed; // false — u 는 여전히 준영속 em.contains(u); // false em.contains(managed); // true — 이후 작업은 managed 로!
가장 흔하게 마주치는 함정입니다. 지연 로딩 연관은 실제로 접근할 때 조회가 일어나는데,
그 시점에 영속성 컨텍스트가 이미 닫혀 있으면(트랜잭션 종료 후) 프록시가 DB에 접근하지 못해
LazyInitializationException 이 터집니다.
원인: 트랜잭션이 끝나 엔티티가 준영속이 된 뒤, 컨트롤러·뷰에서 u.getRoles() 같은 지연 연관에 처음 접근.
해결: ① 필요한 연관을 JOIN FETCH 로 트랜잭션 안에서 미리 로딩 / ② 트랜잭션 안에서 필요한 DTO로 변환해 반환 / ③ 트랜잭션 경계를 연관 접근까지 넓히기.
(무조건 즉시 로딩으로 바꾸는 건 N+1·과다 로딩을 부를 수 있어 권장되지 않아요.)
한눈에 — EntityManager 메서드 / 엔티티 상태
| 메서드 | 상태 변화 | 요약 |
|---|---|---|
persist(e) | 비영속 → 영속 | 저장 예약(INSERT는 flush/커밋) |
find(C, id) | → 영속 | 기본키 조회, 없으면 null |
getReference(C, id) | → 영속(프록시) | 실제 접근 시 로딩 |
merge(e) | 준영속 → 영속(복사본) | 관리 인스턴스를 반환 |
remove(e) | 영속 → 삭제 | DELETE는 flush/커밋 |
detach(e) | 영속 → 준영속 | 하나만 떼어냄 |
clear() | 전부 → 준영속 | 컨텍스트 비움 |
flush() | 유지 | 변경을 DB에 즉시 동기화 |
contains(e) | 유지 | 관리 중 여부(boolean) |
이 프로젝트에서는 보통 @Transactional 서비스 안에서 리포지토리를 통해 엔티티를 다루므로,
영속성 컨텍스트가 트랜잭션과 함께 살아 있습니다. 그래서 조회 후 setter만으로 수정(변경 감지)이 자연스럽게 동작하고,
지연 로딩 연관도 트랜잭션 안에서 안전하게 접근할 수 있어요. 실제 코드·리포지토리 구조와 깊은 내용은
be-04 데이터 접근 에서 다룹니다.
다음 단계
- JPA 전반·엔티티·리포지토리 개요 → JPA
- 이 모든 걸 감싸는 백엔드 프레임워크 마무리 → Spring
- 실제 코드로 배우는 데이터 접근 → be-04 데이터 접근