일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- factory
- 일급 컬렉션
- Dependency Injection
- java
- nestjs
- 일급 객체
- spring security
- middleware
- synchronized
- lombok
- builder
- Volatile
- OAuth 2.0
- Google OAuth
- Spring
- Today
- Total
HJW's IT Blog
JPA 다건 조회 시 프록시 객체가 포함되는 원인 분석 - feat. Persistence Context 출력하기 본문
1. 엔티티 리스트를 조회했는데 프록시가 반환되었다
JPA 를 다루며 쿼리 최적화를 위해 디버거와 로그를 통해 분석중 이었다. 그때, `Channel` 리스트를 조회해야 하는 쿼리의 반환값이 이해하기 힘들었다.
4개는 실제 객체, 2개는 프록시 객채였던 것이다. `Lazy Loading` 으로 불러온 객체들도 아닌, SimpleJpaRepository
의 findByIdIn
을 사용해 불러온 객체들 중 프록시가 포함되어 있다는 것이 이해가 되질 않았다.
아래는 이 현상을 분석하기 위해 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.
Hibernate
는 Persistence 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;
}
Channel
과 ReadStatus
는 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 를 사용하지 않았다)
이후, `Channel` 리스트를 불러와 보니, 결과는 다음과 같았다.
- 눈치 빠른 분들은 이미 눈치 챘겠지만, statuses 의 0번째 객체의 channel 프록시 주소와 channels 리스트의 5번 객체의 프록시 주소가 동일한것을 볼 수 있다.
4. 세션 스코프의 동일성
하나의 Session Scop
내부에서, 같은 식별자를 가진 엔티티는 단 하나만 존재한다. 이는 이전에 언급했든 Persistence Context
가 엔티티를 식별자로 관리하기 때문이다.
이전의 ReadStatus
목록을 조회할때, 위의 Channel
리스트 중 몇개는 Lazy Loding 에 의해 프록시로 불러와졌다. 그렇다, 여기가 핵심이다.
Hibernate
의 1차 캐시는 같은 엔티티에 대해 비교 연산을 하면, 항상 두 객체가 같음을 보장해야 한다. 그렇다면 생각해보자, 처음 불러온 Channel$Proxy
와, 두번째 실제 DB 조회로 불러온 Channel
은 Hibernate
입장에서 어떻게 처리해야 할까? 이 두 객체의 식별자는 동일하다.
@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 조회 직후를 찍어보겠다.
당연한 이야기지만, Channel
과 User
는 프록시로 존재하며 초기화 되지 않은 상태이다.
이제 Channel
이후 1차 캐시 조회 결과를 보여주겠다.
이전의 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.정리
Hibernate
에서 연관된 엔티티가Lazy Loading
일때, 처음 로드시 프록시 객체만 생성된다.- 이후, 동일한
Session
내에서 Proxy 에 해당하는 엔티티를 실제 DB 에서 조회한다. - 1차 캐시에서 프록시가 있는지 확인한다.
- 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, 초기화 여부)
'SPRING' 카테고리의 다른 글
[Spring Security] OAuth2.0 + JWT 로그인 구조에 Form Login 통합하기 (0) | 2025.03.26 |
---|---|
Spring Security 기반 OAuth 2.0 & JWT 구현하기 (0) | 2025.03.25 |
Batch Fetching + Pagination으로 N + 1 해결하기 (0) | 2025.03.04 |
JPA SQL - 가독성 좋게 보기 (0) | 2025.02.19 |
프론트 컨트롤러와 DispatcherServlet (0) | 2025.01.09 |