[Spring Data JPA] LazyInitializationException 문제 해결하기

ttaho·2024년 3월 25일
2

프로젝트를 진행하다가 JPA의 LazyInitializationException 문제가 발생했다.

우선, 프로젝트 환경을 말해보겠다.

우리 프로젝트는 사용자 인증, 인가로 OAuth2.0 + Spring Security + JWT를 사용하고 있다.

그러므로 Security Filter Chain의 한 필터에서 토큰을 검증하고, 유저 정보를 SecurityContextHolder에 저장하게 된다.

그리고, Controller 이후 부터는 Spring Security의 SecurityContextHolder를 통해 현재 인증된 사용자의 세부 정보에 접근하게 된다. 나는 service에 getUser라는 메소드를 만들어서 user를 꺼내오게 했다.

private User getUser(){
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        User user = principalDetails.getUser();
        return user;
    }

UserDetails를 구현한 PrincipalDetails에 User 인스턴스가 Composition 방식으로 들어있어서 get 메소드로 가져올 수 있었다.

@Getter
public class PrincipalDetails implements UserDetails {

    private User user; // 콤포지션

    public PrincipalDetails(User user){
        this.user = user;
    }

    //생략
}

그래서 무슨 문제가 발생했는데??

문제발생

문제가 발생한 상황은 아래와 같다.

  1. 회원마다 자기가 살고싶은 동네(Dong)을 찜 할 수 있다.
  2. 회원 엔티티에는 양방향 연관관계로 찜List가 ArrayList 형태로 들어있다.
  3. user.getZzimList()로 접근하려는데 문제가 발생했다.
public List<ZzimDto> getZzimListByUser() {
        User user = getUser();

        List<ZzimDto> zzimList = user.getZzimList().stream()
                .map(zzim -> new ZzimDto(zzim.getDong().getDongId(), zzim.getDong().getDongName()))
                .collect(Collectors.toList());

        return zzimList;
    }

위의 메소드를 호출할때 아래의 LazyInitialLizationException 에러가 발생했다.

로그를 보니 hibernate에서 발생한것이므로 JPA와 관련된 문제라고 판단했다.

failed to lazily initialize a collection of role: com.example.back.user.entity.User.zzimList, could not initialize proxy - no Session

이 문장? 을 통해 zzimList를 프록시 객체로 찾을 수 없는 에러인것 같았다. JPA의 LAZY 로딩을 사용하게되면 프록시 객체를 통해 먼저 가져오고, 진짜로 필요할때 호출을 하는것을 이전에 공부했었다.

관련된 이전 포스팅: [JPA]프록시와 로딩 전략

그래서 당연히 이 문제를 찾아보았고, 비슷한 문제를 해결한 블로그를 발견하였다.

(출처 : Spring Boot, JPA, LazyInitializationException 예외 설명 및 해결책 정리)

위의 블로그를 통해 해결할 수 있다고 생각했....다...


나와있는 해결책은 JPA의 OSIV(Open Session In View)를 활성화 시켜주어서 요청이 들어오고 응답할때 까지 Session을 열어주는 것이라고 한다.
즉, 영속성 컨텍스트를 뷰까지 열어두어 session이 유지되게 하는 방법이었다.

OSIV로 해결해보기

바로 적용해보았다.
application.yml의 JPA 부분에 추가해주었다.

그러고 똑같은 요청을 보냈는데....?

하지만 똑같은 에러 로그를 볼 수 있었다.

또 나만 안돼... ㅠㅠ

추가적으로 해본 방안은 이렇다
1. 해당 메소드에 @Transactional 달기 -> 실패
2. Controller의 매핑되는 메소드에 @Transactional 달기 -> 실패 (이 방법은 애초에 하면 안되는 방법이다)

그 이후로, 2시간 쯤 뒤 해결책을 찾을 수 있었다.

영속성 컨텍스트와 Hibernate session 종료시점 이해하기

첫번째 방법인 OSIV로 해당 문제를 해결하지 못하였지만, 다시 한번 Session에 집중하게 되었다.

기존에 알고 있었던 지식은 이렇다.

JPA는 Lazy 로딩을 위해 프록시 객체를 "어딘가"에 저장한다.

여기서 "어딘가"는 영속성 컨텍스트 라고 생각했다. 그럼 로그에 찍혔던 Session 이라는것은 무슨 Session인지 궁금해서 전지전능하신 ChatGPT에 물어보았다.

영속성 컨텍스트와 세션의 관계
JPA와 Hibernate를 사용할 때, 영속성 컨텍스트는 데이터의 영속적 저장과 관리를 위한 추상화된 개념입니다. Hibernate에서 이 개념은 세션을 통해 구현되며, 실제로 애플리케이션과 데이터베이스 사이에서 엔티티의 생명 주기, 캐싱, 트랜잭션 관리 등의 중요한 역할을 담당합니다. 세션을 통해 영속성 컨텍스트에 접근하고, 엔티티의 상태를 관리하며, 데이터베이스와의 상호 작용을 수행합니다.

이 답변을 듣고 이렇게 생각했다.

Filter 단계에서 영속성 컨텍스트에 User를 올렸지만, 필터 처리 이후는 세션과 영속성 컨텍스트가 닫혔고, 새롭게 service의 트랜잭션 안에서 열린 세션과 영속성 컨텍스트는 User를 올렸던 것과 다른 세션이다!

그래서 필터단에서 User를 직접 저장해주는게 아닌, 인가를 위한 UserSimple 인스턴스를 저장 해주고 service 단의 트랜잭션 안에서 새롭게 User 객체를 영속성 컨텍스트에 올려보자는 생각을 하게 되었다.

정리하자면
1. Filter 단에서는 User가 아닌 UserSimple을 올려서 인가 처리를 수행한다.
2. 실제 메소드가 호출된 트랜잭션 안에서 SecurityContextHolder에 접근해서 userId를 획득하고, 이것으로 User를 조회하여 User를 영속 상태로 만든다.

적용하기

User를 임시로 대체 할 UserSimple 클래스를 만들어주었다.

// UserSimple.java
@Getter
@Setter
@AllArgsConstructor
public class UserSimple {

    private Long userId;
    private Long kakaoId;
    private String roles;


    public List<String> getRoleList(){
        if(this.roles.length() > 0){
            return Arrays.asList(this.roles.split(","));
        }
        return new ArrayList<>();
    }
}

User의 정보중에 인가(Authorization)부분에 꼭 필요한 정보들만 넣어주었다.

그리고 PrincipalDetails에서 User 대신 UserSimple로 수정해주었다.

인가 필터에서는 User대신 UserSimple로 SpringContextHolder에 저장해주게 된다.

//AuthorizationFilter.java

private Authentication getAuthentication(String id) {

        User user = userRepository.findById(Long.parseLong(id)).orElseThrow(() -> new UsernameNotFoundException("User not found"));
        UserSimple userSimple = new UserSimple(user.getUserId(), user.getKakaoId(), user.getRoles());
        // 진짜 User를 넣어주는게 아닌 UserSimple을 넣어준다.
        PrincipalDetails principalDetails = new PrincipalDetails(userSimple);
        // authentication 객체 만들기 with principalDetails, null, authorities
        return new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
    }

UserService에서 getUser를 아래처럼 수정해주었다.
User를 새롭게 Repository에서 조회하여 해당 트랜잭션 안에서 영속성 컨텍스트에 올렸다.

// UserService.java

public User getUser(){
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        // authentication에서 PrincipalDetails 꺼내기
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        UserSimple userSimple = principalDetails.getUser();
        Long userId = userSimple.getUserId();
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        return user;
    }

아래는 기존과 동일한 getZzimListByUser() 메소드이지만, 이제는 ZzimList를 가져오는게 가능하다
한 트랜잭션 안에서 User를 꺼내왔으므로, 영속성 컨텍스트와 Hibernate의 세션이 유지되어 프록시 객체를 호출 할 수 있기 때문!

// UserService.java
// 기존과 동일
public List<ZzimDto> getZzimListByUser() {
        User user = getUser();

        List<ZzimDto> zzimList = user.getZzimList().stream()
                .map(zzim -> new ZzimDto(zzim.getDong().getDongId(), zzim.getDong().getDongName()))
                .collect(Collectors.toList());

        return zzimList;
    }

결과

해결 완료!

결론

필터단과 컨트롤러 이후의 영속성 컨텍스트와 Hibernate Session은 다르다!

profile
백엔드 꿈나무

1개의 댓글

comment-user-thumbnail
2024년 3월 25일

정말 멋진 포스트네요!
잘 읽었습니다.

답글 달기