그동안 JPA를 사용하면서 아무 생각없이 사용하였던 것 같다.
단순히,
Service 계층에서 의존할 로직을 영속성 계층에 위치시켜 사용하고, JPA를 활용하기 위해 JpaRepository를 상속받는다.
이와 같이 결과론적으로만 생각을 하였던 것 같다.
JPA가 snapshot을 저장하여 전후의 변경점을 감지하는 것 까지는 알고 있었는데, 이것이 "영속성 컨텍스트"를 관리하는 과정이며, 그렇기에 "영속성 계층"이 이로부터 참조한 개념이라는 것까지는 미처 생각하지 못하였다.
일전에 항해를 하면서 배운 개념 중 하나인 Advice/jointpoint 등을 이해하고, 나아가 Entity Manager 및 트랜잭션까지 깊게 생각할 필요가 있는 사항이기에 좀 더 살펴보게 되었다.
지금의 공부를 기초로 더 깊게 JPA를 이해할 수 있는 기반을 만들어보았다.
위에서 기술하였듯이 JPA는 스냅샷 변경을 감지하여 이를 DB에 반영한다.
이때, JPA가 영속성 컨텍스트를 만들어 개발자가 이를 관리할 수 있는 직접적인 기능을 제공한다는 것이 핵심이며, 만들어진 영속성 컨텍스트는 1차 캐시에 저장된다.
DB에 반영한다는 것은 "영구저장 혹은 그러한 상태가 지속될 수 있는 것을 보장"의 의미를 가지고 있는데, 이 "영구저장"의 개념에서 영속성(Persistence)이 발생하였다고 생각하면 되겠다.
이는 무작정 Persistence Layer로 명명하고 있기에 그냥 이렇게 계층을 이해하면 되겠구나가 아닌, 어떤 유래/목적으로 그러한 이름을 사용하고 있는지 살펴보자는 의미에서 기술하였다.
본격적으로 JPA의 실무적 이점을 살펴볼 것이다.
그 첫번째 과정은 myBatis와의 비교이다.
mybatis와 JPA를 깊게 비교하였을때, 유의미한 차이점을 발견할 수 있었다.
꽤 재미있는 부분인데,
MyBatis는 SQL 매퍼라서, SQL을 실행하고 결과를 즉시 "객체"로 매핑할 뿐, 그 객체의 상태 변화를 추적하거나 동일성을 보장하지 않는다.
기본적으로는 매번 쿼리 → 매핑 → 반환의 흐름이고, 반환된 객체는 그냥 일반 Java 객체(POJO)이라는 점, 즉 이는 영속성 컨텍스트와는 거리가 멀다.
따라서 MyBatis는 "영속성 컨텍스트"라는 추적/동기화 메커니즘이 전혀 없고, JPA의 ORM(영속성 컨텍스트를 매핑하기 위한 도구)/Entity Manager(동일 트랜잭션 흐름 내 개발자가 이를 직접 관리하고 조율할 수 있는 기능) 등의 관점에서 비교하였을때 단순하고 다소 폐쇄적이라는 것을 알 수 있다.
MyBatis 내부에도 캐시는 있다.
1) 1차 캐시: 같은 SqlSession 내에서 동일한 쿼리를 수행하면 결과를 재사용(*MyBatis에서 사용하는 SqlFactory/SqlSession이 여기서 나온 개념).
2) 2차 캐시: Mapper 단위로 공유 가능한 캐시.
하지만 이건 쿼리 "결과"의 캐싱일 뿐 이를 "관리한다"라는 개념과는 다소 멀다.
객체의 변경을 추적하지 않고, flush 타이밍도 없고, 동일성(identity) 보장도 하지 않는다. JPA의 Persistence Context와는 완전히 다른 의미이고, 다만 재미있는 것은 "큰 흐름"으로 보았을때 내부적으로 DB에 반영하는 로직은 꽤나 비슷하다는 점이다.
Transactional 어노테이션을 사용할때 유의해야할 점은, Spring Framework 관점에서 트랜잭션 관리 정책과 개발자가 관리하는 Entity Manager 관리 정책이 충돌할 수 있다.
Entity Manager라는 개념을 알았으므로, Transaction 시 발생하는 AOP Proxy 개념과 다시 융합하면서 정책적 충돌이 발생하지 않도록 유의할 필요가 있겠다.
@Transactional이 붙은 메서드는 AOP 프록시(advice)로 감싸져서 실행이 이루어지고, 메서드 진입 시점에 트랜잭션 시작, 종료 시점에 commit/rollback 한다.
트랜잭션은 DB Connection 단위로 관리되고, TransactionSynchronizationManager라는 ThreadLocal 기반 저장소에 현재 트랜잭션 정보를 보관한다.
같은 스레드 내에서 @Transactional을 통해 트랜잭션이 이미 열려 있음에도 새로운 @Transactional을 만난다면, 새로 시작하지 않고 기존 걸 재사용한다.
예를 들어,
@Service
public class OrderService {
private final PaymentService paymentService;
@Transactional // (1) 트랜잭션 시작
public void placeOrder() {
// 주문 로직 수행
paymentService.pay(); // (2) 또 다른 @Transactional 메서드 호출
}
}
@Service
public class PaymentService {
@Transactional // (2) 여기서 또 트랜잭션 어드바이스를 만남
public void pay() {
// 결제 로직 수행
}
}
위와 같이 서비스 계층에서 다른 서비스를 호출하는데, 그 호출한 서비스가 @Transactional를 사용하고 있는 경우, propagation의 기본 설정인 "REQUIRED"를 인식하여 기존의 AOP Proxy를 재사용한다.
즉, placeOrder() 실행 시점에서 @Transactional를 통해 만들어진 Spring AOP Proxy가 감싸고 있어서 트랜잭션 어드바이스가 동작하게 되고, 이 시점(메서드 실행시점)부터 트랜잭션을 시작한다.
TransactionSynchronizationManager에 현재 스레드의 트랜잭션 정보 바인딩된다.
이때 paymentService.pay() 호출하고, 보니까 여기에도 @Transactional이 붙어 있기에 또다른 트랜잭션 어드바이스를 만난다.
전파 옵션(propagation) 확인하였을때, 기본값인 REQUIRED로 인식하게 되고(REQUIRED의 의미: 이미 트랜잭션이 있으면 그걸 그대로 사용하고, 없으면 새로 만든다), 이에 따라 placeOrder()에서 열린 트랜잭션을 그대로 이어받는다.
결국 두 메서드(placeOrder, pay)는 같은 트랜잭션 안에서 실행된다. 즉,
하나라도 예외 발생 → 전체 롤백.
정리하면,
JPA의 EntityManager는 MyBatis처럼 단순히 DB로 반영하기 위해 만들어진 일반 Java 객체가 아니라, Spring이 관리하는 프록시예요.
우리가 @PersistenceContext 또는 생성자 주입으로 주입받는 건 "진짜 EntityManager"가 아니라, 현재 트랜잭션에 맞는 EntityManager를 찾아주는 프록시(대리 객체)가 되겠다.
이 프록시는 내부적으로 JPA에서 사용하는 TransactionSynchronizationManager에서, 현재 스레드 트랜잭션과 연결된 EntityManager를 꺼내 사용합니다.
여기서 핵심은 "스레드 트랜잭션과 연결된 EntityManager 객체를 꺼내 사용하여 이를 관리할 수 있도록 제공해준다는 것".
-> 그렇기에 동일한 스레드로 관리 중인 트랜잭션이라면, JPA에서는 이와 연결한 "동일한 문맥의 Entity Manager"를 생성하여 준다는 의미.
즉, 같은 스레드에서, 같은 트랜잭션 안이라면, 다른 서비스 클래스/메서드로 넘어가도 자동으로 동일한 EntityManager 인스턴스가 사용된다.
@Transactional 어노테이션으로 인해 트랜잭션 AOP를 실행하는 과정에서, 다른 서비스 호출로 인해 로직의 진입점, 즉 "JointPoint"가 달라진다면 고려해야할 사항이 있는가?
결론적으로 말하자면, "동일 트랜잭션"을 구성한다고 하였을때 JointPoint와 트랜잭션의 차이점이 발생할 수 있는 여지는 없다.
AOP에서 "jointpoint"는 단순히 "메서드 실행 지점"일 뿐이다.
@Transactional이 붙은 여러 메서드가 호출되더라도, 트랜잭션 전파 속성(기본 REQUIRED) 덕분에 같은 트랜잭션 안에서 실행하므로, 개발자가 EntityManager에게 "이거 같은 트랜잭션이야!"라고 직접 알려줄 필요가 전혀 없다.
같은 스레드, 같은 트랜잭션 전파 규칙(REQUIRED) 하에서는 EntityManager는 자동으로 동일한 트랜잭션을 인식한다.
서비스 클래스가 다르든, jointpoint가 다르든, 같은 트랜잭션 경계 내라면 동일한 EntityManager가 바인딩된다.
개발자가 수동으로 "같다고 인식시켜줄" 작업은 필요 없다.
다만, 별도의 트랜잭션을 만들 필요가 있을 경우(전체 Rollback이 아닌 일부 결과 반영이 필요할 경우),
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void pay() {
// 여기서는 항상 새로운 트랜잭션 시작
}
위와 같이 propagation(전파정책)을 바꿔주면 될 것이고, 이에따른 jointpoint에서의 정책이나 Entity Manager 설정도 알맞게 변경해주면 되겠다.
기본적으로 위와 같은 과정을 잘 인지하고 있다면, 새로운 트랜잭션을 만드는 과정도 그리 어려운 일은 아닐 것으로 생각한다.
지금까지 Spring 프레임워크의 트랜잭션 관리 방법과 EntityManager의 개념, 서로 연결하면서 트랜잭션이 어떻게 이루어지는지 살펴보았다.
이것만 보아도 MyBatis에 비해 왜 실무에서 JPA를 사용하는지 알 수 있게 된다.
myBatis와 비교하니 꽤 재미있고 유의미한 비교 결과가 나타난 것 같다.