티스토리 뷰

안녕하세요.

spring-session-data-redis와 JPA를 함께 사용하면서 LazyInitializationException 예외가 발생했습니다.

이를 해결하기 위해 공부했던 내용을 정리해봤습니다.

LazyInitializationException 발생

 

문제 상황 

먼저 문제가 발생한 당시의 상황을 정리해보겠습니다.

  • 세션 기반 로그인 매커니즘
  • spring-session-data-redis를 사용
  • JPA Entity를 세션 저장소(redis)에 저장함 

 

사용자가 로그인을 요청하면 MemberLoginService.login() 메서드가 호출됩니다. login() 메서드는 Member 엔티티를 세션에 저장합니다. spring-session-data-redis를 활용하여 WAS가 아닌 Redis에 Member 엔티티가 저장되도록 만들어 주었습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberLoginService {
	private final MemberFindService memberFindService;
	private final PasswordEncoder passwordEncoder;

	@Transactional
	public MemberLoginResponseDto login(HttpServletRequest request, MemberLoginRequestDto dto) {
		Member member = memberFindService.findActiveMember(dto.getEmail());
		validatePassword(dto.getPassword(), member.getPassword());
		HttpSession session = request.getSession(true);
		session.setAttribute(LoginConstant.LOGIN_SESSION_ATTRIBUTE, member);
		return new MemberLoginResponseDto(member);;
	}

	private void validatePassword(String rawPassword, String encryptedPassword) {
		if (!passwordEncoder.matches(rawPassword, encryptedPassword)) {
			throw new AuthenticationException(ErrorCode.WRONG_ACCOUNT);
		}
	}
}

 

MyBatis를 사용할 때는 모든 동작이 잘 동작했습니다. 하지만 JPA 로 변경하니 문제가 발생했습니다. 로그인 시도 후, 이어지는 모든 요청에 대해 LazyInitializationException 예외가 발생하게 됐습니다.

 

이 글을 통해서 다음 3가지를 정리해보고 궁극적으로 SerializationException 발생 원인에 대해 알아보겠습니다.

  • JPA 프록시
  • HttpServlet 아키텍처
  • spring-session 아키텍처

 

JPA 프록시

Member 엔티티와 연관관계를 맺고 있는 candidates, notifications 객체의 fetch 속성을 FetchType.LAZY로 지정하였습니다. 이렇게 되면 연관 객체(candidates, notifications)에 프록시 객체가 들어가게 됩니다. candidates, notifications 객체가 프록시임을 기억해주세요.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {
	@Id
	@Column(name = "member_id")
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	...

	@OneToOne(mappedBy = "member", cascade = CascadeType.ALL, optional = false)
	private GameAccount gameAccount;

	@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
	private List<Candidate> candidates;

	@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
	private List<Notification> notifications;
	
    ...
}

 

프록시 객체를 사용하면 실제 엔티티가 사용될 때 까지 데이터베이스 조회를 미룰 수 있게 됩니다. 실제 엔티티를 사용하는 시점에 데이터베이스를 조회해해서 엔티티 객체를 생성하는 것을 프록시 객체의 초기화라고 합니다. 초기화는 영속성 컨텍스트의 도움을 받아야 가능합니다. 따라서 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태에선 프록시를 초기화할 수 없습니다. 만약 초기화를 시도하면 하이버네이트는 org.hibernate.LazyInitializationException 예외를 발생시킵니다.

 

spring-session 아키텍처

gralde에 org.springframework.session:spring-session-data-redis 의존성을 추가하기만 해도 SessionRepositoryFilter가 등록됩니다. SessionRepositoryFilter의 주기능은 간단합니다. 단지 request, response를 SessionRepositoryRequestWrapper, SessionRepositoryResponseWrapper 로 감싸주는 역할을 합니다.

 

SessionRepositoryRequestWrapper 는 HttpServletRequest 를 구현하고 있습니다. getSession()을 오버라이딩하고 있는데요. 커스터마이징한 세션 저장소(redis)에서 세션 정보를 가져올 수 있게끔 하기 위함입니다.

 

세션 저장소에 데이터가 어떤 구조로 저장되는지도 잠깐 살펴보겠습니다.

Redis에는 Strings, Lists, Hashes, Sets 등의 자료구조가 지원되며, 확인해보니 Hashes 구조로 저장된 것을 알 수 있었습니다. Hashes 타입의 세션 데이터는 다음과 같은 subkey를 가지고 있었습니다.

  • lastAccessedTime : 마지막 세션 조회 시간
  • sessionAttr : 세션에 저장한 데이터
  • creationTime : 세션 생성 시간
  • maxInactiveInterval : 만료 시간

 

세션 저장 데이터는 직렬화되어 Json 형태의 문자열로 저장된 모습입니다.

 

HttpServlet 아키텍처

Servlet이란 클라이언트의 요청을 받아 이를 처리하고 결과를 반환하는 자바의 웹 프로그래밍 기술을 말합니다. 웹 애플리케이션 서버 (WAS)의 Servlet Container가 Servlet을 가지고 있습니다. 흔히 사용되는 Tomcat이 이러한 컨테이너의 역할을 수행하는 대표적인 소프트웨어입니다. Servlet은 Java Thread를 이용하여 동작하며 멀티쓰레딩이 가능합니다.

서블릿은 클라이언트 요청에 따라 service() 메서드를 호출하고, service() 메서드는 요청에 따라 doGet(), doPost(), doPut(), doDelete().. 를 호출합니다.

출처:&nbsp;https://github.com/junu0516/mytil/blob/main/Java/servlet.md

 

사실 요청이 HttpServlet에 도달하기 전에 거치는 곳이 더 있습니다. 바로 Filter 입니다. 앞서 다룬 SessionRepositoryFilter도 거치게 됩니다. 전체적인 동작 과정을 정리해보면 다음과 같습니다.

 

FrameworkServlet은 publishRequestHandledEvent()를 호출합니다. 여기서 주목할 점은 내부적으로 request.getSession()을 호출한다는 점입니다.

이 때 요청은 이미 SessionRepositoryFilter를 거친 후이기 때문에 request는 SessionRepositoryRequestWrapper로 감싸져 있는 상태입니다. 따라서 오버라이딩된 getssion()이 호출되며, WAS가 아닌 redis로부터 세션 정보를 가져오게 됩니다. 이 때, redis로부터 가져온 데이터를 역직렬화하게 되는데 내부적으로 ObjectMapper.readValue를 사용하며, 다음 그림과 같이 Collection 타입인 객체들의 원소들에 접근하게 됩니다. 만약 Collection 객체가 지연로딩을 통해 할당받은 프록시라면 원소에 접근하고 있으므로 초기화를 수행할 것입니다. 하지만, 이 시점은 영속성 컨텍스트의 도움을 받을 수 없기 때문에 LazyInitializationException 예외가 발생하게 됩니다.

ObjectMapper가 데이터를 역직렬화할 때 사용하는 ManagedReferenceProperty 클래스

 

해결 방법

특별한 해결 방법은 찾지 못했습니다. 스택오버플로우에서 비슷한 질문을 찾긴 했는데, 마땅한 답변이 달리진 않았네요. https://stackoverflow.com/questions/65976639/lazy-session-fetching-with-spring-redis

 

그래서 세션에 엔티티를 저장하지 않기로 했습니다. 대신 세션에 저장할 필요가 있는 정보만을 추려서 별도의 클래스로 만들었습니다.

public class MemberLoginSessionData {
	private Long memberId;
	private String lolId;
	private String email;

	public MemberLoginResponseDto(Member member) {
		this.memberId = member.getId();
		this.lolId = member.getGameAccount().getLolId();
		this.email = member.getEmail();
	}
}

 

오늘은 spring-session, JPA를 사용하면서 발생한 LazyInitializationException의 원인과 해결 방법을 알아보았습니다. 그리고 그 과정에서 JPA 프록시, HttpServlet 아키텍처, spring-session 아키텍처를 정리해봤습니다.

더 좋은 해결 방법을 알고 계신다면 댓글에 남겨주세요..!

 

참고자료

댓글