HJW's IT Blog

JPA 다건 조회 시 프록시 객체가 포함되는 원인 분석 - feat. Persistence Context 출력하기 본문

SPRING

JPA 다건 조회 시 프록시 객체가 포함되는 원인 분석 - feat. Persistence Context 출력하기

kiki1875 2025. 3. 12. 09:30

1. 엔티티 리스트를 조회했는데 프록시가 반환되었다

JPA 를 다루며 쿼리 최적화를 위해 디버거와 로그를 통해 분석중 이었다. 그때, `Channel` 리스트를 조회해야 하는 쿼리의 반환값이 이해하기 힘들었다.

 

Channel 조회 결과

 

4개는 실제 객체, 2개는 프록시 객채였던 것이다. `Lazy Loading` 으로 불러온 객체들도 아닌, SimpleJpaRepositoryfindByIdIn 을 사용해 불러온 객체들 중 프록시가 포함되어 있다는 것이 이해가 되질 않았다.

 

아래는 이 현상을 분석하기 위해 Hibernate ORM 공식 문서와, 1차 캐시를 뜯어보며 분석해본 결과이다.

 

2. 세션과 영속성 컨텍스트

 

[공식문서]
2.9.2. Natural Id API
As stated before, Hibernate provides an API for loading entities by their associated natural id.


HibernatePersistence Context 를 하나의 Session 동안 유지하며, Session 내의 엔티티의 상태를 관리한다. 이 때, Hibernate 는 엔티티의 식별자를 기준으로 관리하게 되는데, 우리가 잘 알고 있는 1차 캐시, dirty check, write behind 등이 이 세션과 Persistence Context 를 통해 이루어 진다.

 

하나의 Session 내부에서, 특정 엔티티를 ID 로 조회하게 되면, Hibernate 는 먼저 Persistence Context 를 들여다 보고, 해당 ID 를 가진 엔티티가 있는지 확인한다. 만약 존재 한다면 해당 엔티티를 반환하고, 없다면 새로운 엔티티를 DB에서 로딩하여 반환한다.

3. 코드 설명

여기까지 봐선 아직 명확하지 않다. 코드를 천천히 살펴보며 분석해보자

 

/* Channel 엔티티 */
public class Channel extends BaseUpdatableEntity {  

  @Enumerated(EnumType.STRING)  
  @Column(nullable = false)  
  private ChannelType type;  
  private String name;  
  private String description;  

  @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, mappedBy = "channel")  
  private List<ReadStatus> statuses = new ArrayList<>();
}

/* ReadStatus 엔티티 */
@Entity  
@Getter  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@Table(  
    name = "read_statuses",  
    uniqueConstraints = @UniqueConstraint(columnNames = {"channel_id", "user_id"})  
)  
public class ReadStatus extends BaseUpdatableEntity {  

  @ManyToOne(fetch = FetchType.LAZY)  
  @JoinColumn(name = "channel_id")  
  private Channel channel;  

  @ManyToOne(fetch = FetchType.LAZY)  
  @JoinColumn(name = "user_id")  
  private User user;  

  @Column(name = "last_read_at")  
  private Instant lastReadAt;
}

 

 

ChannelReadStatus 는 1 : N 양방향 관계이다.

이 상황이 일어난 코드는 다음과 같다.

@Override  
public List<Channel> findAllChannelsForUser(String userId) {  

  List<ReadStatus> statuses = readStatusService.findAllByUserId(userId);  
  List<UUID> extractedChannelIds = parseStatusToChannelUuid(statuses);  
  List<Channel> channels = channelService.findAllChannelsInOrPublic(extractedChannelIds);
    //... 추가 로직
}

 

 

세부 로직까지 설명하기엔 복잡하니 간단하게 설명하겠다.

List<ReadStatus> statuses :

  • userId 를 기준으로 ReadStatus List 를 조회한다
  • 조회된 ReadStatus 의 ChannelId 를 기준으로 추가 ReadStatus를 조회한다.
  • 두 결과를 합쳐 반환한다 (중복 x)

List<UUID> extractedChannelIds :

  • statuses 에서 ChannelId 만 추출한 리스트

List<Channel> channels = channelService.findAllChannelsInOrPublic(extractedChannelIds) :

  • 추출된 ChannelId, 혹은 ChannelType 이 Public 인 Channel List 를 반환한다.

위의 ReadStatus 엔티티에서 볼 수 있듯이 양방향의 연관관계 모두 FetchType.LAZY 가 적용되어 있다. 즉, statuses 를 불러온 시점에, statuses 의 컬렉션 멤버의 Channel 은 프록시 객체인 것이다. (Fetch Join 혹은 Entity Graph 를 사용하지 않았다)

 

 

status 반환값

 

이후, `Channel` 리스트를 불러와 보니, 결과는 다음과 같았다.

 

 

Channel 조회 결과

  • 눈치 빠른 분들은 이미 눈치 챘겠지만, statuses 의 0번째 객체의 channel 프록시 주소와 channels 리스트의 5번 객체의 프록시 주소가 동일한것을 볼 수 있다.

4. 세션 스코프의 동일성

하나의 Session Scop 내부에서, 같은 식별자를 가진 엔티티는 단 하나만 존재한다. 이는 이전에 언급했든 Persistence Context 가 엔티티를 식별자로 관리하기 때문이다.

 

이전의 ReadStatus 목록을 조회할때, 위의 Channel 리스트 중 몇개는 Lazy Loding 에 의해 프록시로 불러와졌다. 그렇다, 여기가 핵심이다.

 

Hibernate 의 1차 캐시는 같은 엔티티에 대해 비교 연산을 하면, 항상 두 객체가 같음을 보장해야 한다. 그렇다면 생각해보자, 처음 불러온 Channel$Proxy 와, 두번째 실제 DB 조회로 불러온 ChannelHibernate 입장에서 어떻게 처리해야 할까? 이 두 객체의 식별자는 동일하다.

@Slf4j  
@Component  
public class HibernateUtil {  

  private static EntityManager em;  

  @PersistenceContext  
  public void setEntityManager(EntityManager em) {  
    HibernateUtil.em = em;  
  }  

  public static void printPersistenceContext() {  
    SessionImplementor sessionImplementor = em.unwrap(SessionImplementor.class);  
    org.hibernate.engine.spi.PersistenceContext persistenceContext = sessionImplementor.getPersistenceContext();  
    Collection<?> managedEntities = persistenceContext.getEntitiesByKey().values();  

    if (managedEntities.isEmpty()) return;  
    for (Object entity : managedEntities) {  
      log.info("[ENTITY IN PC] : {}", entity);  
    }  
  }  
}

@Override  
public List<Channel> findAllChannelsForUser(String userId) {  

  List<ReadStatus> statuses = readStatusService.findAllByUserId(userId);  

  HibernateUtil.printPersistenceContext();
}

 

위 코드를 통해 1차 캐시에 등록되어 있는 객체의 목록을 찍어보자. 우선 status 조회 직후를 찍어보겠다.

status 조회 직후

 

당연한 이야기지만, ChannelUser 는 프록시로 존재하며 초기화 되지 않은 상태이다.

 

이제 Channel 이후 1차 캐시 조회 결과를 보여주겠다.

 

channels 조회 이후

이전의 ReadStatus 와 더불어 6개의 Channel 객체가 초기화 된것을 볼 수 있다. 즉, Debug 콘솔에 Proxy 로 표기된 것과 달리, 1차 캐시엔 실제 6개의 Channel 객체가 들어있는 것이다.

 

여기까지의 분석을 종합해 보면, findByIdIn 으로 조회한 Channel 리스트에 프록시 객체가 섞여있던 이유는 JPA 의 1차 캐시 동일성 보장 메커니즘 때문 이라는 결론이 도출된다.

 

이때 주의할 점은, 이 객체는 Proxy 이지만, 초기화 되어 있으며, 실제 객체가 프록시로 감싸져 있는 상태인 것이다.

확실하게 하기 위해 로그를 추가로 찍어보겠다.

@Override  
public List<Channel> findAllChannelsForUser(String userId) {  

  List<ReadStatus> statuses = readStatusService.findAllByUserId(userId);  

  HibernateUtil.printPersistenceContext();  

  List<UUID> extractedChannelIds = parseStatusToChannelUuid(statuses);  
  List<Channel> channels = channelService.findAllChannelsInOrPublic(extractedChannelIds);  

  HibernateUtil.printPersistenceContext();  

  if (channels.get(4) instanceof HibernateProxy) {  
    System.out.println("프록시 객체");  
  }else{  
    System.out.println("일반 객체");  
  }  
  if(!Hibernate.isInitialized(channels.get(4))){  
    System.out.println("초기화 안됨");  
  }else{  
    System.out.println("초기화 됨");  
  }  
  System.out.println(channels.get(4).getType());

 

위와 같이 프록시 객체의 여부와 초기화 여부를 출력해 보면,

 

와 같은 결과가 나온다.

 

예상한 바와 같이, `ReadStatus` 의 Channel은 Proxy 상태이며, 이후 `Channel` 조회 후, 조회한 Channel 엔티티는 프록시면서, 초기화되었고, 필드값에 접근해도 추가 쿼리가 나가지 않는 상태인 것이다.

 

즉, 이 동일성 보장 메커니즘에 의해 이후, `someReadStatus.getChannel == channel` 의 결과가 `true` 가 나올 수 있는 것이다.

 

5.정리

  1. Hibernate 에서 연관된 엔티티가 Lazy Loading 일때, 처음 로드시 프록시 객체만 생성된다.
  2. 이후, 동일한 Session 내에서 Proxy 에 해당하는 엔티티를 실제 DB 에서 조회한다.
  3. 1차 캐시에서 프록시가 있는지 확인한다.
  4. Proxy 를 찾으면 엔티티 반환값을 프록시로 감싸서 반환한다. -> 동일성을 보장하기 위해서

반환 객체가 프록시로 표기되는 이유는, 조회하기 전, 이미 1차 캐시에 프록시 형태로 존재하던 엔티티이기 때문이다.

 

6. 1차 캐시 출력하기

Hibernate 의 1차 캐시를 출력해보아야 결과를 알 수 있었는데, 레퍼런스를 찾기 정말 힘들었다..

그래서 직접 Hibernate를 뜯어가며 1차 캐시에 접근할 수 있었다.

  public static void printPersistenceContext() {  
    SessionImplementor sessionImplementor = em.unwrap(SessionImplementor.class);  
    org.hibernate.engine.spi.PersistenceContext persistenceContext = sessionImplementor.getPersistenceContext();  
    Collection<?> managedEntities = persistenceContext.getEntitiesByKey().values();  
    Map<EntityKey, Object> m = persistenceContext.getEntitiesByKey();  

    StatefulPersistenceContext spc = (StatefulPersistenceContext) persistenceContext;  
    Map<EntityKey, EntityHolder> m2 = spc.getEntityHoldersByKey();  

    for( EntityKey v : m2.keySet()){  
      System.out.println("[Persistence Context][Entity] : " + v + " [Initialized] : " + m2.get(v).isInitialized());  
    }  
//  
//    if (managedEntities.isEmpty()) return;  
//    for (Object entity : managedEntities) {  
//      if (entity instanceof HibernateProxy) {  
//        HibernateProxy proxy = (HibernateProxy) entity;  
//        Object id = proxy.getHibernateLazyInitializer().getIdentifier();  
//        String entityName = proxy.getHibernateLazyInitializer().getEntityName();  
//        log.info("[ENTITY IN PC] : Proxy for [{}] with ID: {}", entityName, id);  
//      } else {  
//        log.info("[ENTITY IN PC] : Actual entity: {}", entity.getClass().getName());  
//      }  
//    }  
  }

 

다음과 같은 구조 같다

PersistenceContext
├── entitiesByKey (실제 엔티티 관리)
│   └── EntityKey -> 실제 엔티티 (Channel)
└── entityHoldersByKey (프록시와 초기화 상태 관리)
    └── EntityKey -> EntityHolder(Proxy, 초기화 여부)