xToOne 관계 조회 API 성능 최적화
추가, 수정 API 보다도 조회 API가 성능에 가장 민감한 만큼 JPA를 사용할 때 조회 API의 성능을 최적화하는 방법을 단계별로 정리하려 합니다. 주문과 배송정보 그리고 회원을 조회하는 API를 만들
praaay.tistory.com
위 글에도 정리했지만, 조회 API에서 엔티티가 지연 로딩으로 설정되었다면 1+N 쿼리가 나가는 문제를 조심해야 합니다.
특히 아래 코드와 같이 엔티티를 DTO로 변환하는 과정에서 프록시 객체에 접근하는 경우라면 프록시 객체가 초기화되면서 1+N 쿼리가 나가는 문제가 발생합니다.
하지만 1+N 쿼리가 나갈 것이라 예상했던 상황에서 1+N 쿼리가 나가지 않았던 경험을 이야기해보려 합니다.
먼저 ChatRoom 엔티티 클래스를 살펴봅시다.
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoom extends BaseTimeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "chatroom_id")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "rootclient_id")
private RootClient rootClient;
public static ChatRoom createRoom(String name, RootClient rootClient) {
return ChatRoom.builder()
.name(name)
.rootClient(rootClient)
.build();
}
public void update(String name) {
this.name = name;
}
}
ChatRoom 엔티티는 RootClient와 다대일 단방향 연관관계로 설정되어 있습니다. 데이터베이스에 저장된 ChatRoom 리스트를 반환하는 조회 API를 개발하여 아래 RoomResponse 코드와 같이 조회한 ChatRoom 엔티티를 RoomResponse로 변환하여 API Response로 뿌릴 계획이었습니다.
// DTO class
@Getter
@AllArgsConstructor
public class RoomResponse {
private Long id;
private String name;
private Long rootClientId;
public RoomResponse(ChatRoom chatRoom) {
this.id = chatRoom.getId();
this.name = chatRoom.getName();
this.rootClientId = chatRoom.getRootClient().getId();
}
}
// Controller class
@GetMapping()
public ResponseEntity<Result<List<RoomResponse>>> getAllRooms() {
List<ChatRoom> chatRooms = roomService.getAllRooms();
List<RoomResponse> response = chatRooms.stream()
.map(RoomResponse::new)
.toList();
return ResponseEntity.ok().body(new Result<>(response));
}
// Service class
@Override
public List<ChatRoom> getAllRooms() { return roomRepository.findAll(); }
// Repository class
@Override
public List<ChatRoom> findAll() {
return entityManager.createQuery("select m from ChatRoom m", ChatRoom.class)
.getResultList();
}
Repository 계층의 메소드에서는 아래 코드와 같이 페치 조인을 사용하지 않기 때문에 Repository 클래스의 findAll 메소드가 호출되어 반환되는 List<ChatRoom> 요소들의 rootClient 필드는 지연 로딩 설정으로 인해 프록시 객체가 있을 것이라 예상할 수 있습니다.
그렇다면 실제로 API 호출 시 발생되는 쿼리를 살펴봅시다. 먼저 데이터베이스에 넣어둔 테스트 데이터는 아래와 같습니다.

Repository 클래스의 findAll 메소드가 호출되면 CHATROOM_ID 11개를 조회하는 SELECT 쿼리가 나가고, Controller 클래스의 getAllRooms 메소드에서 ChatRoom 엔티티를 RoomResponse로 변환하며 chatRoom.getRootClient().getId(); 를 호출합니다.
ChatRoom 엔티티의 rootClient는 프록시 객체가 있기 때문에 프록시 객체에 접근하는 순간 SELECT 쿼리가 나갈 것입니다.
따라서 ROOTCLIENT_ID를 조회하는 SELECT 쿼리는 4번 나가게 됩니다. ROOTCLIENT_ID가 1인 엔티티를 조회하면 해당 엔티티는 영속성 컨텍스트 1차 캐시에 저장되기 때문에 그다음 ROOTCLIENT_ID가 1인 엔티티를 조회하면 데이터베이스에 쿼리가 나가지 않고 영속성 컨텍스트에서 데이터를 얻습니다. 따라서 ROOTCLIENT_ID의 종류만큼 SELECT 쿼리가 나갈 것이라 예상했습니다.
하지만 실제로는 아래와 같이 1+N 쿼리 문제가 발생하지 않습니다. 2번의 쿼리 밖에 나가고 있지 않습니다.
2024-10-21T19:24:57.682+09:00 DEBUG 56121 --- [nio-8080-exec-1] org.hibernate.SQL :
select
cr1_0.chatroom_id,
cr1_0.create_time,
cr1_0.deleted_at,
cr1_0.modified_date,
cr1_0.name,
cr1_0.rootclient_id
from
chat_room cr1_0
Hibernate:
select
cr1_0.chatroom_id,
cr1_0.create_time,
cr1_0.deleted_at,
cr1_0.modified_date,
cr1_0.name,
cr1_0.rootclient_id
from
chat_room cr1_0
처음 쿼리는 Repository 클래스의 findAll 메소드에 의해 발생하고 두 번째 쿼리는 DTO 생성자에서 프록시 객체를 초기화하는 과정에서 발생합니다.
여기서 의문이 생길 것입니다. 왜? 데이터베이스의 11개 필드의 ChatRoom의 프록시 객체를 초기화하는데 SELECT 쿼리가 한 번만 나갈까요? 챗지피티의 답변은 아래와 같았습니다.
데이터베이스 내용을 보면 ROOTCLIENT_ID 값이 여러 개의 ChatRoom에 동일하게 할당되어 있으니, RootClient가 지연 로딩으로 조회될 때 N+1 문제가 발생할 수 있는 상황입니다.
하지만 쿼리가 2번밖에 나가지 않은 이유는 다음과 같을 수 있습니다:
1. 식별자 접근 (getId())로 인한 쿼리 최소화: RoomResponse 생성자에서 chatRoom.getRootClient().getId()만 호출하고 있습니다. getId()는 프록시 객체에서 이미 영속성 컨텍스트에 로드된 식별자만을 조회하기 때문에, 추가적인 SELECT 쿼리를 필요로 하지 않습니다.이는 지연 로딩된 프록시 객체에서 식별자에만 접근할 때 Hibernate가 해당 객체를 완전히 초기화하지 않고도 식별자를 반환하는 방식 때문입니다. 즉, RootClient의 다른 속성(예: name, email 등)에 접근하지 않는 한, 추가적인 쿼리가 발생하지 않는 것입니다.
2. 프록시 상태 유지: RootClient가 아직 완전히 초기화되지 않은 프록시 상태로 남아 있기 때문에, getId() 호출만으로는 RootClient가 실제로 조회되지 않습니다. 따라서 Hibernate는 프록시 초기화가 필요하지 않다고 판단하고, 그로 인해 추가적인 SELECT 쿼리가 발생하지 않습니다.
결론:
현재 chatRoom.getRootClient().getId()는 프록시 객체의 식별자에 접근하는 코드이므로, N+1 문제처럼 RootClient에 대한 추가적인 쿼리가 발생하지 않는 상황입니다. 만약 chatRoom.getRootClient().getName()과 같은 다른 필드에 접근하면, 그때는 RootClient가 지연 로딩으로 초기화되며 N+1 문제가 발생할 수 있습니다.
여기서 알 수 있는 사실은 프록시 객체에 getId()를 호출해 식별자를 리턴받으려할 때는 프록시 초기화가 발생하지 않는다는 것입니다.
찾아보니 엔티티를 프록시로 조회할 때, 식별자(PK) 값을 파라미터로 전달하게 된다고 합니다. 이때 PK 값을 프록시 객체가 보관하게 되면서 프록시 객체의 PK 값을 호출하는 것으로는 프록시 초기화가 이러나지 않는다고 합니다. 식별자는 DB조회 없이도 알 수 있기 때문입니다.
그럼 PK 값인 id가 아닌 name 필드를 조회하도록 DTO 코드를 고치면 1+N 문제가 발생할까요? 아래와 같이 id가 아닌 프록시 객체의 name 필드에 접근하도록 고치고 확인해 보았습니다.
@Getter
@AllArgsConstructor
public class RoomResponse {
private Long id;
private String name;
private String rootClientId;
public RoomResponse(ChatRoom chatRoom) {
this.id = chatRoom.getId();
this.name = chatRoom.getName();
this.rootClientId = chatRoom.getRootClient().getClientName();
}
}
PK인 id가 아니라 name으로 프록시 객체에 접근했더니 아래와 같이 1+N 문제가 발생했습니다.
2024-10-21T19:46:50.682+09:00 DEBUG 56222 --- [nio-8080-exec-2] org.hibernate.SQL :
select
cr1_0.chatroom_id,
cr1_0.create_time,
cr1_0.deleted_at,
cr1_0.modified_date,
cr1_0.name,
cr1_0.rootclient_id
from
chat_room cr1_0
Hibernate:
select
cr1_0.chatroom_id,
cr1_0.create_time,
cr1_0.deleted_at,
cr1_0.modified_date,
cr1_0.name,
cr1_0.rootclient_id
from
chat_room cr1_0
2024-10-21T19:46:50.687+09:00 DEBUG 56222 --- [nio-8080-exec-2] org.hibernate.SQL :
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
Hibernate:
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
2024-10-21T19:46:50.688+09:00 DEBUG 56222 --- [nio-8080-exec-2] org.hibernate.SQL :
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
Hibernate:
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
2024-10-21T19:46:50.689+09:00 DEBUG 56222 --- [nio-8080-exec-2] org.hibernate.SQL :
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
Hibernate:
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
2024-10-21T19:46:50.690+09:00 DEBUG 56222 --- [nio-8080-exec-2] org.hibernate.SQL :
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
Hibernate:
select
rc1_0.rootclient_id,
rc1_0.client_id,
rc1_0.client_name,
rc1_0.client_password,
rc1_0.create_time,
rc1_0.deleted_at,
rc1_0.modified_date
from
root_client rc1_0
where
rc1_0.rootclient_id=?
원하던(?) 1+N 문제가 발생한 것을 확인할 수 있습니다. 만약 PK 값을 조회할 때도 지연 로딩으로 인한 1+N 문제를 발생시키고 싶다면
@Access(AccessType.FIELD)로 설정해 봅시다. AccessType.FIELD로 설정하면 JPA가 getId()라는 메소드가 PK인 id만 조회하는 메소드인지, 다른 필드까지 조회하는 메소드인지 알 수 없게 됩니다. 따라서 id만을 조회하더라도 프록시 객체를 초기화하게 됩니다.
단순히 동작하는 코드보다 '예상대로' 동작하는 코드에 의미를 두고 있다 보니 1+N 문제가 발생할 것이라 예상되는 지점에서 문제가 발생하지 않으니 이 같은 글을 쓰게 되었습니다.
덕분에 PK 값만을 조회할 경우 프록시 객체는 초기화되지 않는다는 사실은 잊지 않을 것 같습니다.
'실시간 채팅 솔루션 개발 > 문제 해결 사례' 카테고리의 다른 글
Entity 삭제 시 발생하는 Referential integrity constraint violation 에러 (0) | 2024.10.25 |
---|---|
그럼 모든 JPA 에러는 롤백 처리해야 할까? (트랜잭션 롤백 여부) (0) | 2024.10.23 |
JPA가 던지는 체크 에러를 언체크 에러로 변환하면, 트랜잭션이 롤백 될까? (0) | 2024.10.23 |
유연함을 위해 Custom Result 타입을 사용하자 (0) | 2024.09.28 |