DB/JPA

JPA 즉시 로딩, 지연 로딩

코딩딩코 2023. 1. 20. 22:45

즉시 로딩, 지연 로딩

JPA를 사용하면 즉시 로딩과 지연 로딩을 선택해서 사용할 수 있습니다.

fetch = FetchType.LAZY = 지연 로딩

fetch = FetchType.EAGER

 

둘의 차이점은 즉시 로딩은 연관관계로 매핑되어 있는 상황이라면 join을 통해서 한 번의 SELECT를 통해서

데이터를 가져옵니다.

 

반대로 지연 로딩은 하나의 객체만을 SELECT를 통해서 데이터를 가져오고 연관관계로 매핑되어 있는 객체는 Proxy 객체로 가져오고 해당 객체에 대해 getter 메서드등을 통해서 값을 가져올 때 SELECT를 날려서 데이터를 가져옵니다.

 

 

지연 로딩으로 데이터를 가져올 시 주의할 점

회원의 주요 정보가 담겨있는 User 객체와 부가 정보가 담겨있는 UserDetail은 OneToOne으로 매핑되어 있습니다.

이제 저에게 필요한 데이터는 User 객체에 있는 정보와 UserDetail 객체에 있는 userProfileImage 필드입니다.

 

@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUserId(username).orElse(null);

        if(user == null) {
            throw new UsernameNotFoundException("아이디가 올바르지않습니다.");
        }

        return new PrincipalDetails(user);
    }
}

로그인하는 로직입니다. username을 통해서 DB에서 값을 가져오고 null이 아니라면 SecurityContextHolder에 Authenticattion 객체를 저장해 줍니다.

지연 로딩으로 데이터를 불러왔다면 UserDetail 객체는 Proxy 객체로 SELECT가 되지 않은 상태입니다.

위와 같은 SELECT가 들어간 것입니다.

아래의 빨간 줄은 지금 신경 쓰지 마시고 쿼리문에만 집중하겠습니다.

 

@GetMapping("/principal")
public ResponseEntity<?> loadPrincipal(@AuthenticationPrincipal PrincipalDetails principalDetails) {
    return ResponseEntity.ok(new CustomResponseDto<>(1, "Principal Load Successful", principalDetails.getUser().toUserDto()));
}

Authentication 객체를 가지고 오는 로직입니다.

principalDetails에 저장되어 있는 User 객체는 Entity이기 때문에 Dto로 변환하기 위해서 toUserDto() 메서드를 호출합니다.

이 과정에서 문제가 되는데

public ReadUserResponseDto toUserDto() {
    return ReadUserResponseDto.builder()
            .userCode(userCode)
            .userId(userId)
            .userName(userName)
            .userPassword(userPassword)
            .userNickname(userNickname)
            .userEmail(userEmail)
            .userPhoneNumber(userPhoneNumber)
            .userRole(userRole)
            .userDetail(userDetail.toUserDetailDto())	// Proxy 객체인 userDetail을 참조
            .build();
}

Authentication 객체에 저장되어 있는 User 객체의 userDetail 속성은 현재 Proxy 객체로 참조한다면

지연 로딩으로 인해 SELECT 쿼리가 DB에 전달되어야 합니다.

하지만 현재 Authentication 객체를 가지오 오는 RestController 단에는 트랜잭션이 걸려있지 않고 영속성 컨텍스트 또한 종료되었습니다.

 

지연 로딩은 해당 객체가 영속상태이어야 하며 트랜잭션 안에서만 적용되기 때문에 위에서 봤던 빨간 줄 에러 코드가 발생합니다.

 

Authentication 객체를 가지고 오는 로직에 @Transactional 추가해도 안 되는 이유

Authentication 객체를 가지고 오는 RestController 단에 @Transactional 어노테이션을 추가합니다.

@Transactional
@GetMapping("/principal")
public ResponseEntity<?> loadPrincipal(@AuthenticationPrincipal PrincipalDetails principalDetails) {
    return ResponseEntity.ok(new CustomResponseDto<>(1, "Principal Load Successful", principalDetails.getUser().toUserDto()));
}

이렇게 된다면 트랜잭션 안에서 Proxy 객체를 조회하기 때문에 SELECT 쿼리가 전달이 될 것 같습니다.

하지만 결과는 동일하게 no Session으로 작동하지 않습니다.

 

이유는?

트랜잭션은 보통 서비스 계층에서 시작합니다.

그리고 서비스 계층이 끝나는 시점에 트랜잭션이 종료됨과 동시에 영속성 컨텍스트도 함께 종료됩니다.

따라서, 조회한 엔티티가 Service/Repository 계층에서는 영속성 컨텍스트에서 관리되며 영속 상태를 유지하지만,
컨트롤러나 뷰 같은 프레젠테이션 계층에서는 준영속 상태가 되어버립니다.

 

준영속 상태는 엔티티가 영속성 컨텍스트에서 분리된 것을 이야기합니다.

즉, 준영속 상태의 엔티티는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없습니다.

 

User 엔티티는 Service에서 조회한 값이므로, 이때에는 영속성 컨텍스트가 올바르게 수행되었다.

그러나 서비스의 loadUserByUsername() 메서드가 종료되면서 영속성 컨텍스트가 닫혔고, 반환된 User 엔티티가 준영속 상태가 됩니다.

 

이제 RestController 단에서 Proxy 객체인 userDetail에 참조를 해도 준영속 상태이므로 에러가 발생하는 것입니다.

 

 

해결 방법은?

 

1. FetchType을 LAZY가 아닌 EAGER로 즉시 로딩을 사용한다.

2. JPQL을 사용한다.

3. Service 계층에서 DTO로 변환한다.

4. Service 계층에서 일부러 지연로딩을 실행한다.

 

 

FetchType.EAGER 방법

FetchType을 EAGER로 즉시 로딩을 한다면 아래와 같이 한 번에 join 된 값을 가져오고 그 User객체를

SecurityContextHolder에 저장을 할 수 있습니다.

왼쪽 이미지 - JpaRepository에서의 조회한 쿼리 / 오른쪽 이미지 - EntityManager.find() 메서드로 조회한 쿼리

EntityManager.find()로 객체를 조회하면 PK를 정해 놓고 DB에서 가져오기 때문에 JPA 내부에서 최적화를 할 수 있다고 합니다. (한방 쿼리)

 

 

하지만 즉시 로딩은 가급적이면 사용하지 말라는 게시글을 보았기 때문에 해당 방법은 사용하지 않고

다른 방법을 사용하겠습니다.

즉시 로딩으로 데이터를 가지고 오면 불필요한 객체까지 조인을 하게 됩니다.

User 객체에 @ManyToOne 매핑이 3개가 존재하는데 FetchType.EAGER로 설정되어있다면

단순히 User 객체만 가지고 오면 되는데 다른 3개의 연관관계가 이어져있는 객체까지 가져오기 위해서

join이 일어날 것입니다.

 

 

JPQL 사용

JPQL을 사용해서 처음부터 join 된 데이터를 가지고 와서 Authentication 객체에 담고 SecurityContextHolder에 저장하는 방법입니다.

 

@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String jpql = "select u from User u join fetch u.userDetail where u.userId = :userId";

        User user = entityManager.createQuery(jpql, User.class).setParameter("userId", username).getResultList().get(0);

        if(user == null) {
            throw new UsernameNotFoundException("아이디가 올바르지않습니다.");
        }

        return new PrincipalDetails(user);
    }
}

JPQL을 사용한다면 아래와 같은 SELECT 쿼리가 DB에 전달됩니다.

join 된 상태로 데이터를 들고 오고 그 데이터를 SecurityContextHolder에 저장한다면

지연 로딩이 아니기 때문에 필요할 때마다 userDetail에 접근할 수 있겠죠

 

Service 계층에서 DTO로 변환

@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUserId(username).orElse(null);

        if(user == null) {
            throw new UsernameNotFoundException("아이디가 올바르지않습니다.");
        }

        return new PrincipalDetails(user.toUserDto()); // 영속성 컨텍스트가 종료되기 전에 지연 로딩 발생
    }
}

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
@Table(name = "user_mst")
public class User extends BaseTimeEntity {

	...

    public ReadUserResponseDto toUserDto() {
        return ReadUserResponseDto.builder()
                .userCode(userCode)
                .userId(userId)
                .userName(userName)
                .userPassword(userPassword)
                .userNickname(userNickname)
                .userEmail(userEmail)
                .userPhoneNumber(userPhoneNumber)
                .userRole(userRole)
                .userDetail(userDetail.toUserDetailDto()) // Proxy 객체 참조로 인해 지연 로딩 발생
                .build();
    }
}

영속성 컨텍스트가 종료되기 전인 Service 계층에서 Entity를 DTO로 변환한다면 그 자리에서

Proxy 객체에 접근하여 DB에 추가로 SELECT 쿼리를 전달하게 될 것입니다.

그렇다면 Controller와 같은 프레젠테이션 계층으로 원하는 데이터로 변환된 DTO를 받을 것이고

그 데이터를 사용하면 됩니다.

 

 

Service 계층에서 지연 로딩 발생시키기

@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUserId(username).orElse(null);

        user.getUserDetail().getUserProfileImage(); // 지연 로딩 발생

        if(user == null) {
            throw new UsernameNotFoundException("아이디가 올바르지않습니다.");
        }

        return new PrincipalDetails(user.toUserDto());
    }
}

마찬가지로 영속성 컨텍스트가 종료되기 전인 Service 계층에서 Proxy 객체인 userDetail을 한번 참조하는 로직을

추가한다면 그 자리에서 지연 로딩이 발생하여 데이터를 넣을 수 있습니다.

하지만 이 방법은 어떻게 보면 불필요한 로직이 추가가 되므로 선호하지 않습니다.

 

 

 

참조

https://kafcamus.tistory.com/31

https://ict-nroo.tistory.com/132

 

감사합니다.