프레임워크 · JPA · JPQL · 영속성 컨텍스트

JPA · JPQL과 영속성 컨텍스트

JPA? 에는 두 개의 큰 기둥이 있습니다. 하나는 객체를 대상으로 질의하는 언어 JPQL, 다른 하나는 객체를 메모리에서 관리하는 영속성 컨텍스트입니다. 이 둘을 알면 JPA가 왜 그렇게 동작하는지 보이기 시작해요.

JPQL은 "어떤 객체를 데려와줘"라고 주문하는 말투이고, 영속성 컨텍스트는 데려온 객체들을 보관하는 "내 책상 위 작업 공간" 같은 것입니다. 책상에 올려둔 객체는 JPA가 계속 지켜보다가, 내용이 바뀌면 알아서 DB에 반영해 줘요. (버전 기준: Jakarta Persistence 3.1, Hibernate 6.x, Java 17)

이 글은 두 파트로 나뉩니다.

  • 파트 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 같은 키워드는 대소문자를 가리지 않습니다(소문자로 써도 됨). 반면 엔티티 이름과 필드 이름은 대소문자를 구분해요(Useruser, 자바 클래스명 그대로). 그리고 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 u

5. 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 의 차이

그냥 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 == btrue (자바 객체 동일성까지)
쓰기 지연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 로!
LazyInitializationException — 트랜잭션 밖에서 지연 로딩

가장 흔하게 마주치는 함정입니다. 지연 로딩 연관은 실제로 접근할 때 조회가 일어나는데, 그 시점에 영속성 컨텍스트가 이미 닫혀 있으면(트랜잭션 종료 후) 프록시가 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 데이터 접근