Spring JPA EntityManager 동시성 처리 원리

Spring Framework 기반의 백엔드 시스템을 설계할 때 가장 빈번하게 마주치는 역설이 있습니다. 바로 "싱글톤(Singleton) 객체 내에서의 상태(State) 관리" 문제입니다. Service나 Repository 레이어의 빈(Bean)들은 기본적으로 싱글톤으로 관리되어 애플리케이션 전역에서 공유됩니다. 반면, 데이터베이스 접근의 핵심인 EntityManager는 스펙상 명백히 스레드에 안전하지 않은(Thread-unsafe) 상태 저장 객체입니다.

만약 EntityManager가 순수한 객체 그대로 싱글톤 빈에 주입된다면, 동시 요청이 발생했을 때 Transaction A의 데이터가 Transaction B의 영속성 컨텍스트를 오염시키는 심각한 레이스 컨디션(Race Condition)이 발생할 것입니다. 하지만 실제 운영 환경에서는 별다른 동기화 코드 없이도 이러한 문제가 발생하지 않습니다. 본 글에서는 Spring이 Shared EntityManager ProxyThreadLocal을 활용하여 이 모순을 기술적으로 어떻게 해결했는지 아키텍처 관점에서 분석합니다.

1. EntityManager의 생명주기와 동시성 문제

Jakarta Persistence(구 JPA) 명세에 따르면, EntityManager 인스턴스는 영속성 컨텍스트(Persistence Context)와 1:1로 연결됩니다. 이곳에는 1차 캐시(First-level Cache), 쓰기 지연 저장소(SQL Action Queue) 등 현재 트랜잭션의 상태 정보가 메모리에 상주합니다. 따라서 이 객체는 본질적으로 상태를 가지며(Stateful), 여러 스레드가 공유해서는 안 됩니다.

Risk Assessment:
순수 Java SE 환경에서 정적(Static) 변수나 공유 객체에 EntityManager를 할당하고 멀티 스레드에서 em.persist()를 호출하면, 서로 다른 스레드의 엔티티가 뒤섞이거나 이미 닫힌 세션에 접근하는 예외가 발생합니다. 이는 데이터 무결성을 심각하게 훼손합니다.

하지만 Spring의 Repository 코드를 살펴보면 우리는 자연스럽게 이를 필드 멤버로 선언하여 사용합니다.

@Repository
public class AccountRepository {
    
    // 싱글톤 빈의 멤버 변수로 선언됨
    @PersistenceContext
    private EntityManager em; 

    public void save(Account account) {
        em.persist(account);
    }
}

여기서 주입되는 em 객체의 정체를 파악하는 것이 스레드 안전성 이해의 핵심입니다.

2. Proxy 패턴을 통한 컨텍스트 격리 전략

Spring 컨테이너가 주입하는 EntityManager는 실제 구현체(Hibernate의 경우 SessionImpl)가 아닙니다. 디버거를 통해 확인해보면 com.sun.proxy.$Proxy 형태의 JDK Dynamic Proxy 객체임을 알 수 있습니다. 이를 Shared EntityManager Proxy라고 부릅니다.

이 프록시 객체는 실제 EntityManager의 기능을 수행하지 않고, 현재 요청을 처리하는 스레드에 할당된 '진짜' EntityManager를 찾아 작업을 위임(Delegation)하는 라우터 역할을 수행합니다. 구체적인 동작 흐름은 다음과 같습니다.

  1. 트랜잭션 시작: 요청이 들어와 @Transactional 메서드가 실행되면, TransactionManagerEntityManagerFactory를 통해 새로운 EntityManager를 생성합니다.
  2. 리소스 바인딩: 생성된 EntityManagerTransactionSynchronizationManager에 의해 현재 스레드의 ThreadLocal 저장소에 바인딩됩니다.
  3. 프록시 호출: Repository에서 em.persist()를 호출하면, 프록시 객체는 ThreadLocal을 조회하여 현재 스레드에 매핑된 실제 EntityManager를 가져옵니다.
  4. 위임 실행: 가져온 실제 객체의 persist() 메서드를 실행합니다.
  5. 리소스 해제: 트랜잭션이 종료되면 ThreadLocal에서 언바인딩되고, EntityManager는 종료(close)됩니다.
Architecture Note:
이러한 구조를 통해 싱글톤 빈 내부에 있는 em 멤버 변수는 상태를 가지지 않는 무상태(Stateless) 프록시가 됩니다. 결과적으로 멀티 스레드 환경에서도 각 스레드는 자신만의 격리된 영속성 컨텍스트를 바라보게 되어 스레드 안전(Thread-safe)이 보장됩니다.

3. @PersistenceContext vs @Autowired

Spring Boot 최신 버전에서는 @Autowired를 사용하여 EntityManager를 주입받아도 동일하게 프록시가 주입되어 정상 동작합니다. 하지만 엔지니어링 관점에서는 @PersistenceContext 사용을 권장하며, 그 이유는 명확합니다.

구분 @PersistenceContext @Autowired
출처 Jakarta Persistence 표준 (JPA) Spring Framework 전용
목적 영속성 컨텍스트 주입 전용 일반적인 의존성 주입(DI)
확장 기능 unitName, type 속성 지원 Qualifier 필요
권장 여부 권장 (Recommended) 가능하나 비권장

특히 @PersistenceContext는 Spring 컨테이너가 아닌 표준 JPA 컨테이너 환경으로 이식성을 높여주며, PersistenceAnnotationBeanPostProcessor를 통해 Proxy 주입 프로세스가 명시적으로 처리됨을 코드 레벨에서 드러냅니다.

4. Extended Persistence Context와 트레이드오프

일반적으로 영속성 컨텍스트는 트랜잭션 범위(Transaction Scope)와 생명주기를 같이 합니다. 하지만 @PersistenceContext(type = PersistenceContextType.EXTENDED)를 설정하면, 트랜잭션이 종료되어도 영속성 컨텍스트가 닫히지 않고 유지됩니다.

@Service
public class ProductWizardService {
    
    // 영속성 컨텍스트가 트랜잭션 범위를 넘어 유지됨
    @PersistenceContext(type = PersistenceContextType.EXTENDED)
    private EntityManager em;
    
    // ...
}

이 기능은 과거 Stateful한 세션 빈(Stateful Session Bean)을 사용할 때 사용자 요청 간의 엔티티 상태 유지를 위해 고안되었습니다. 하지만 Stateless 아키텍처가 표준이 된 현대의 REST API 환경에서는 다음의 치명적인 단점으로 인해 사용이 극도로 제한됩니다.

  • 메모리 누수(Memory Leak): 명시적으로 컨텍스트를 초기화하지 않으면 1차 캐시에 엔티티가 계속 쌓여 OOM(Out Of Memory)을 유발할 수 있습니다.
  • 커넥션 점유: 트랜잭션이 끝나도 DB 커넥션을 반환하지 않거나, 재사용 시점에 커넥션 풀 고갈 문제를 야기할 수 있습니다.
  • 동시성 복잡도: 상태가 유지되는 빈은 더 이상 스레드 안전하지 않을 수 있어 별도의 동기화 처리가 필요해집니다.
Anti-Pattern Alert:
단순히 LazyInitializationException을 피하기 위해 EXTENDED 타입을 사용하는 것은 잘못된 설계입니다. Fetch Join, Entity Graph, 혹은 DTO 프로젝션을 통해 트랜잭션 범위 내에서 데이터를 완전히 로드하는 것이 올바른 해결책입니다.

5. 다중 데이터 소스 환경에서의 unitName 활용

CQRS 패턴 적용을 위해 읽기(Read) DB와 쓰기(Write) DB를 분리하거나, 레거시 시스템과의 연동을 위해 여러 DataSource를 사용하는 경우 unitName 속성은 필수적입니다.

@Configuration
public class JpaConfig {
    // LocalContainerEntityManagerFactoryBean 설정에서 
    // persistenceUnitName을 "orderUnit", "billingUnit" 등으로 지정했다고 가정
}

@Repository
public class OrderRepository {
    @PersistenceContext(unitName = "orderUnit")
    private EntityManager orderEm;
}

@Repository
public class BillingRepository {
    @PersistenceContext(unitName = "billingUnit")
    private EntityManager billingEm;
}

@Autowired@Qualifier 조합으로도 구현 가능하지만, @PersistenceContextunitName 속성을 사용하는 것이 JPA 표준에 부합하며 설정 의도를 더 명확하게 전달합니다.

결론

Spring Data JPA가 제공하는 편리함 이면에는 프록시와 스레드 로컬을 이용한 정교한 추상화 계층이 존재합니다. EntityManager의 스레드 안전성 메커니즘을 이해하는 것은 단순한 이론적 지식을 넘어, 복잡한 트랜잭션 문제나 동시성 이슈를 디버깅할 때 결정적인 통찰력을 제공합니다.

현업 개발자라면 프레임워크의 '마법'에 의존하기보다, 그 마법이 어떻게 구현되었는지 파악하고 트레이드오프를 고려하여 코드를 작성해야 합니다. @PersistenceContext를 명시적으로 사용하고, 트랜잭션 범위를 적절히 관리함으로써 견고하고 확장 가능한 데이터 액세스 계층을 구축하시기 바랍니다.

Post a Comment