본문 바로가기
Tech Stack/Database

🗃️[JPA-02] JPA 엔티티와 영속성 컨텍스트 핵심 (+생명주기)

by 박수무당벌레 2026. 7. 9.

1. JPA 엔티티 (Entity)

1) 엔티티(Entity) 클래스란?

  • 정의: 데이터베이스의 테이블과 1:1로 매핑되는 자바 객체 클래스.
  • 주요 어노테이션:
    • @Entity: JPA가 관리할 클래스임을 선언(명시)한다.
      • ⚠️ 주의점: 이 어노테이션이 없으면 EntityManager가 해당 클래스를 인식하지 못해 영속성 컨텍스트에 담을 수 없다. 기본 생성자(접근 제어자가 public 또는 protected인 인자 없는 생성자)가 반드시 함께 있어야 함.
    • @Id: DB 테이블의 Primary Key(PK)와 매핑할 필드를 지정한다.
      • ⚠️ 주의점: 데이터베이스의 모든 테이블은 식별자(PK)가 필요하듯, JPA 엔티티도 영속성 컨텍스트(1차 캐시)에서 객체를 구분할 고유 ID가 필수. @Id가 지정된 필드가 없으면 프로젝트 빌드 시점에 에러가 발생한다.
    • @Table: 매핑할 DB 테이블 이름을 지정한다 (생략 시 클래스 이름 사용).
    • @Column: 컬럼명 변경이나 제약조건 추가 시 사용한다. (생략 시 필드 이름을 그대로 컬럼명으로 사용하며, 특정 제약 조건이나 컬럼명 변경이 필요할 때만 명시한다)
더보기

💡 @Column 추가 설명

  • 기본 원칙: 자바 필드명과 DB 컬럼명이 같고 별다른 제약 조건이 없다면 과감하게 생략
    •  JPA에서는 지정해주지 않아도 기본적으로 @Column이 생략된 것으로 간주하고 데이터베이스 테이블의 컬럼과 자동으로 매핑해 준다. 
  • 생략 시 기본값(Default) 적용: JPA는 자바 필드 명과 타입을 기반으로 다음과 같은 기본값을 적용한다.
    • 컬럼 이름: 자바 필드 이름을 그대로 사용한다.
      • 예시: private String name; ➡️ DB의 NAME 컬럼과 매핑
    • 데이터 타입 및 길이: 자바 타입을 기반으로 표준 DB 타입(예: VARCHAR(255))으로 자동 매핑된다.
    • null 허용 여부: 기본적으로 NULL을 허용(nullable = true)한다.
  • 기본 규칙 외에 특별한 제약 조건이나 매핑 설정이 필요할 때만 명시적으로 붙여주면 된다. 주로 다음과 같은 상황:
    • ① DB 컬럼명과 자바 필드명이 다를 때
    • ② 필수 값(Not Null) 제약 조건을 넣고 싶을 때 (JPA가 DDL을 자동 생성할 때 NOT NULL이 붙는다.)

2) 상속 및 슈퍼클래스 필드 주의점

  • 자바의 모든 클래스는 기본적으로 Object 클래스를 상속받지만, 상속받은 일반 속성은 DB 테이블의 컬럼으로 매핑되지 않는다.
  • 부모 클래스의 필드를 공통 컬럼으로 매핑하려면 @MappedSuperclass 처리가 필요하다.

2. JPA Entity와 영속성 컨텍스트 (Persistence Context)

1) 개념

  • JPA Entity란?: DB 테이블과 1:1로 매핑되는 자바 객체 클래스.
  • 영속성 컨텍스트란?:
    • "엔티티를 영구 저장하는 환경"을 의미하는 논리적인 영역.
    • EntityManager를 통해 엔티티를 저장하거나 조회하면, JPA는 이 영속성 컨텍스트 안에 엔티티 인스턴스들을 보관하고 상태를 관리한다.
  • 상속 및 필드 매핑 주의점:
    • 자바의 모든 클래스는 암시적으로 Object 클래스를 상속받지만, 상속받은 속성(필드/메서드)은 DB 테이블의 컬럼으로 매핑되지 않는다.
    • 슈퍼클래스의 필드를 테이블 컬럼으로 매핑하려면 @MappedSuperclass 같은 특정 어노테이션 처리가 필요하다.
  •  낙관적 잠금 (Optimistic Lock):
    • 동시성 제어를 위한 JPA의 메커니즘 중 하나.
더보기

ℹ️ 엔티티 비교 (JDBC vs JPA)

 

1) 기존 엔티티(Entity)의 의미 (JDBC 시절 포함)

  • 데이터베이스 설계(DB 모델링)에서 엔티티는 단순히 데이터베이스 테이블 그 자체 또는 테이블에 저장되는 독립된 데이터 단위를 뜻하는 개념적인 용어이다.
  • JDBC에서의 사용:
    • 엔티티를 자바 객체로 표현: 개발자들은 DB 테이블(엔티티)의 구조를 본떠서 자바 클래스를 만듦. 보통 DTO(Data Transfer Object) 또는 VO(Value Object)라고 부른다.
    • 한계:
      • JDBC는 자바 객체와 DB 테이블을 자동으로 연결해 주지 못함.
      • 이때의 객체는 단순히 DB 데이터를 임시로 담아두는 역할에 불과하며, JDBC 입장에서는 이게 엔티티인지 일반 클래스인지 알 방법이 없었음.
      • 개발자가 일일이 SQL 문을 짜서 데이터를 집어넣어 주어야 했다.

2) JPA에서의 '엔티티(Entity)'

  • JPA에서 말하는 엔티티는 단순한 개념이나 데이터를 담는 그릇 역할을 넘어, "JPA라는 프레임워크가 생명주기를 직접 관리하고 DB 테이블과 완벽하게 동기화하는 특별한 객체"를 의미한다.
    • 기술적인 JPA 엔티티는 영속성 컨텍스트라는 메모리 공간에서 적극적인 관리를 받는 특별한 자바 객체를 뜻함.

3) 📊 JDBC 객체 vs JPA 엔티티 비교

  • 가장 큰 차이점은 영속성 컨텍스트(Persistence Context)의 관리 여부. JPA는 '영속성 컨텍스트'라는 중간 계층을 통해 1차 캐시, 쓰기 지연, 변경 감지를 자동으로 처리해 준다.
  JDBC 시절의 객체 (DTO/VO) JPA의 엔티티 (@Entity)
정의 개발자가 수동으로 DB 구조를 흉내 낸 클래스 JPA가 인식하고 DB 테이블과 1:1 매핑한 클래스
상태 관리 없음 (데이터를 담고 있는 일반 자바 객체) 영속성 컨텍스트가 상태를 추적함 (비영속, 영속 등)
수정 방식 값을 바꾸고 반드시 수동으로 UPDATE SQL 실행 객체의 값만 바꾸면 변경 감지(Dirty Checking)로 자동 수정
ID 식별 자바 입장에서는 일반 변수 중 하나일 뿐 @Id를 통해 영속성 컨텍스트 내의 고유 식별자로 쓰임

2) 엔티티의 생명주기 (Entity Lifecycle)

  • EntityManager가 영속성 컨텍스트를 관리하며 엔티티의 생명주기(상태 변화)를 다룬다.
  • 비영속 (Transient): 객체를 생성만 한 상태로, 영속성 컨텍스트와 전혀 관련이 없는 상태. (영속성 컨텍스트가 관리하지 않음)
  • 영속 (Managed): em.persist(entity) 또는 em.find()를 통해 영속성 컨텍스트에 저장되어 JPA의 관리를 받는 상태.
  • 준영속 (Detached): 영속성 컨텍스트에 저장되어 있다가 분리된(em.detach(), em.clear()) 상태.
  • 삭제 (Removed): DB 및 영속성 컨텍스트에서 엔티티를 삭제하기로 예약한 상태.
        [ 비영속 (Transient) ]
                  │  em.persist()
                  ▼
          [ 영속 (Managed) ]  ─── em.detach() / em.clear() ───► [ 준영속 (Detached) ]
                  │
                  │  em.remove()
                  ▼
          [ 삭제 (Removed) ]

3. 영속성 컨텍스트의 핵심 기능 (내부 동작 원리/이점)

개발 생산성과 객체지향적 설계를 가능하게 해주는 내부 기능들.

① 1차 캐시 (First Level Cache / Entity Map)

  • 개념: 영속성 컨텍스트 내부에 존재하는, 영속 상태의 엔티티 인스턴스를 보관하는 메모리 저장소.
  • 조회 동작 방식 (예시):
    • 조회 요청(em.find() 호출) 시 DB보다 먼저 1차 캐시를 확인한다.
    • 1차 캐시에 해당 엔티티가 존재하면: DB 조회 없이 4번 동작으로. (메모리에서 바로 반환)
    • 1차 캐시에 해당 엔티티가 없으면(조회 ➡️ 객체 생성 ➡️ 저장):
      1. 행 데이터 조회: JDBC API를 통해 DB에서 데이터를 조회한다.
      2. 엔티티 객체 생성(변환): DB에서 가져온 로우(Row) 데이터를 바탕으로 자바 엔티티 객체를 만든다
      3. 1차 캐시 저장 & 복사본(스냅샷) 생성: 이 객체를 영속성 컨텍스트의 1차 캐시에 집어넣으면서(*리플렉션 활용), 그 집어넣는 순간의 최초 상태를 그대로 복사해서 별도의 공간에 보관한다. 이 복사본을 스냅샷이라고 한다.
    • 엔티티 반환: 모든 준비가 끝난 엔티티 객체를 호출한 비즈니스 로직(애플리케이션)으로 반환한다. 
      1. 개발자는 반환받은 객체를 활용해 데이터를 조회하거나 값을 수정(변경 감지)하는 비즈니스 로직을 수행하게 됨.
  •  .em.clear()를 호출하면 1차 캐시가 모두 초기화되어 이후 조회 시 다시 DB SELECT 쿼리가 나간다.
더보기

💡 JDBC vs 영속성 컨텍스트 데이터의 형태

 

JDBC가 데이터베이스에서 꺼내오는 데이터와 영속성 컨텍스트가 받아서 저장하는 데이터의 형태가 다르다. 아래는 과정별로 나누어 보면 비교 & 설명한다.

엔티티를 통한 비교는 위의 [ℹ️ 엔티티 비교 (JDBC vs JPA)] 에서 설명하고 있다.

 

1. JDBC API가 조회하는 데이터

데이터베이스 테이블의 로우(Row) 데이터 (가공되지 않은 데이터)

  • JDBC는 엔티티라는 개념을 모른다.
  • 개발자가 SELECT * FROM POST라는 SQL을 날리면, JDBC는 그저 데이터베이스 테이블에 있는 가공되지 않은 순수한 레코드(텍스트, 숫자 등의 데이터 묶음)를 ResultSet 형태로 조회해 올 뿐.

2. '엔티티'가 되는 시점

  • 조회한 로우 데이터를 자바 객체로 변환(매핑)한 후
  • JPA는 JDBC가 던져준 가공되지 않은 데이터를 받아서, 리플렉션(Reflection) 기술을 사용해 개발자가 정의한 엔티티 클래스(예: Post)의 인스턴스를 새로 생성하고 값을 채워 넣는다.
  • 이렇게 자바 객체로 변환 완료된 것이 바로 엔티티(Entity).

3. 1차 캐시에 저장되는 형태 (엔티티 객체, 스냅샷)

  • 이 변환된 엔티티를 1차 캐시에 저장할 때, 스냅샷과 엔티티가 동시에 만들어져 각각 보관된다. (별개의 메모리 영역)
  • 내부 구조(1차 캐시): Map 구조 (Key: @Id로 지정된 PK 값, Value: 엔티티 클래스 인스턴스 참조값).
  • 저장 형태:
    • Key: 엔티티의 식별자 PK (예: Post(1L)).
    • Value: 실제 자바 엔티티 객체의 메모리 주소(참조값).
      • 엔티티 객체: 실제로 비즈니스 로직에서 쓰고 값을 수정할 진짜 객체. (현재 객체)
    • 스냅샷 (Snapshot): 엔티티가 최초로 1차 캐시에 저장되는 순간(조회/등록 시점)의 고정된 초기 상태를 별도의 공간에 그대로 복사하여 보관. 
      • 개발자가 자바 객체 값을 아무리 수정해도 스냅샷의 초기값은 절대 변하지 않는다.
      • 나중에 변경 감지할 때 비교 기준점으로 쓰임 + 읽기 전용
더보기

✨ 리플렉션(Reflection) 활용이란?

 

1) JPA는 왜 리플렉션을 사용할까?

  • JPA(Hibernate) 프레임워크 입장에서 전 세계 수많은 개발자가 어떤 엔티티 클래스(예: Post, Member, Product 등)를 만들지 미리 알 수 없음.
  • 하지만 DB에서 데이터를 조회해 오면, 어떻게든 개발자가 만든 그 클래스의 객체를 생성해서 값을 채워주어야 한다. 이때 컴파일 시점이 아닌, 프로그램이 실행되는 도중에(런타임) 동적으로 객체를 조립하기 위해 사용하는 기술이 바로 리플렉션
    • 구체적인 클래스 타입을 알지 못해도, 런타임에 클래스의 메서드, 타입, 변수들에 접근할 수 있도록 해준다.
  • 요약: DB에서 가져온 진짜 데이터를 가지고 진짜 객체를 생성/조립할 때 쓰는 필수 기술 (1차 캐시 저장 시점)

 2) 1차 캐시 저장 시점의 리플렉션 동작 방식 (em.find()로 DB를 거쳐올 때)

  1. DB에서 로우 데이터 조회: em.find() 등을 통해 DB에서 실제 행(Row) 데이터를 한 줄 읽어온다
  2. 리플렉션을 통한 객체 생성: 
    • JPA는 리플렉션 기술을 사용해 해당 엔티티의 기본 생성자를 강제로 호출하여 빈 인스턴스를 만듦.
    • 💡 깨알 지식: JPA 엔티티에 인자 없는 기본 생성자가 필수인 이유가 바로 리플렉션이 이 기본 생성자를 통해 객체를 생성하기 때문. (빈 인스턴스를 만들고 나서 값을 채움)
  3. 필드 값 주입 및 1차 캐시 저장:
    • 생성된 빈 객체의 필드(변수)에 DB에서 가져온 진짜 데이터들을 직접 집어넣어 진짜 엔티티 객체를 완성한다.
    • 완성된 진짜 객체와 최초 스냅샷을 영속성 컨텍스트 내의 1차 캐시에 저장하고 반환.

3) 💡 연관 개념: 프록시(Proxy)와 지연 로딩

  • 개념: 데이터베이스 조회를 당장 하지 않고 뒤로 미루기 위해, 진짜 객체 대신 임시로 세워두는 가짜 대리자(껍데기) 객체
  • 등장 시점: 1차 캐시에 진짜 객체를 채워 넣는 시점이 아니라, 연관된 데이터를 당장 조회하지 않고 미루는 지연 로딩(Lazy Loading) 시점에 등장한다.
    • 1차 캐시에 넣을 진짜 객체를 아직 만들고 싶지 않을 때(DB 조회를 미루고 싶을 때) 등장 
  • 프록시 동작 방식 (예시): Post(게시글) 엔티티를 조회할 때 작성자 정보인 User 엔티티가 연관되어 있다고 가정. 게시글 본문만 읽으면 되는데 작성자 정보까지 DB에서 매번 가져오면 무겁다.
    1. 상황 및 지연 로딩 설정: 당장 화면에는 게시글 제목만 필요하고 작성자 정보는 필요하지 않은 상황.
    2. JPA의 행동 (가짜 객체 삽입): JPA는 User 자리에 진짜 객체를 리플렉션으로 만들지 않고, 껍데기만 똑같이 생긴 가짜 프록시 객체를 생성해서 꽂아둔 채로 Post를 반환한다. (DB 조회를 미룸)
    3. 실제 데이터가 필요한 시점 (프록시 초기화): 이후 비즈니스 로직 코드에서 user.getName()처럼 진짜 작성자 데이터가 필요한 순간이 오면, JPA가 비로소 DB를 조회하고 리플렉션을 가동해 진짜 데이터를 프록시 객체 내부에 채워 넣는다.

② 영속 엔티티의 동일성(Identity) 보장

  • 같은 트랜잭션 내에서 동일한 @Id로 조회한 엔티티는 Java의 == 비교 시 true를 반환한다.
Post a = em.find(Member.class, "post1");
Post b = em.find(Member.class, "post1");
System.out.println(a == b); // true (동일성 보장)

③ 쓰기 지연 버퍼 (Transactional Write-Behind Queue / ActionQueue)

  • 개념: em.persist()를 호출하는 즉시 DB에 SQL을 보내지 않고, 영속성 컨텍스트 내부의 쓰기 지연 SQL 저장소(ActionQueue)에 쿼리를 먼저 모아두는 메커니즘 (트랜잭션을 지원한다)
    • JPA나 Hibernate 같은 ORM 기술에서 아주 중요한 역할을 한다.
  • 실행 시점: 트랜잭션을 커밋(tx.commit())하거나 플러시(em.flush())하는 시점에 모아둔 SQL이 데이터베이스로 한꺼번에 일괄(Batch) 전달된다.
    1. em.persist()를 호출할 때(데이터의 변경 사항(등록, 수정, 삭제)을 발견할 때마다) SQL을 즉시 DB에 보내지 않고 SQL 저장소(ActionQueue)에 쌓아둔다.
    2. 트랜잭션을 commit() 하거나 em.flush() 하는 시점에 모아둔 SQL이 DB로 한꺼번에 전송되어 실행된다.
  • 💡 쓰기 지연을 사용하는 이유와 장점
    • 네트워크 비용 감소 (Batch Insert): 쿼리가 발생할 때마다 매번 DB와 통신(Network I/O)하면 성능이 떨어진다. 쿼리를 모았다가 한 번에 보냄으로써 JDBC 일괄 처리(Batch, 배치) 기능을 극대화할 수 있다.
    • 애플리케이션 수준의 최적화: 한 트랜잭션 내에서 데이터를 여러 번 수정하거나 생성 후 바로 삭제하는 경우, 영속성 컨텍스트가 최종 상태만 계산하여 꼭 필요한 쿼리만 압축해 보낼 수 있다.
      • 일반적인 방식(예시): INSERT ➡️ UPDATE ➡️ UPDATE ➡️ DELETE 총 4번의 쿼리가 실행됨.
      • 쓰기 지연 방식: 극한의 경우 쿼리가 하나도 안 나가거나 꼭 필요한 쿼리만 압축해서 보낼 수 있다.
    • 비즈니스 로직 집중: 개발자는 데이터베이스 트랜잭션 시점에 맞춰 SQL이 나가는 것을 신경 쓰지 않고, 순수 자바 객체를 다루듯 비즈니스 로직에만 집중할 수 있다.
      • 트랜잭션 커밋 시점에 성공하면 다 같이 반영되고, 실패하면 다 같이 안 되는 원자성(Atomicity)을 보장받기가 훨씬 수월해 짐.
더보기

💡 어떻게 동작하는지? (동작 원리)

 

만약 코드에서 새로운 게시글(Post) 3개를 연속으로 저장(persist)한다고 가정했을 때:

  1. em.persist(post1);
    • 영속성 컨텍스트의 '1차 캐시'에 post1을 저장한다.
    • 동시에 이 객체를 DB에 넣기 위한 INSERT SQL을 생성해서 '쓰기 지연 SQL 저장소'라는 곳에 차곡차곡 쌓아둔다. (아직 DB로는 안 보냄)
  2. em.persist(post2); ...
    • 마찬가지로 1차 캐시에 저장하고, INSERT SQL을 쓰기 지연 저장소에 계속 쌓는다.
  3. tx.commit(); (트랜잭션 커밋 시점)
    • 트랜잭션을 커밋하면, JPA가 내부적으로 flush()를 먼저 호출한다.
    • 이때 쓰기 지연 SQL 저장소에 모여있던 3개의 INSERT SQL이 한번에 데이터베이스로 전송됨.
    • 마지막으로 데이터베이스의 진짜 트랜잭션이 커밋되면서 데이터가 최종 반영된다

💡 요약: 쓰기 지연은 데이터베이스와의 불필요한 통신을 줄이기 위해 영속성 컨텍스트라는 '버퍼(Buffer)'를 두고 쿼리를 모아서 던지는 최적화 메커니즘.

④ 변경 감지 (Dirty Checking)

  • 개념: 영속 상태의 엔티티 값이 변경되었을 때, JPA가 이를 자동으로 감지하여 데이터베이스에 반영하는 메커니즘.
  • 원리: JPA는 엔티티가 영속 상태가 될 때의 최초 상태를 복사한 스냅샷을 1차 캐시에 함께 저장해 둔다. 
  • 수정 동작 방식 (프로세스)
    1. 엔티티 값 수정: 영속 상태인 엔티티 객체의 값을 변경한다.
      • 예시: post1.setTitle("제목 수정합니다!");
    2. 1:1 상태 비교 (플러시/커밋 시점): 트랜잭션이 커밋되거나 플러시(em.flush())가 호출되는 시점에, JPA가 엔티티의 현재 상태와 최초 1차 캐시에 저장되었던 스냅샷을 서로 비교(Dirty Checking)한다.
      • 영속성 컨텍스트가 엔티티의 "처음 상태(스냅샷)"를 기억하고 있다가, 트랜잭션이 끝날 때 "현재 상태"와 자동으로 비교해 주는 것.
      • 때문에 별도의 update()나 save() 메서드를 호출하지 않아도 됨. 개발자가 수동으로 "수정되었다"고 알려주지 않아도 자바 객체의 변경 사항을 스스로 추적할 수 있는 것.
    3. UPDATE 쿼리 자동 생성 및 실행: 비교 결과 변경사항이 감지되면, JPA가 자동으로 UPDATE SQL을 생성하여 쓰기 지연 SQL 저장소에 등록한다. 이후 트랜잭션 커밋 또는 플러시(em.flush()) 시점에 저장소에 모여있던 UPDATE SQL이 데이터베이스로 한꺼번에 전송되어 최종 반영(실행)된다.

4. 실습 코드 흐름 요약 (Post 엔티티)

// 1. 비영속 상태 (일반 자바 객체 단계, 영속성 컨텍스트가 아직 모름)
Post post1 = new Post(1L, "첫 번째 글", "안녕하세요.");
Post post2 = new Post(2L, "두 번째 글", "반갑습니다.");

// 2. 영속 상태 (1차 캐시 등록 + INSERT 쿼리 쓰기 지연 큐 등록)
em.persist(post1);
em.persist(post2);

// 3. 변경 감지 (Dirty Checking)
// 객체 값만 변경하면, 커밋 시점에 자동으로 UPDATE 쿼리가 생성되어 실행됨
// → 별도의 update()를 부르지 않아도 됨
post1.setTitle("제목 수정합니다!");

// 4. 삭제 대상 조회 및 삭제 (DELETE 쿼리가 쓰기 지연 큐에 등록됨)
Post deleteTarget = em.find(Post.class, 2L);
em.remove(deleteTarget); // DELETE 쿼리가 쓰기 지연 큐에 등록됨

// 5. 플러시 및 트랜잭션 커밋
// 이 시점에 쓰기 지연 큐에 쌓여있던 쿼리들(INSERT 1개, UPDATE 1개, DELETE 1개)이 DB로 한꺼번에 전송된다.
tx.commit()