Lazyinitializationexception 트러블 슈팅

박진선·2023년 6월 14일
0
post-custom-banner

문제 상황

JWT 검증 Filter 에서 JWT를 파싱하여 얻은 Email 정보로 MemberRepository를 호출해 Member 엔티티를 조회하여 SecurityContext 에 저장하기 위해 아래 코드와 같이 작성하였는데 Lazy Loading 이 설정된 멤버의 권한정보가 저장된 roles 필드에 접근 시 Lazyinitializationexception 이 발생했다.

@Entity
public class Member{
	...
    ...
  @ElementCollection(fetch = FetchType.LAZY)
  @CollectionTable(name = "role_member", joinColumns = @JoinColumn(name = "member_id", referencedColumnName = "id"))
  @Column(name = "role")
  private List<String> roles = new ArrayList<>();
}


public void setAuthenticationToContext(Claims claims) {
    Member member = memberRepository.findByEmail(claims.getSubject()).orElseThrow(); //Subject 에 Email 정보가 저장되어있다.
    
    List<GrantedAuthority> authorities = customAuthorityUtils.createAuthority(member.getRoles());
    
    UsernamePasswordAuthenticationToken authenticatedToken =
      UsernamePasswordAuthenticationToken.authenticated(member, null, authorities);
      
    SecurityContextHolder.getContext().setAuthentication(authenticatedToken);
  }

Repository 에서 Member를 조회 후 곧 바로 영속성 컨텍스트가 종료되어 Lazy loading 설정된 roles 필드에 접근시 Lazyinitializationexception 이 발생한 것이었다.

의문점 및 궁금점

  • 서비스 계층에서 Repository 계층을 호출해 엔티티를 조회 후 컨트롤러로 반환하더라도 예외 발생 없이 Lazy loading 설정된 연관관계에 접근이 가능한데 Filter 에서는 왜 곧 바로 영속성 컨텍스트가 종료되는 것일까?

OSIV

의문점의 해답은 OSIV에 있었다.

OSIV(Open Session In View) 란 영속성 컨텍스트를 뷰까지 열어두는 기능을 말한다. 뷰에서도 지연 로딩을 사용할 수 있어 스프링부트를 사용 시 OSIV 를 기본적으로 true로 설정하고 있다.
트랜잭션 시작시 최초 데이터베이스 커넥션 시작 시점부터 API응답이 끝날 때(View) 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.

동작원리
1. 클라이언트 요청이 들어오면 서블릿 필터 혹은 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단 이때 트랜잭션은 시작하지 않는다.
2. 서비스 계층에서 @Transactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
3. 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 트랜잭션은 끝내지만 영속성 컨텍스트는 종료하지 않는다.
4. 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
5. 서블릿 필터나 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 단 이때 플러시를 호출하지 않고 바로 종료한다.

스프링 컨테이너는 JPA의 영속성 컨텍스트를 지원할 때, 기본 전략으로 트랜잭션 범위의 영속성 컨텍스트를 사용한다. 즉 트랜잭션을 시작할 때 영속성 컨텍스트가 생성되고 종료되면 같이 소멸하는 것이다. 하지만 스프링 부트를 사용하면 OSIV 를 기본적으로 true로 설정하기 때문에 컨트롤러 계층에서도 LazyLoading 설정된 연관관계에 접근이 가능했던 것이다.

아래의 코드는 스프링부트 에서 OpenEntityManagerInViewInterceptor 를 생성하여 Bean으로 등록하는 로직이다. @ConditionalOnProperty 어노테이션을 보면 YML 파일 기준으로 spring:jpa:open-in-view: true 설정이 되어야만 Bean으로 등록되며 스프링부트를 사용할 경우 기본값이 true 이기 때문에 별다른 설정이 없으면 Bean으로 등록된다.

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(JpaProperties.class)
public abstract class JpaBaseConfiguration implements BeanFactoryAware {
	...
    ...
    @Configuration(proxyBeanMethods = false)
	@ConditionalOnWebApplication(type = Type.SERVLET)
	@ConditionalOnClass(WebMvcConfigurer.class)
	@ConditionalOnMissingBean({ OpenEntityManagerInViewInterceptor.class, OpenEntityManagerInViewFilter.class })
	@ConditionalOnMissingFilterBean(OpenEntityManagerInViewFilter.class)
	@ConditionalOnProperty(prefix = "spring.jpa", name = "open-in-view", havingValue = "true", matchIfMissing = true)
	protected static class JpaWebConfiguration {
    	private static final Log logger = LogFactory.getLog(JpaWebConfiguration.class);

		private final JpaProperties jpaProperties;

		protected JpaWebConfiguration(JpaProperties jpaProperties) {
			this.jpaProperties = jpaProperties;
		}

		@Bean
		public OpenEntityManagerInViewInterceptor openEntityManagerInViewInterceptor() {
			if (this.jpaProperties.getOpenInView() == null) {
				logger.warn("spring.jpa.open-in-view is enabled by default. "
						+ "Therefore, database queries may be performed during view "
						+ "rendering. Explicitly configure spring.jpa.open-in-view to disable this warning");
			}
			return new OpenEntityManagerInViewInterceptor();
		}

		@Bean
		public WebMvcConfigurer openEntityManagerInViewInterceptorConfigurer(
				OpenEntityManagerInViewInterceptor interceptor) {
			return new WebMvcConfigurer() {

				@Override
				public void addInterceptors(InterceptorRegistry registry) {
					registry.addWebRequestInterceptor(interceptor);
				}

			};
		}
    }
}

그렇다면 Filter 에서는 왜 OSIV가 적용되지 못했던 걸까? 그 이유는 인터셉터는 Filter 를 통과한 후 DispatcherServlet 에서 HandlerAdapter 를 통해 컨트롤러를 호출하기 전에 실행되기 때문이다. 즉 OpenEntityManagerInViewInterceptor 의 preHandle 메소드가 컨트롤러 호출 전에 실행되어 EntityManager를 생성하고 뷰가 렌더링된 후 afterCompletion 메소드를 실행하여 EntityManager 를 종료시키는 것이다.

해결방법

  1. OpenEntityManagerInViewFilter 를 ApplicationFilterChain 에 등록하기
    해당 Filter 또한 OpenEntityManagerInViewInterceptor 와 마찬가지로 스프링 부트에서 OSIV를 지원하기 위해 제공하는 Filter 이다.
    해당 Filter를 등록하면 필터 레벨에서 EntityManager를 생성하기 때문에 JWT 검증 Filter 에서도 Lazy Loading 설정된 연관관계 필드에 접근이 가능하다. 그러하여 Filter 중 가장 먼저 실행될 수 있도록 우선순위를 두었다.
@Configuration
public class OpenEntityManagerConfig {
  
  @Bean
  public FilterRegistrationBean<OpenEntityManagerInViewFilter> registerOpenEntityManagerInViewFilter(){
    FilterRegistrationBean<OpenEntityManagerInViewFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
    filterFilterRegistrationBean.setFilter(new OpenEntityManagerInViewFilter());
    filterFilterRegistrationBean.setOrder(Integer.MIN_VALUE);
    return filterFilterRegistrationBean;
  }
}

위의 로직과 같이 OpenEntityManagerInViewFilter 를 등록하면 사진과 같이 ApplicationFilterChain에 등록된 것을 볼 수 있다.

  1. Dto 클래스를 생성후 SecurityContextHolder 에 저장하여 컨트롤러 파라미터로 제공하는 방법
    아래 로직을 보면 Member의 roles 필드를 즉시로딩으로 변환하고 AuthorizedMemberDto 라는 Dto를 생성고 Member 의 id, email을 대입하여 SecurityContextHolder 에 저장한 후 컨트롤러 파라미터로 제공한다.
@Entity
public class Member{
	...
    ...
  @ElementCollection(fetch = FetchType.EAGER)
  @CollectionTable(name = "role_member", joinColumns = @JoinColumn(name = "member_id", referencedColumnName = "id"))
  @Column(name = "role")
  private List<String> roles = new ArrayList<>();
}

public void setAuthenticationToContext(Claims claims) {
    Member member = memberRepository.findByEmail(claims.getSubject()).orElseThrow();
    AuthorizedMemberDto authorizedMemberDto = AuthorizedMemberDto.builder().id(member.getId()).email(member.getEmail()).build();
    List<GrantedAuthority> authorities = customAuthorityUtils.createAuthority(member.getRoles());
    UsernamePasswordAuthenticationToken authenticatedToken =
      UsernamePasswordAuthenticationToken.authenticated(authorizedMemberDto, null, authorities);
  
    SecurityContextHolder.getContext().setAuthentication(authenticatedToken);
  }
profile
주니어 개발자 입니다
post-custom-banner

0개의 댓글