기본 콘텐츠로 건너뛰기

JPA EntityManager란 무엇인가: 영속성 컨텍스트와 엔티티 상태를 실무 흐름으로 이해하기

JPA EntityManager란 무엇인가: 영속성 컨텍스트와 엔티티 상태를 실무 흐름으로 이해하기

빠른 답

  • EntityManager의 핵심 역할은 DB에 바로 쓰는 것이 아니라 영속성 컨텍스트를 통해 엔티티 상태를 관리하는 데 있다.
  • persist를 호출해도 INSERT가 즉시 나가지 않을 수 있으며, 실제 SQL 시점은 flush와 commit에 달려 있다.
  • 1차 캐시와 변경 감지는 영속 상태의 엔티티에서만 기대한 대로 동작한다.
  • merge, detach, clear를 무심코 쓰면 예상하지 못한 UPDATE 누락이나 중복 조회가 생길 수 있다.

흐름으로 보기

JPA EntityManager란 무엇인가: 영속성 컨텍스트와 엔티티 상태를 실무 흐름으로 이해하기 흐름 다이어그램

실무에서는 이 여섯 단계를 머릿속에 두는 것만으로도 JPA 동작을 훨씬 예측하기 쉬워집니다. 객체를 만들었다고 바로 DB와 연결되는 것이 아니고, persist()로 영속성 컨텍스트에 올린 뒤에야 관리가 시작됩니다. 이후 필드 변경은 메모리 안에서 추적되다가 flush()commit 시점에 SQL로 반영됩니다. 마지막으로 detach()는 관리 대상에서 분리하고, remove()는 삭제 예정 상태로 바꿉니다.

왜 EntityManager를 영속성 컨텍스트로 이해해야 할까

EntityManager를 메서드 모음으로만 보면 persist()는 저장, find()는 조회, remove()는 삭제처럼 따로따로 보입니다. 하지만 실제로는 모두 영속성 컨텍스트를 조작하는 행위입니다.

예를 들어 find()는 단순 조회가 아닙니다. 먼저 1차 캐시를 확인하고, 없으면 DB에서 읽어온 뒤 그 엔티티를 영속 상태로 올립니다. persist()도 즉시 INSERT를 보낸다기보다 새 엔티티를 관리 대상으로 등록하는 동작에 가깝습니다. 이 차이를 이해해야 다음 개념들이 자연스럽게 연결됩니다.

  • 1차 캐시: 같은 트랜잭션에서 중복 조회를 줄입니다.
  • 변경 감지: 영속 상태 엔티티의 필드 변경을 추적합니다.
  • 쓰기 지연: SQL을 모아 적절한 시점에 보냅니다.
  • 동일성 보장: 같은 식별자의 엔티티를 같은 객체처럼 다룹니다.

즉, EntityManager는 DB API가 아니라 상태 관리 API로 이해하는 편이 맞습니다.

영속성 컨텍스트와 EntityManager는 어떤 관계일까

영속성 컨텍스트는 엔티티를 관리하는 메모리상의 작업 공간입니다. EntityManager는 그 공간을 다루는 인터페이스입니다. 개발자는 EntityManager를 통해 엔티티를 넣고, 찾고, 분리하고, 삭제 예약을 합니다.

이 관계를 간단히 말하면 다음과 같습니다.

  • 영속성 컨텍스트: 엔티티를 보관하고 상태를 추적하는 곳
  • EntityManager: 그 컨텍스트를 조작하는 창구

Spring Boot에서는 보통 개발자가 EntityManager를 직접 생성하지 않습니다. 트랜잭션 범위 안에서 프록시 형태의 EntityManager가 연결되고, 그 내부에서 실제 영속성 컨텍스트가 동작합니다. 그래서 코드가 단순해 보여도 실제로는 트랜잭션과 상태 관리가 함께 묶여 돌아갑니다.

엔티티 상태는 어떻게 바뀔까

JPA를 이해할 때 가장 중요한 축은 메서드 이름보다 엔티티 상태입니다. 엔티티는 크게 비영속, 영속, 준영속, 삭제 상태를 오갑니다.

  • 비영속: new로 만들었지만 아직 JPA가 관리하지 않는 상태
  • 영속: 영속성 컨텍스트가 관리하는 상태
  • 준영속: 한때 관리되었지만 지금은 분리된 상태
  • 삭제: 삭제 예정으로 관리되는 상태

상태 변화는 코드로 보면 더 명확합니다.

Member member = new Member("sancho");   // 비영속

em.persist(member);                     // 영속

member.changeName("sancho-park");      // 변경 감지 대상

em.detach(member);                     // 준영속

em.remove(em.merge(member));           // 삭제 예약

비영속 상태에서는 객체를 아무리 바꿔도 DB와 상관이 없습니다. 영속 상태가 되면 JPA가 필드 변화를 추적할 수 있고, flush() 시점에 UPDATEINSERT를 실행합니다. 준영속이 되면 다시 평범한 객체가 되므로 값을 바꿔도 자동 반영되지 않습니다. 삭제 상태 역시 즉시 DELETE가 나가는 것이 아니라 보통 flush()commit 시점에 실제 SQL이 실행됩니다.

Spring Boot에서 EntityManager와 트랜잭션은 어떻게 묶일까

실무에서 EntityManager는 트랜잭션 안에서 가장 자연스럽게 이해됩니다. @Transactional이 시작되면 그 범위에서 영속성 컨텍스트가 유지되고, 메서드 종료 시점에 flush()commit이 이어집니다.

아래 코드는 가장 흔한 흐름입니다.

@Service
@RequiredArgsConstructor
public class MemberService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public Long join(String name) {
        Member member = new Member(name);   // 비영속
        em.persist(member);                 // 영속

        member.changeName(name + "-joined"); // 변경 감지

        return member.getId();
    }

    @Transactional(readOnly = true)
    public Member find(Long id) {
        return em.find(Member.class, id);
    }
}

이 코드에서 persist()가 호출되는 순간 엔티티는 영속 상태가 됩니다. 하지만 INSERT가 꼭 그 줄에서 나간다고 생각하면 안 됩니다. 실제 SQL은 트랜잭션 종료 직전, 혹은 쿼리 실행 직전의 flush() 타이밍에 나갈 수 있습니다. 그래서 JPA를 볼 때는 코드 순서와 SQL 순서를 구분해서 봐야 합니다.

persist, find, detach, remove, flush를 코드로 따라가 보기

아래 예시는 실무에서 가장 자주 마주치는 메서드들을 한 번에 보여줍니다.

@Transactional
public void entityManagerFlow(Long id) {
    Member created = new Member("sancho");
    em.persist(created);

    created.changeName("sancho-park");

    Member first = em.find(Member.class, created.getId());
    Member second = em.find(Member.class, created.getId());

    System.out.println("same instance = " + (first == second));

    em.flush();

    em.detach(first);
    first.changeName("detached-name");

    Member target = em.find(Member.class, id);
    em.remove(target);
}

여기서 볼 포인트는 세 가지입니다.

  • persist()는 영속 상태로 만드는 호출이지, 반드시 즉시 INSERT를 보내는 호출은 아닙니다.
  • 같은 트랜잭션 안에서 같은 식별자를 find()로 두 번 조회하면 1차 캐시 때문에 같은 객체를 돌려줄 수 있습니다.
  • detach() 이후에는 필드 값을 바꿔도 변경 감지가 동작하지 않습니다.

즉, JPA의 편의 기능은 영속 상태를 전제로 한다는 점을 기억해야 합니다.

merge가 특히 헷갈리는 이유

merge()는 많이 쓰이지만 가장 자주 오해되는 메서드이기도 합니다. 핵심은 전달한 객체 자체를 다시 관리하는 것이 아니라, 그 상태를 복사한 관리 객체를 반환한다는 점입니다.

이 차이를 놓치면 merge() 이후 원본 객체를 계속 수정하면서 왜 반영이 안 되는지 헷갈리게 됩니다. 실무에서는 아래 상황이 자주 문제를 만듭니다.

  • 준영속 객체를 merge()한 뒤, 반환값이 아니라 원본 객체를 계속 수정하는 경우
  • DTO 일부 필드만 채운 객체를 merge()해 의도치 않게 값이 덮어써지는 경우
  • 수정 전에 관리 엔티티를 조회하지 않아 예상보다 많은 SELECT가 발생하는 경우

그래서 신규 저장은 persist() 중심으로, 수정은 가능하면 조회한 영속 엔티티를 직접 바꾸는 방식이 더 예측 가능합니다. 즉, "찾고 바꾸기"가 "통째로 합치기"보다 안전한 경우가 많습니다.

SQL 로그로 보면 동작이 더 선명해진다

JPA를 디버깅할 때 가장 중요한 단서는 코드가 아니라 SQL 로그입니다. persist()를 호출했는데 왜 SQL이 안 보이는지, 왜 갑자기 UPDATE가 나갔는지, 왜 같은 엔티티를 또 조회하는지 같은 질문은 로그를 보면 바로 풀리는 경우가 많습니다.

먼저 로컬에서 자주 쓰는 설정은 아래 정도면 충분합니다.

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

이제 실제 출력 예시를 보겠습니다.

2026-04-05 14:21:03 DEBUG org.hibernate.SQL
    select m1_0.id, m1_0.name
    from member m1_0
    where m1_0.id=?

2026-04-05 14:21:03 TRACE org.hibernate.orm.jdbc.bind
    binding parameter [1] as [BIGINT] - [1]

same instance = true

2026-04-05 14:21:04 DEBUG org.hibernate.SQL
    insert into member (name, id)
    values (?, ?)

2026-04-05 14:21:04 TRACE org.hibernate.orm.jdbc.bind
    binding parameter [1] as [VARCHAR] - [sancho-park]
    binding parameter [2] as [BIGINT] - [10]

2026-04-05 14:21:05 DEBUG org.hibernate.SQL
    delete from member
    where id=?

이 로그에서 읽어야 할 핵심은 다음입니다.

  • find() 호출로 먼저 SELECT가 나갔습니다.
  • 같은 엔티티를 다시 조회했을 때 애플리케이션 출력이 same instance = true로 찍혔습니다.
  • persist() 직후가 아니라 flush() 또는 트랜잭션 종료 시점에 INSERT가 실행됐습니다.
  • remove() 역시 호출 즉시가 아니라 동기화 시점에 DELETE가 나갑니다.

운영 중 "값을 바꿨는데 UPDATE가 없다"는 문제가 보이면 먼저 그 엔티티가 정말 영속 상태였는지, 중간에 detach()clear()가 있었는지부터 확인하는 것이 빠릅니다.

실무에서 자주 놓치는 주의점

EntityManager를 직접 많이 쓰지 않더라도 Spring Data JPA의 Repository는 결국 이 모델 위에 서 있습니다. 그래서 Repository만 사용하더라도 아래 원칙은 알아두는 편이 좋습니다.

  • 수정 로직은 가능하면 조회한 영속 엔티티를 변경하는 방식으로 작성합니다.
  • flush()commit은 같은 개념이 아닙니다. flush()는 동기화이고, commit은 최종 확정입니다.
  • clear()는 1차 캐시를 비우므로 이후 조회에서 다시 SELECT가 나갈 수 있습니다.
  • detach()된 객체는 겉보기엔 같아 보여도 더 이상 변경 감지 대상이 아닙니다.
  • merge()는 편리해 보여도 관리 객체와 원본 객체를 헷갈리게 만들 수 있습니다.

결국 EntityManager는 "DB에 명령을 내리는 객체"보다 "엔티티 생명주기를 관리하는 객체"로 이해해야 실전에서 덜 헷갈립니다. 이 관점이 잡히면 persist, find, flush, detach, remove가 각각 따로 떨어진 API가 아니라 하나의 상태 전이 흐름으로 보이기 시작합니다.

원문 참고

https://www.maeil-mail.kr/question/29

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...