들어가며
토이 프로젝트를 하며 이 오류를 처음 접하게 되었다.
아래 내용을 통해 어떻게 해결하는지 알아보자
아래 코드를 보자
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final LoginEntity login;
private MemberEntity member;
@Override
public Collection<? extends GrantedAuthority> getAuthorities () {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority () {
return login.getMember().getRole().getCode(); // !!문제 부분!!
}
});
return collection;
}
}
위 코드를 보자 위 코드는 SpringSecurity 클래스를 사용하여 로그인을 처리하는 로직 중 하나이다.
getAuthorities() 메소드를 통해 로그인 시 처리할 Role 값을 얻어야 했다.
필자는 한 테이블에 user 정보 및 로그인 정보를 담기보다는 User 테이블 따로 Login 테이블 따로 관리하는 방법을 택했다.
그래서 Login 테이블에 로그인ID, Password 를 저장하는 방법을 사용하였다.
아래 Entity 를 보자.
@Getter
@EntityListeners(AuditingEntityListener.class)
@Table(name = "login")
@Entity
public class LoginEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name="member_id")
private MemberEntity member;
// 다른 필드 생략
}
로그인 엔티티를 작성한 로직이다.
JPA 는 직접 해보면서 공부하자는 마인드로 여러 블로그를 참조하였고, 90% 이상의 블로그에서
연관관계에서 자식 엔티티에 속하는 연관관계 부분에 @XToOne(fetch = FetchType.LAZY)
즉 One 으로 끝나는 어노테이션에는 Lazy 로딩하게 작성이 되있는걸 보았고, 나도 그걸 활용하였다.
아래 부모 엔티티를 보자
@Builder
@NoArgsConstructor @AllArgsConstructor
@Getter
@Table(name="member")
@Entity
public class MemberEntity {
@OneToOne(mappedBy = "member")
private LoginEntity login;
}
부모 엔티티 또한 자식 엔티티를 참조하기 위한 1:1 관계를 의미하는 @OneToOne 어노테이션을 사용하였다.
얼핏보기에는 문제가 없어 보인다.
여러분들은 뭐가 문제인지 알겠는가?
필자는 오류를 보기 전까지 전혀 몰랐다..
could not initialize proxy [org.petmeet.http.db.member.MemberEntity#2] - no Session
대충 요약해서 위 오류 메세지가 나왔다. 왜 이 에러 메시지가 나왔을까? 본격적으로 봐보자
본론
근본적으로 Hibernate 가 EntityManager 를 통해 세션을 생성을 했고 프록시 객체를 생성했지만,
그 생성된 프록시 객체를 세션이 끝난 후에 사용하려고 했기 때문에 위 문제가 발생한 것이다.
아래서 자세히 보자.
엔티티에서 연관관계를 맺을때 @OneToOne, @ManyToOne 등 어노테이션을 사용한다.
JPA에서 엔티티 간의 연관 관계를 설정할 때, 패치 전략(fetch strategy)은 데이터베이스에서
데이터를 가져오는 방식을 결정하는 중요한 설정이다.
위 어노테이션은 기본적으로 구현이 fetch 전략이 Eager 로 되어있다.
그래서 연관관계를 맺은 엔티티들이 조회를 할 때마다 Join 을 걸어서 불필요한 조회를 하게 된다.
내 상황을 비유하자면 그냥 간단한 Member 를 조회하려고 api 를 만들면 login 정보는 필요가 없는데
연관된 엔티티인 Login 엔티티도 매번 같이 조회가 된다는 말이다. 즉 불필요한 Join 으로 인한 성능이 안좋아질 것이다.
그에 비해 Lazy 전략은 내가 필요할 때 까지 연관 엔티티를 로드하지않고 , 나중에 필요할때 직접 작업을 해야지
연관 엔티티를 Join 후 조회를 하는 것이다.
즉 Lazy 는 필요할 때만 Join 을 사용하므로 원래 이게 맞다고 생각한다.
그에 반해 @OneToMany, @ManyToMany 처럼 1:다 관계일 경우는 기본이 Lazy 전략을 사용한다.
그럼 이제 본론으로 돌아가보자
위 문제가 왜 발생할까?
LoginEntity 클래스에서 member 필드는 FetchType.LAZY로 설정되어 있다.
이는 login 엔티티를 처음 로드할 때 member 엔티티가 로드되지 않고 프록시 객체로 남아 있음을 의미합니다.
CustomUserDetails 클래스에서 login.getMember().getRole().getCode() 메서드를 호출할 때,
member 엔티티에 접근한다. 이 시점에서 member는 아직 로드되지 않았고,
로드하려고 시도하면 이미 트랜잭션이 발생한 직후로 member 는 비어있는 값으로 되어있는 것으로 추정된다.
결과적으로 프록시 객체를 초기화하려고 시도할 때 LazyInitializationException이 발생합니다.
즉 JPA 엔티티가 트랜잭션 범위 밖에서 작동해서 접근해서 발생하는 것 같다.
지연 로딩으로 설정한 연관 객체를 사용해야 하는 경우 EntityManger 를 종료하기 전에 연관된 객체에 접근해야 한다.
위 문제를 어떻게 해결해야 할까?
1) fetch 전략 Eager 로 변경
처음으로 떠오른게 그냥 간단하게 fetch 전략을 Eager로 하면 되는거 아닌가? 라는 생각이 들었다.
근데 이거는 최후의 수단이다.
제일 쉬운 방법인 만큼 나중에 후폭풍이 분명 있을 것이다.
2) @Transactional 을 사용해서 트랜잭션 범위로 만들어 버린다.
위 방법을 사용하였지만, 해결이 되지 않았다... 추후 다시 다뤄보는걸로
3) Entity 가 아닌 DTO 를 만들어서 전달한다.
저는 위 방법을 사용해서 해결을 했습니다. 제일 보편적으로 사용하는 방식이라고 합니다.
@Getter
@Builder
public class MemberDTO {
private Role role;
public static MemberDTO from(MemberEntity member) {
return MemberDTO.builder()
.role(member.getRole())
.build();
}
}
MemberEntity 를 DTO 로 만들어서 처리하게 만들었다. 그 후 비즈니스 로직에 담았다.
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final LoginEntity login;
private final MemberDTO memberDTO; // 추가
@Override
public Collection<? extends GrantedAuthority> getAuthorities () {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority () {
return memberDTO.getRole().getCode(); // 교체
}
});
return collection;
}
}
그리고 실제 로직을 사용할 서비스인 로직에서 또한 생성자를 통한 초기화를 함으로써 에러를 해결할 수 있었다.
@Slf4j
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
private final LoginRepository loginRepository;
@Transactional
@Override
public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException {
LoginEntity login = loginRepository.findByUsername(username)
.orElseThrow( () -> new UsernameNotFoundException(username + "Not Found"));
// 바뀐 부분
MemberEntity member = login.getMember();
MemberDTO memberDTO = MemberDTO.from(member);
return new CustomUserDetails(login, memberDTO);
}
}
결론
역시 맞으면서 배우고 삽질을 해보니 어지간해서 까먹지 않을 것 같다.
역시 뭐든 경험이 최고인 것 같다.
실무도 중요하지만, 역시 이론이 뒷받침된 실무가 제일 좋은 것 같다는 생각이 들었고
에러를 해결하기 위해 전반적인 JPA의 개념을 한번 더 잡아서 좋았으며
대표적으로 JPA 영속성 컨텍스트의 프록시 -> Lazy Loading 개념을 확실히 이해했다.
추후 같은 에러가 발생해도 금방 해결할 수 있을 것 같은 자신감을 얻었다..ㅎㅎ