이 글에서는 JPA의 트랜잭션과 영속성 컨텍스트가 어떻게 협력하는지 살펴보겠습니다. 앞선 글 “영속성 컨텍스트 이해하기”에서 영속성 컨텍스트(Persistence Context)의 개념과 엔티티 생명주기를 다뤘다면, 이번에는 트랜잭션과 영속성 컨텍스트가 실제로 어떤 관계로 동작하는지 Hibernate 구현을 중심으로 파헤쳐봅니다. JPA 구현체인 Hibernate의 1차 캐시(영속성 컨텍스트), 쓰기 지연(write-behind), 플러시(flush), 더티 체킹(변경 감지) 등이 트랜잭션 범위 내에서 어떻게 작용하는지 구체적으로 설명합니다. 또한 Spring 프레임워크 없이 JPA를 사용할 때와 Spring 환경에서 @Transactional을 사용할 때 트랜잭션 관리와 영속성 컨텍스트의 연결이 어떻게 달라지는지도 함께 다룹니다.
JPA에서는 영속성 컨텍스트(Persistence Context)가 엔티티의 저장 공간이자 1차 캐시 역할을 합니다. 이 영속성 컨텍스트는 보통 트랜잭션 범위에 맞추어 생존합니다. 트랜잭션이 시작되면 새로운 영속성 컨텍스트가 연결되고, 트랜잭션이 끝나면 영속성 컨텍스트도 종료되어 관리되던 엔티티들이 더 이상 관리되지 않는 상태(Detached)로 전환됩니다. 즉, 트랜잭션 범위가 곧 영속성 컨텍스트의 범위라고 할 수 있으며, 이를 트랜잭션 스코프 영속성 컨텍스트라고 부릅니다. 아래 그림은 JPA 엔티티의 생명주기(state transition)를 나타낸 것입니다. 엔티티가 새로운 상태(비영속)에서 영속성 컨텍스트에 관리되는 영속 상태로 들어가고, 트랜잭션이 끝나면 준영속(DETACHED)으로 분리되는 등의 과정을 보여줍니다.
JPA 엔티티 생명주기 – 엔티티가 비영속(New) → 영속(Managed) → 준영속(Detached) → 삭제(Removed) 상태로 변이하는 모습. persist()를 통해 영속상태에 들어가고, 트랜잭션이 끝나면 영속성 컨텍스트가 종료되면서 엔티티는 준영속(detached) 상태가 된다. flush()는 영속성 컨텍스트의 변경 내용을 DB 트랜잭션에 동기화하지만 commit으로 트랜잭션이 확정되어야 실제 DB에 반영된다.
트랜잭션이 없는 상태에서는 JPA의 영속성 컨텍스트를 통한 데이터 변경이 즉시 DB에 반영되지 않습니다. 사실 JPA 표준에서는 데이터를 변경하는 모든 작업(persist, merge, remove 등)은 반드시 활성화된 트랜잭션 안에서 실행되도록 규정하고 있으며, 그렇지 않을 경우 TransactionRequiredException 예외가 발생합니다. 예를 들어 트랜잭션을 시작하지 않고 entityManager.persist(entity)를 호출하면 오류가 발생하거나, Hibernate의 구현상 일시적으로 영속성 컨텍스트에 객체를 저장해두더라도 flush 시점에 트랜잭션이 없어 실제 SQL이 수행되지 않습니다.
반대로 트랜잭션 안에서는 엔티티를 영속화하거나 수정하는 작업이 모두 하나의 영속성 컨텍스트 안에서 이뤄지고, commit 시점에 일괄 DB 반영됩니다. 트랜잭션 범위 내에서는 엔티티를 조회(find)할 때 1차 캐시를 사용하여 동일 트랜잭션 내 중복 조회를 막고, 동일 식별자의 엔티티는 항상 동일한 인스턴스로 관리됩니다 (엔티티 동일성 보장). 또한 트랜잭션이 활성화된 상태에서 persist나 remove 등의 조작을 하면 영속성 컨텍스트가 변경 내용을 추적하고 있다가 나중에 flush를 통해 DB에 전달합니다.
참고: JPA에는 영속성 컨텍스트의 범위를 트랜잭션과 별개로 확장할 수 있는 EXTENDED 모드도 있습니다. EXTENDED 영속성 컨텍스트는 여러 트랜잭션에 걸쳐 지속되며, 트랜잭션 밖에서도 엔티티를 영속성 컨텍스트에 유지할 수 있습니다. 이 경우 트랜잭션이 없는 구간에서 수행한
persist(),merge(),remove()등은 즉시 DB에 SQL을 보내지 않고 영속성 컨텍스트에 대기(queue)시켰다가, 해당 영속성 컨텍스트가 트랜잭션에 join될 때(예: 새로운 트랜잭션 시작) 한꺼번에 flush되어 반영됩니다. EXTENDED 모드는 주로 복잡한 사용자 대화형 작업(conversation)을 stateful하게 처리해야 하는 EJB, JSF같은 환경에서 사용하며, Spring 부트 애플리케이션에서는 기본적으로 사용되지 않습니다 (별도 설정 필요). 대부분의 애플리케이션은 기본값인 TRANSACTION 스코프 영속성 컨텍스트를 사용하며, 이번 글에서도 특별한 언급이 없으면 트랜잭션 스코프를 가정합니다.
일반적으로 하나의 트랜잭션마다 별도의 영속성 컨텍스트(엔티티매니저)가 사용됩니다. 예를 들어 10명의 사용자가 각각 독립적인 서비스 로직을 트랜잭션으로 실행하면, 각 트랜잭션마다 자신만의 1차 캐시(영속성 컨텍스트)를 갖게 되므로 총 10개의 EntityManager 인스턴스가 운용됩니다. 1차 캐시의 범위가 트랜잭션으로 구분되기 때문에, 다른 사용자의 트랜잭션이나 다른 스레드 사이에 1차 캐시가 공유되지 않습니다. 따라서 1차 캐시는 동일 트랜잭션 내에서의 조회 성능이나 동일 엔티티 식별자에 대한 동등성(identity) 보장에는 큰 이점을 주지만, 트랜잭션을 넘어선 글로벌 캐시로서 동작하지는 않음에 유의해야 합니다. (글 하단에서 설명할 2차 캐시(프로세스 레벨 캐시)는 별개입니다.)
요약하면 트랜잭션이 시작되면 영속성 컨텍스트가 열리고 엔티티를 관리하기 시작하며, 커밋되면 그동안의 변경 내용을 DB에 반영하고 영속성 컨텍스트는 비워지거나 종료됩니다. 트랜잭션 밖에서는 영속성 컨텍스트를 통해 엔티티를 조회할 수는 있지만 (읽기에는 제한이 적음), 데이터를 변경하는 작업은 제한되거나 지연됨을 기억해야 합니다.
이제 트랜잭션의 흐름에 따라 영속성 컨텍스트가 어떻게 동작하는지 살펴보겠습니다. 핵심 개념은 flush(플러시)와 commit(커밋), 그리고 rollback(롤백) 시의 동작입니다. 또한 영속성 컨텍스트의 변경 감지(더티 체킹) 메커니즘이 언제 발동되는지도 함께 정리합니다.
트랜잭션을 시작하면 (EntityTransaction.begin() 또는 @Transactional 진입 시) 해당 스레드에 영속성 컨텍스트가 할당됩니다. 트랜잭션 스코프의 EntityManager의 경우, 트랜잭션 시작 시점에 새로운 영속성 컨텍스트를 만들어 트랜잭션과 연결하거나, 이미 생성된 EntityManager가 있다면 그 안의 영속성 컨텍스트를 해당 트랜잭션과 연결합니다. 이때부터 엔티티를 조회하거나 등록/수정/삭제하는 작업은 모두 이 열린 영속성 컨텍스트를 통해 이뤄지며, 영속성 컨텍스트는 엔티티의 상태를 추적합니다.
em.find()로 가져온 Member 엔티티가 있다면, 영속성 컨텍스트는 해당 엔티티의 복사본을 내부에 저장해두고 (스냅샷), 나중에 flush 시점에 현재 값과 비교합니다. 이렇게 함으로써 개발자가 update 메소드를 호출하지 않아도 엔티티 객체의 필드 변경만으로 변경 사항을 감지할 수 있게 됩니다 (이 과정을 Dirty Checking, 변경 감지라고 합니다).persist()나 remove() 같은 연산으로 발생한 INSERT/DELETE SQL문을 즉시 DB에 보내는 대신 일단 저장소에 적재해둡니다. 이 역시 트랜잭션이 진행되는 동안 DB와의 교신을 최소화하고 마지막에 한번에 반영하는 (write-behind) 전략입니다. 이를 Hibernate의 쓰기 지연 (write-behind)이라고 하며, 영속성 컨텍스트가 변경사항을 누적해두는 중요한 이유입니다.커밋은 트랜잭션을 종료하면서 그 동안의 변경 내용을 실제 데이터베이스에 확정하는 단계입니다. JPA 구현체(Hibernate)는 트랜잭션을 커밋할 때 자동으로 flush()를 호출하여 영속성 컨텍스트의 변경내역을 DB와 동기화합니다. flush가 호출되면 아래와 같은 일들이 벌어집니다.
entity.setName("새이름")처럼 엔티티만 변경했다면, flush 시점에 비로소 “이 엔티티의 name 필드가 변경되었구나”를 체크하고 SQL을 준비하는 것입니다. 이처럼 트랜잭션 커밋 직전에 자동으로 수행되는 flush를 통해 JPA는 엔티티 변경사항을 DB에 반영할 SQL로 변환합니다.em.persist(user)를 3번 호출했다면 INSERT 문 3개가 쌓여있을 것이고, flush 시점에 이 INSERT 문 3개가 DB로 보내집니다. 중요하게도, 이때 DB 트랜잭션은 아직 커밋되지 않은 상태입니다. flush는 단지 SQL을 DB에 보내어 실행하지만, DB 입장에서는 아직 트랜잭션이 끝나지 않았으므로 (auto-commit이 아닐 경우) 다른 세션에서 그 변경을 볼 수 없습니다. 쉽게 말해 flush = SQL 발행 (DB 임시 반영)이고 commit = 트랜잭션 확정입니다. flush로 보낸 SQL은 commit이 일어나기 전까지 DB의 트랜잭션 로그에 머물며 롤백 가능 상태로 존재합니다.Connection.commit()을 호출하고, JTA 환경에서는 JTA 트랜잭션을 커밋하여 모든 변경을 확정합니다. 이 시점에 비로소 다른 트랜잭션에서도 변경 내용을 조회할 수 있게 됩니다.em.clear()를 호출해 기존 영속성 컨텍스트를 초기화하고 재사용할 수도 있습니다.flush 후 clear, detach에 대하여: EntityManager.flush()는 말 그대로 영속성 컨텍스트 내용을 DB와 동기화만 할 뿐 영속성 컨텍스트를 비우지는 않습니다 . 따라서 커밋 직전 수동으로 em.flush()를 호출했다고 해서 엔티티가 영속성 컨텍스트에서 사라지지 않습니다. 반면 EntityManager.clear()는 영속성 컨텍스트를 완전히 초기화하여 모든 엔티티를 detach 상태로 만들고, EntityManager.detach(obj)는 특정 엔티티 한 개만 준영속 상태로 분리합니다. 이들은 주로 특정 시점에 메모리 관리를 위해 쓰이거나, 긴 트랜잭션에서 일부 엔티티를 더 이상 관리하지 않도록 하기 위해 사용됩니다. 일반적인 트랜잭션 커밋 과정에서는 flush 이후 clear나 detach를 자동으로 호출하지는 않지만, 트랜잭션이 끝나면 결과적으로 영속성 컨텍스트는 사라지므로 (EntityManager 종료) 모든 엔티티가 detach 된 효과가 납니다.
정리: commit = flush + 데이터베이스 commit. flush는 영속성 컨텍스트 ↔ DB 간 동기화이고, commit은 DB 측 트랜잭션을 끝내는 것입니다. 둘의 차이로 인해, flush만 해서는 다른 트랜잭션에서 변경 내용을 볼 수 없고, commit해야 비로소 영구 반영됩니다. 또 commit하면 영속성 컨텍스트가 비워지므로, commit 이후에는 동일 EntityManager를 계속 사용할 수 없다면 엔티티가 더 이상 관리되지 않음을 유의해야 합니다.
트랜잭션 도중 예외 등의 이유로 rollback이 일어나면, 해당 트랜잭션에서 수행된 모든 DB 변경은 취소됩니다. JPA에서 롤백 시에는 영속성 컨텍스트에도 변화를 주는데, 롤백이 발생하면 영속성 컨텍스트가 관리하던 엔티티들은 모두 준영속(detached) 상태로 변환됩니다. JPA 명세에 따르면 “트랜잭션 롤백 시, 트랜잭션 내 존재하던 모든 관리 엔티티와 삭제된(remove된) 엔티티는 detach되어 더 이상 Persistence Context에서 관리되지 않는다”고 명시되어 있습니다. 즉, rollback 이후에는 영속성 컨텍스트가 더 이상 유효하지 않으므로 (데이터 불일치 가능성 때문에) 해당 EntityManager를 새로 만들어 써야 하거나, rollback 직후에는 즉시 영속성 컨텍스트를 clear하거나 close해야 합니다.
롤백 시 flush 여부는 보통 중요하지 않은데, 왜냐하면 flush가 이미 일어났더라도 rollback하면 DB 변경이 취소되고, flush가 안 일어났다면 애초에 DB에 간 영향이 없기 때문입니다. 다만 주의할 점은, JPQL 쿼리 등의 실행으로 트랜잭션 중간에 flush가 발생한 경우 rollback 시 그동안 수행된 SQL이 모두 취소되지만, 영속성 컨텍스트 내 엔티티의 상태는 업데이트되었을 수 있습니다. 예를 들어 FlushMode AUTO 상황에서, 중간에 JPQL 실행으로 flush되어 엔티티 A가 DB에 업데이트되었다가 이후 rollback되면, DB는 원상태로 돌아갔지만 영속성 컨텍스트의 엔티티 A 필드는 변경된 값을 유지하고 있을 수 있습니다. 이러한 불일치(inconsistency) 때문에 JPA는 롤백 시 해당 엔티티들을 detach해버리는 것입니다. 따라서 rollback 이후에는 영속성 컨텍스트에 더 이상 엔티티가 남아있지 않으며, 필요하다면 새로운 트랜잭션을 시작해서 다시 객체를 조회하거나 해야 합니다. (Spring에서는 롤백이 일어나면 해당 EntityManager를 자동으로 폐기하여 이후 사용시 새로운 영속성 컨텍스트를 쓰도록 처리합니다.)
요약하면, 롤백 발생 시 영속성 컨텍스트는 모든 변경을 폐기하고 (DB도 롤백, 영속성 컨텍스트도 엔티티 분리) 더 이상 사용할 수 없게 되며, 새 트랜잭션을 시작해서 다시 사용해야 합니다.
Hibernate 구현체를 중심으로, 영속성 컨텍스트(1차 캐시)와 트랜잭션이 구체적으로 어떻게 맞물려 동작하는지 자세히 알아보겠습니다. Hibernate의 Session은 JPA의 EntityManager에 대응하며, Session이 관리하는 1차 캐시가 바로 영속성 컨텍스트입니다. Session(영속성 컨텍스트)과 트랜잭션은 흔히 1:1 관계로 사용되는데 (“세션 당 한 트랜잭션” 패턴), 이때 세션이 열려있는 동안 동일 트랜잭션 내에서 조회한 엔티티는 1차 캐시에 저장되어, 이후 동일한 엔티티를 다시 조회하면 DB를 재조회하지 않고 캐시를 반환합니다. 또한 Session은 앞서 설명한 대로 엔티티 변경을 모니터링하여 트랜잭션 커밋 시점에 필요한 SQL을 생성합니다.
Hibernate의 1차 캐시는 트랜잭션 내에서 반복 가능한 읽기(Repeatable Read)를 제공하는데 기여합니다. 예를 들어 같은 트랜잭션 내에서 em.find(User.class, userId)를 두 번 호출하면 두 번째 호출은 DB 쿼리를 보내지 않고 처음 조회한 엔티티 인스턴스를 그대로 반환합니다. 이 동작은 영속성 컨텍스트가 트랜잭션 동안 해당 엔티티를 캐싱하고 있기 때문입니다. 아래 그림은 1차 캐시의 동작을 보여줍니다. 처음 find("member2")를 호출하면 1차 캐시에 해당 엔티티가 없어서 DB를 조회하고, 영속성 컨텍스트에 엔티티를 저장한 뒤 반환합니다. 그 다음 같은 find("member2")를 호출하면 이번에는 DB를 재조회하지 않고 1차 캐시에서 찾아 바로 반환합니다.
Hibernate 1차 캐시와 조회 흐름 – 1) 트랜잭션 내 처음 조회하는 엔티티(예: member2)는 1차 캐시에 없으므로 DB에서 조회한 후 3) 1차 캐시에 저장한다. 4) 이후 같은 엔티티를 조회하면(예: 다시 find("member2")) DB를 거치지 않고 캐시로부터 바로 객체를 반환한다. 이러한 1차 캐시는 각 트랜잭션마다 분리되어 동작하며, 트랜잭션이 끝나면 캐시도 함께 소멸된다*
위 그림에서 볼 수 있듯, 1차 캐시로 인해 동일 트랜잭션에서는 같은 엔티티를 재조회해도 일관된 상태를 얻고 쓸데없는 쿼리를 줄일 수 있습니다. 또한 트랜잭션이 진행되는 동안 영속성 컨텍스트가 엔티티 객체를 관리하므로, 애플리케이션에서는 객체를 통해 편하게 데이터를 다루고,commit 시점에 Hibernate가 그 객체의 변경을 캐치하여 SQL로 반영하게 됩니다. (이는 객체 지향적인 unit of work 패턴으로, Hibernate가 개발자 대신 트랜잭션 단위 작업을 관리해주는 특징입니다.)
Hibernate Session은 쓰기 지연 전략을 통해 트랜잭션을 최적화합니다. 앞서 언급한 대로, session.persist(entity)를 호출해도 즉시 INSERT SQL이 실행되지 않고, 영속성 컨텍스트(1차 캐시)에 엔티티가 저장되며 SQL은 내부 큐(액션 큐)에 쌓입니다. 마찬가지로 entity.setName("변경") 같이 엔티티를 변경해도 바로 UPDATE가 실행되는 것이 아니라, 변경된 엔티티를 일단 1차 캐시에 유지하고 있을 뿐입니다. 이러한 변경들은 flush 시점에 한꺼번에 DB에 전송되는데, flush는 앞에서 설명했듯이 트랜잭션 커밋 시 자동 호출되거나, JPQL 실행 시점, 또는 개발자가 명시적으로 em.flush()를 호출할 때 발생합니다.
Hibernate에서는 FlushMode 설정을 통해 flush 타이밍을 제어할 수 있습니다. JPA 표준의 FlushModeType은 AUTO(기본값)와 COMMIT 두 가지가 있으며, Hibernate는 추가로 ALWAYS, MANUAL 등을 지원합니다.
Member를 persist해놓고 (INSERT 대기), 곧바로 SELECT m FROM Member m JPQL을 실행하면, 쿼리 대상 테이블이 Member이므로 Hibernate는 쿼리 실행 전에 persist했던 내용을 DB에 flush해서 쿼리 결과에 방금 추가한 Member가 포함되도록 합니다. (만약 flush를 안 하면 메모리엔 있지만 DB에는 아직 없으니 쿼리 결과에 빠집니다.)@Transactional(readOnly=true)를 설정하면 Hibernate Session의 FlushMode를 MANUAL로 설정하여 commit 시에도 flush를 생략하게 하는데, 이는 FlushMode.COMMIT과 유사한 효과를 내며 변경 감지 비용도 줄여줍니다.)flush()를 직접 호출하지 않으면 커밋 시에도 flush를 수행하지 않습니다. Spring의 readOnly 트랜잭션이 내부적으로 이 모드와 유사하게 동작합니다.대부분의 경우 기본 FlushMode.AUTO로 동작하며, Hibernate는 최대한 flush 시점을 지연시켜 트랜잭션이 끝나기 직전에 몰아서 쓰기를 합니다. 이로써 불필요한 중간 DB I/O를 줄이고 트랜잭션 내에서는 자유롭게 엔티티를 변경한 뒤 한 번만 DB와 통신하게 최적화합니다.
Hibernate의 Session은 내부적으로 JDBC 커넥션을 관리하며, Session.beginTransaction()을 호출하면 JDBC의 auto-commit을 끄고 실제 DB 트랜잭션을 시작합니다. 이후 flush/commit/rollback 동작은 앞서 JPA 전체에서 설명한 내용과 동일한 개념으로 진행됩니다. 정리하면:
autocommit=false) 트랜잭션 경계를 시작합니다.save/persist 등으로 엔티티를 추가하거나 수정하면 Hibernate는 이를 일단 메모리(1차 캐시와 액션큐)에 저장하고, DB에는 바로 적용하지 않습니다.connection.commit()을 호출하여 DB에 실제 커밋을 수행합니다. 이 과정에서 예외가 없다면 DB 트랜잭션이 완료되고, Session이 관리하던 엔티티들은 세션 종료와 함께 모두 분리됩니다.connection.rollback()을 호출하여 DB 변경을 취소합니다. Hibernate 세션도 더 이상 유효하지 않으므로 세션에 남아있던 엔티티를 파기(detach)하고 세션을 종료합니다.Hibernate는 JDBC 커넥션과 트랜잭션을 직접 다루면서도 JPA 표준의 EntityManager API와 동작을 따르도록 구현되어 있습니다. 개발자는 JPA의 EntityManager나 Hibernate의 Session API로 동일하게 트랜잭션 범위의 작업을 기술하고, flush/commit 시점의 동작은 Hibernate가 맡아서 처리합니다. 추가로, Hibernate는 낙관적 락(버전 관리)을 flush/commit 시에 자동 처리하여 동시성 충돌을 감지하는 기능도 제공합니다 (예: @Version 필드가 있다면 flush 시 version을 비교/증가). 이러한 상세한 부분은 이번 범위를 넘어서므로 여기서는 언급만 하고 넘어가겠습니다.
지금까지는 주로 컨테이너나 프레임워크가 트랜잭션을 관리(Transaction Manager)하는 상황을 가정했습니다. 이번에는 Spring 등의 트랜잭션 매니저 없이 순수 JPA를 사용할 때 트랜잭션과 영속성 컨텍스트를 관리하는 방식을 알아보겠습니다. 예를 들어 자바 SE 환경에서 EntityManagerFactory를 직접 생성하고 EntityManager를 얻어 사용할 때, 혹은 Java EE 컨테이너 없이 Resource-Local 트랜잭션을 사용할 때를 생각해볼 수 있습니다.
JPA에서는 EntityManager.getTransaction() 메서드를 통해 트랜잭션 제어 객체(EntityTransaction)를 얻을 수 있습니다. 개발자가 이 객체의 begin(), commit(), rollback()을 직접 호출하여 트랜잭션 경계를 명시적으로 관리해야 합니다. 아래는 간단한 예시입니다.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("example-unit");
EntityManager em = emf.createEntityManager(); // 엔티티 매니저 생성 (영속성 컨텍스트 준비)
EntityTransaction tx = em.getTransaction(); // 트랜잭션 객체 획득
try {
tx.begin(); // 트랜잭션 시작
Member member = new Member("user1", "회원1");
em.persist(member); // 영속성 컨텍스트에 엔티티 등록 (쓰기 지연)
member.setName("회원1_수정"); // 엔티티 필드 변경 (영속성 컨텍스트가 변경 감지)
// ... 필요한 다른 DB 작업 ...
tx.commit(); // 트랜잭션 커밋 -> flush 자동 수행 후 commit
} catch (Exception e) {
tx.rollback(); // 트랜잭션 롤백
} finally {
em.close(); // 엔티티 매니저 종료 (영속성 컨텍스트 종료)
}
위 코드에서는 Spring 없이 애플리케이션 코드에서 직접 트랜잭션을 관리하고 있습니다. 트랜잭션 매니저 없이 사용할 때 유의해야 할 점은 다음과 같습니다.
begin()을 호출하지 않으면 persist 등의 작업이 불가능하며, commit()이나 rollback()을 호출하지 않으면 열린 트랜잭션이 유지되어 커넥션이 반환되지 않고 잠금이 걸린 상태가 지속될 수 있습니다. 반드시 try-catch-finally 구조로 commit/rollback을 보장하고, 마지막에 EntityManager를 닫아야 자원 누수가 없습니다.em.getTransaction().begin() 호출 시 자동으로 그 영속성 컨텍스트가 새 트랜잭션에 join되며, 이전 트랜잭션에서 대기 중이던 쿼리들이 있었다면 (extended일 때) 실행됩니다. 일반적으로는 혼동을 피하기 위해 트랜잭션마다 새로운 EntityManager를 사용하는 것이 안전합니다.persist()하여 영속화하려면 반드시 트랜잭션 안에서 호출해야 합니다. 트랜잭션 밖에서 persist()를 호출하면 예외가 발생하거나 (영속성 컨텍스트가 트랜잭션에 join되지 못했다는 오류) flush 시 반영되지 않습니다. 즉, 새 엔티티를 저장하고 싶다면 tx.begin() 이후에 em.persist()를 호출해야 합니다.em.merge()입니다. 이 역시 트랜잭션 안에서 호출되어야 DB에 반영됩니다. merge()는 해당 엔티티의 복사본을 새 영속성 컨텍스트에 만들어서 영속 상태로 만들고, 그 복사본을 반환합니다. 따라서 detached 엔티티를 다루는 패턴은:tx.begin();
managedEntity = em.merge(detachedEntity);
/* 변경 */;
tx.commit(); 와 같습니다. 또는 애초에 detach되지 않도록 같은 영속성 컨텍스트를 유지해야 합니다.요약하면, Spring 없이 JPA를 사용할 때는 개발자가 트랜잭션과 영속성 컨텍스트의 수명을 직접 관리해야 합니다. 특히 커밋/롤백 누락이나 EntityManager 미종료로 인한 문제가 발생하지 않도록 주의해야 합니다. 반면 Spring이나 Java EE 컨테이너를 사용하면 이러한 부분을 프레임워크가 알아서 처리해줍니다.
스프링(Spring) 프레임워크를 사용하면 보통 @Transactional 어노테이션과 JpaTransactionManager를 통해 트랜잭션을 관리합니다. 이때 개발자는 트랜잭션 시작이나 커밋을 직접 호출하지 않고, 스프링이 제공하는 선언적 트랜잭션 관리에 의해 트랜잭션과 영속성 컨텍스트가 관리됩니다. Spring과 JPA(Hibernate)가 어떻게 연동되는지 살펴보겠습니다.
Spring은 PlatformTransactionManager 인터페이스를 통해 다양한 트랜잭션 기술을 추상화하는데, JPA에는 JpaTransactionManager 클래스를 사용합니다. JpaTransactionManager는 내부적으로 EntityManagerFactory를 통해 EntityManager를 생성/관리하고, 이를 스레드 로컬(ThreadLocal)에 바인딩하여 보관합니다. 예를 들어, @Transactional이 붙은 서비스 메서드가 호출되면:
JpaTransactionManager를 통해 새로운 트랜잭션을 시작합니다. 이 과정에서 EntityManagerFactory로부터 EntityManager를 하나 생성하고, TransactionSynchronizationManager를 통해 현재 스레드에 바인딩합니다 . 이렇게 하면 이후 이 스레드에서 실행되는 영속성 연산들은 모두 같은 EntityManager를 참조하게 됩니다. (@PersistenceContext로 주입된 EntityManager는 실제로는 프록시로서, 같은 스레드에 바인딩된 실제 EntityManager를 찾아 위임합니다.)참고: Reactive 환경에서는
PlatformTransactionManager대신ReactiveTransactionManager를 사용하며 이는 thread binding이 아니라 reactive context binding(context propagation) 방식을 이용한다.
repository.save()나 em.persist(), em.find() 등을 호출하면, Spring Data JPA의 경우 이미 주입된 EntityManager (프록시)를 통해, 혹은 EntityManagerFactoryUtils.getEntityManager 등을 통해 스레드에 바인딩된 EntityManager를 얻어와 사용합니다. 결국 동일한 EntityManager (영속성 컨텍스트) 안에서 모든 작업이 진행되므로 앞서 설명한 1차 캐시, 더티 체킹 등이 적용됩니다. 여러 DAO나 리포지토리를 호출해도 하나의 트랜잭션 안에서는 같은 영속성 컨텍스트를 공유합니다.JpaTransactionManager에게 commit을 지시하고, 예외 발생 시 rollback을 지시합니다. JpaTransactionManager는 JPA 표준에 따라 commit 시 EntityManager.flush()를 호출하여 변경사항을 flush한 뒤 DB 트랜잭션을 커밋합니다. 만약 예외로 rollback하게 되면 EntityTransaction.rollback()을 호출하여 취소합니다. 그 다음 EntityManager를 스레드에서 언바인드하고 close (또는 clear)하여 영속성 컨텍스트를 정리합니다. 이로써 해당 스레드에서는 더 이상 그 EntityManager를 참조하지 않게 되고, 트랜잭션이 종료된 것입니다.Hint: Spring의
JpaTransactionManager는 하나의EntityManagerFactory당 한 개의 EntityManager를 스레드에 바인딩해서 사용합니다. 트랜잭션이 없는 상태에서@PersistenceContext로 EntityManager를 호출하면 Spring은 자동으로 트랜잭션을 시작하거나 새로운 EntityManager를 만들어주지 않으므로,@Transactional을 통해 트랜잭션을 열어주어야 합니다. (프록시 EntityManager는 트랜잭션이 없으면 자체적으로 EntityManager를 생성하여 동작할 수도 있지만, 일반적으로는 service 계층에 트랜잭션을 단다고 가정합니다.)
LazyInitializationException). 따라서 지연로딩 활용 여부와 성능을 고려해 OSIV 사용 여부를 결정해야 합니다.스프링의 트랜잭션은 전파(propagation) 속성을 통해 기존 트랜잭션에 참여하거나 새로 시작하는 등의 행위를 제어할 수 있습니다. 기본값은 REQUIRED로, 이미 진행 중인 트랜잭션이 있으면 거기에 참여하고 없으면 새로 시작합니다. 따라서 같은 스레드에서 중첩 호출되는 @Transactional 메서드들은 기본적으로 하나의 트랜잭션/영속성 컨텍스트를 공유합니다. 이런 경우 내부 메서드에서는 별도로 flush/commit이 일어나지 않고, 최종 경계에서 한 번 commit됩니다.
또한 앞서 언급했듯 @Transactional(readOnly=true)로 설정하면 Hibernate는 FlushMode를 MANUAL로 변경하여 플러시를 생략하고, 영속성 컨텍스트에서도 더티체킹을 위한 스냅샷을 보관하지 않도록 최적화합니다. 이렇게 하면 엔티티 변경 시에도 flush되지 않으므로 안전하게 조회 전용으로 사용할 수 있고, 성능도 향상됩니다. 다만 readOnly 트랜잭션 내에서 데이터 변경을 하면 flush가 안 일어나므로 커밋해도 DB에 반영되지 않으니 주의해야 합니다. (Spring Data JPA는 readOnly 트랜잭션에서 변경 발생 시 경고 로그를 남깁니다.)
@Transactional을 통해 개발자 대신 트랜잭션 시작/종료와 EntityManager 할당/반납을 관리해줍니다. 이로써 영속성 컨텍스트의 라이프사이클이 자동으로 트랜잭션과 연계됩니다.정리하면, JPA의 영속성 컨텍스트는 트랜잭션과 불가분의 관계를 맺으며 함께 동작합니다. 트랜잭션이 없다면 영속성 컨텍스트는 제대로 역할을 할 수 없고, 트랜잭션이 진행되는 동안에만 엔티티 변경을 모아두었다가 커밋 시 반영하는 식으로 일합니다. Hibernate 구현체를 통해 살펴본 내부 동작으로, 1차 캐시와 쓰기 지연, 더티 체킹 등이 모두 트랜잭션 범위 내에서 최적화되고 관리됨을 알 수 있었습니다.
스프링 같은 프레임워크를 활용하면 이러한 트랜잭션 시작/종료와 영속성 컨텍스트 생명주기를 수동으로 다루지 않아도 되어 개발 편의성이 높아집니다. 하지만 내부 동작 원리를 이해하는 것은 성능 튜닝이나 문제 발생 시 원인을 파악하는 데 큰 도움이 됩니다. 예컨대 “왜 트랜잭션 밖에서 persist가 안 되는지”, “flush를 했는데 왜 DB에는 반영이 안 되었는지”, “rollback 후 왜 엔티티를 다시 조회해야 하는지”와 같은 질문에 대해, 이 글에서 다룬 내용을 바탕으로 명쾌하게 설명할 수 있을 것입니다.
마지막으로, 영속성 컨텍스트와 트랜잭션을 잘 활용하면 복잡한 데이터 상태 관리를 JPA에게 맡기고 개발자는 비즈니스 로직에 집중할 수 있지만, 반대로 이를 모르면 예상치 못한 쿼리 발생이나 데이터 갱신 타이밍 문제로 어려움을 겪을 수도 있습니다. 이번 기회에 트랜잭션과 영속성 컨텍스트의 협력 관계를 제대로 익혀 두어, JPA를 더욱 효과적으로 활용하시길 바랍니다.