Post

LazyInitializationException 과 SecurityContextHolder

이전과 다른이유로 또 마주친 LazyInitializationException. @EAGER 을 사용하지 않고 해결해보자.

1. LazyInitializationException 의 발생

소셜로그인 코드를 작성하고 실행했더니 아래와 같은 오류가 나타났습니다. 다들 한번쯤은 겪는다는 LazyInitalizationException 이였는데요, 왜 이런 오류가 발생했던 걸까요? 🤔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2024-06-24T13:57:32.112+09:00 DEBUG 15460 --- [nio-8080-exec-3] o.s.web.client.RestTemplate              : Reading to [java.util.Map<java.lang.String, java.lang.Object>]
2024-06-24T13:57:32.113+09:00  INFO 15460 --- [nio-8080-exec-3] c.p.t.g.a.o.s.CustomOAuth2UserService    : ============================================================================
2024-06-24T13:57:32.113+09:00  INFO 15460 --- [nio-8080-exec-3] c.p.t.g.a.o.s.CustomOAuth2UserService    : getAttributes: {sub=107260565118160509484, name=김시은, given_name=시은, family_name=김, picture=https://lh3.googleusercontent.com/a/ACg8ocJS5We9Pzn4BvwlL4vnypALLCHvg3VrHNLkOFRL0SZ8XwX5gA=s96-c, email=wldsmtldsm65@gmail.com, email_verified=true}
2024-06-24T13:57:32.113+09:00  INFO 15460 --- [nio-8080-exec-3] c.p.t.g.a.o.s.CustomOAuth2UserService    : ============================================================================
2024-06-24T13:57:32.113+09:00  INFO 15460 --- [nio-8080-exec-3] c.p.t.g.a.o.e.OAuth2UserInfoFactory      : =============================================================
2024-06-24T13:57:32.113+09:00  INFO 15460 --- [nio-8080-exec-3] c.p.t.g.a.o.e.OAuth2UserInfoFactory      : Google login Request sent
2024-06-24T13:57:32.113+09:00  INFO 15460 --- [nio-8080-exec-3] c.p.t.g.a.o.e.OAuth2UserInfoFactory      : =============================================================
Hibernate: select u1_0.id,u1_0.birthday,u1_0.email,u1_0.is_withdrawal,u1_0.nickname,u1_0.password,u1_0.profile_id,u1_0.provider,u1_0.role,u1_0.sex,u1_0.signup_date,u1_0.user_tag from user u1_0 where u1_0.email=? and u1_0.provider=?
2024-06-24T13:57:32.135+09:00 DEBUG 15460 --- [nio-8080-exec-3] .s.ChangeSessionIdAuthenticationStrategy : Changed session id from 9390C8B22997E5E31616A5D9A48C061A
2024-06-24T13:57:32.135+09:00 DEBUG 15460 --- [nio-8080-exec-3] .s.o.c.w.OAuth2LoginAuthenticationFilter : Set SecurityContextHolder to OAuth2AuthenticationToken [Principal=com.planner.travel.global.auth.oauth.entity.CustomOAuth2User@2459db7b, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=9390C8B22997E5E31616A5D9A48C061A], Granted Authorities=[ROLE_USER]]
2024-06-24T13:57:32.136+09:00  INFO 15460 --- [nio-8080-exec-3] a.o.h.OAuth2AuthenticationSuccessHandler : ============================================================================
2024-06-24T13:57:32.136+09:00  INFO 15460 --- [nio-8080-exec-3] a.o.h.OAuth2AuthenticationSuccessHandler : Social login successful. Social type is google
2024-06-24T13:57:32.136+09:00  INFO 15460 --- [nio-8080-exec-3] a.o.h.OAuth2AuthenticationSuccessHandler : ============================================================================
2024-06-24T13:57:32.139+09:00 ERROR 15460 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

org.hibernate.LazyInitializationException: could not initialize proxy [com.planner.travel.domain.profile.entity.Profile#2] - no Session
	at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:165) ~[hibernate-core-6.4.4.Final.jar:6.4.4.Final]
	at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:314) ~[hibernate-core-6.4.4.Final.jar:6.4.4.Final]
	at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:44) ~[hibernate-core-6.4.4.Final.jar:6.4.4.Final]
	at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:102) ~[hibernate-core-6.4.4.Final.jar:6.4.4.Final]
	at com.planner.travel.domain.profile.entity.Profile$HibernateProxy$MzhvZwtd.getProfileImageUrl(Unknown Source) ~[main/:na]
	at com.planner.travel.global.auth.oauth.handler.OAuth2AuthenticationSuccessHandler.setRedirectUrl(OAuth2AuthenticationSuccessHandler.java:69) ~[main/:na]
	at com.planner.travel.global.auth.oauth.handler.OAuth2AuthenticationSuccessHandler.onAuthenticationSuccess(OAuth2AuthenticationSuccessHandler.java:58) ~[main/:na]



2. profileImageUrl 호출 시 Exception 발생! 🤢

CustomOAuth2User 에서 User 엔티티는 정상적으로 불러 온다는 것을 확인했습니다. 하지만 User 와 관련된 Profile 의 필드 값에 접근할 때 LazyInitalizationException 이 발생하였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private String PRE_FRONT_REDIRECT_URL = "http://localhost:5173";
    private final ObjectMapper objectMapper;
    private final UserRepository userRepository;
    private final TokenGenerator tokenGenerator;
    private final CookieUtil cookieUtil;
    private final RedisUtil redisUtil;

    @Override
    @Transactional
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        String provider = customOAuth2User.getUser().getProvider();
//        String email = getEmailByProvider(provider, customOAuth2User);

        if (response.isCommitted()) {
            log.info("============================================================================");
            log.info("Social login response has been successfully sent.");
            log.info("============================================================================");
        }

        log.info("============================================================================");
        log.info("Social login successful. Social type is {}", provider);
        log.info("============================================================================");

        String accessToken = tokenGenerator.generateToken(TokenType.ACCESS, String.valueOf(customOAuth2User.getUser().getId()));
        String refreshToken = tokenGenerator.generateToken(TokenType.REFRESH, String.valueOf(customOAuth2User.getUser().getId()));

        response.setHeader("Authorization", "Bearer " + accessToken);
        cookieUtil.setCookie("refreshToken", refreshToken, response);
        redisUtil.setData(String.valueOf(customOAuth2User.getUser().getId()), refreshToken);

        String frontendRedirectUrl = setRedirectUrl(customOAuth2User, accessToken);

        response.sendRedirect(frontendRedirectUrl);
    }

    private String setRedirectUrl (CustomOAuth2User customOAuth2User, String accessToken) {
        String encodedUserId = URLEncoder.encode(String.valueOf(customOAuth2User.getUser().getId()), StandardCharsets.UTF_8);
        String encodedNickname = URLEncoder.encode(String.valueOf(customOAuth2User.getUser().getNickname()), StandardCharsets.UTF_8);
        String encodedUserTag = URLEncoder.encode(String.valueOf(customOAuth2User.getUser().getUserTag()), StandardCharsets.UTF_8);
        String encodedBirthday = URLEncoder.encode(String.valueOf(customOAuth2User.getUser().getBirthday()), StandardCharsets.UTF_8);
        String encodedEmail = URLEncoder.encode(String.valueOf(customOAuth2User.getUser().getEmail()), StandardCharsets.UTF_8);
        String encodedProfileImgUrl = URLEncoder.encode(String.valueOf(customOAuth2User.getUser().getProfile().getProfileImageUrl()), StandardCharsets.UTF_8);
        String encodedIsBirthDay = URLEncoder.encode(String.valueOf(isBirthdayToday(customOAuth2User.getUser().getBirthday())));
        String encodedSex = URLEncoder.encode(String.valueOf(customOAuth2User.getUser().getSex()));

        String frontendRedirectUrl = String.format(
                "%s/oauth/callback?token=%s&userId=%s&nickname=%s&userTag=%s&birthday=%s&email=%s&profileImgUrl=%s&isBirthday=%s&sex=%s",
                PRE_FRONT_REDIRECT_URL,
                "Bearer " + accessToken,
                encodedUserId,
                encodedNickname,
                encodedUserTag,
                encodedBirthday,
                encodedEmail,
                encodedProfileImgUrl,
                encodedIsBirthDay,
                encodedSex
        );

        return frontendRedirectUrl;
    }

    private boolean isBirthdayToday(LocalDate birthday) {
        return birthday != null && birthday.getMonth() == LocalDate.now().getMonth() &&
                birthday.getDayOfMonth() == LocalDate.now().getDayOfMonth();
    }
}

2.1. LazyInitalizationException 이 뭔데?

LazyInitializationException 은 Hibernate 에서 지연로딩된 엔티티를 세션이 종료된 후 접근하려고 할 때 발생하는 예외입니다. 성능 최적화를 위해 연관된 데이터를 실제로 필요할 때 까지 로딩하지 않는 방식이 지연 로딩인데, 이 때 Hibernate 는 연관 엔티티에 대한 참조만을 proxy 객체로 반환합니다. 이후 해당 프록시 객체의 필드에 접근하려고 할 때 데이터베이스에서 실제 데이터를 가져옵니다.하지만 이 때 세션이 이미 종료되었다면 Hibernate 는 프록시 객체를 초기화할 수 없기 때문에 예외가 발생하게 됩니다. 데이터베이스와의 연결이 끊겼기 때문이죠.

2.2. 그래서 어떻게 해결 해야할까?

방법은 세가지가 있습니다. 첫번째는 User 와 Profile 의 관계에서 Fetch Type 을 Lazy 대신 EAGER 를 사용하는 것입니다. 하지만 이전에 갓영한님의 강의를 들었을 때 쓰지말라고 했으므로, 그냥 쓰지 않기로 했습니다.

다음으로는 Hibernate.initialize() 를 사용하는 것입니다. 이를 사용하면 트랜잭션이 열려 있는 동안 해당 프록시를 초기화할 수 있습니다. 하지만 이 방법은 사용해 본적이 없으므로 패스.

결론적으로 저는 마지막 방식을 사용했습니다. 이미 세션이 종료된 후라 Profile 을 초기화할 수 없는것이 문제이기 때문에 아래와 같이 User 엔티티를 다시 로드해주었습니다.

1
2
User user = userRepository.findById(customOAuth2User.getUser().getId())
                .orElseThrow(EntityNotFoundException::new);

이렇게 다시 User 엔티티를 명시적으로 로딩 했습니다. 이렇게 하면 연관된 엔티티들도 함께 로드됩니다. 결론적으로 트랜잭션 범위 내에서 이 작업이 수행되기 때문에 문제였던 예외가 발생하지 않습니다. 이는 DB 를 한번 더 타긴 하지만 성능에 큰 영향을 주지 않기 때문에 이와 같이 해결했습니다. 하지만 더 나은 방법이 있을 수도 있기에 proxy, transaction, session 에 대해 더 공부해야겠습니다.



3. 최종코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private String PRE_FRONT_REDIRECT_URL = "http://localhost:5173";
    private final ObjectMapper objectMapper;
    private final UserRepository userRepository;
    private final TokenGenerator tokenGenerator;
    private final CookieUtil cookieUtil;
    private final RedisUtil redisUtil;

    @Override
    @Transactional
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        String provider = customOAuth2User.getUser().getProvider();
//        String email = getEmailByProvider(provider, customOAuth2User);

        User user = userRepository.findById(customOAuth2User.getUser().getId())
                .orElseThrow(EntityNotFoundException::new);

        if (response.isCommitted()) {
            log.info("============================================================================");
            log.info("Social login response has been successfully sent.");
            log.info("============================================================================");
        }

        log.info("============================================================================");
        log.info("Social login successful. Social type is {}", provider);
        log.info("============================================================================");

        String accessToken = tokenGenerator.generateToken(TokenType.ACCESS, String.valueOf(user.getId()));
        String refreshToken = tokenGenerator.generateToken(TokenType.REFRESH, String.valueOf(user.getId()));

        response.setHeader("Authorization", "Bearer " + accessToken);
        cookieUtil.setCookie("refreshToken", refreshToken, response);
        redisUtil.setData(String.valueOf(user.getId()), refreshToken);

        UserInfoResponse userInfoResponse = new UserInfoResponse(
                user.getId(),
                user.getNickname(),
                user.getUserTag(),
                user.getBirthday(),
                user.getEmail(),
                user.getProfile().getProfileImageUrl(),
                isBirthdayToday(user.getBirthday()),
                user.getSex()
        );

        String frontendRedirectUrl = setRedirectUrl(userInfoResponse, accessToken);

        response.sendRedirect(frontendRedirectUrl);
    }

    public String setRedirectUrl (UserInfoResponse userInfoResponse, String accessToken) {
        String encodedUserId = URLEncoder.encode(String.valueOf(userInfoResponse.userId()), StandardCharsets.UTF_8);
        String encodedNickname = URLEncoder.encode(String.valueOf(userInfoResponse.nickname()), StandardCharsets.UTF_8);
        String encodedUserTag = URLEncoder.encode(String.valueOf(userInfoResponse.userTag()), StandardCharsets.UTF_8);
        String encodedBirthday = URLEncoder.encode(String.valueOf(userInfoResponse.birthday()), StandardCharsets.UTF_8);
        String encodedEmail = URLEncoder.encode(String.valueOf(userInfoResponse.email()), StandardCharsets.UTF_8);
        String encodedProfileImgUrl = URLEncoder.encode(String.valueOf(userInfoResponse.profileImgUrl()), StandardCharsets.UTF_8);
        String encodedIsBirthDay = URLEncoder.encode(String.valueOf(userInfoResponse.isBirthday()));
        String encodedSex = URLEncoder.encode(String.valueOf(userInfoResponse.sex()));

        String frontendRedirectUrl = String.format(
                "%s/oauth/callback?token=%s&userId=%s&nickname=%s&userTag=%s&birthday=%s&email=%s&profileImgUrl=%s&isBirthday=%s&sex=%s",
                PRE_FRONT_REDIRECT_URL,
                "Bearer " + accessToken,
                encodedUserId,
                encodedNickname,
                encodedUserTag,
                encodedBirthday,
                encodedEmail,
                encodedProfileImgUrl,
                encodedIsBirthDay,
                encodedSex
        );

        return frontendRedirectUrl;
    }

    private boolean isBirthdayToday(LocalDate birthday) {
        return birthday != null && birthday.getMonth() == LocalDate.now().getMonth() &&
                birthday.getDayOfMonth() == LocalDate.now().getDayOfMonth();
    }
}
This post is licensed under CC BY 4.0 by the author.